From 055e849e1c83e18c9e53b1a5cef9fa520dd3d151 Mon Sep 17 00:00:00 2001 From: aprzn Date: Sun, 26 Feb 2023 17:33:12 -0500 Subject: [PATCH] logging & loading & directories --- ' | 394 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 54 ++++++- Cargo.toml | 4 + src/gd.rs | 81 ++++++++--- src/main.rs | 40 ++++-- src/music.rs | 37 +++-- 6 files changed, 570 insertions(+), 40 deletions(-) create mode 100644 ' diff --git a/' b/' new file mode 100644 index 0000000..66d45b9 --- /dev/null +++ b/' @@ -0,0 +1,394 @@ +#![feature(iter_array_chunks)] +#![warn(clippy::all, clippy::pedantic, clippy::nursery)] +#![allow(dead_code)] + +mod gd; +mod music; + +use eframe::egui; +use reqwest::blocking as req; +use std::boxed::Box; +use std::collections::VecDeque; +use std::error::Error; +use std::fs::File; +use std::io::Write; +use std::mem; +use thiserror::Error; + + +struct PipeDash { + project_dirs: directories::ProjectDirs, + msg_queue: VecDeque, + selected_level: Option, + level_list: Vec, + loaded_song: Option, + loaded_level_checksum: Option<(gd::OuterLevel, md5::Digest)>, + editor: Editor, + error: Option>, +} + +#[derive(Debug, PartialEq, Eq)] +enum Color { + Orange, + Yellow, + Green, +} + +#[derive(Debug)] +enum Message { + LevelSelected(usize), + CloseError, + LoadLevel, +} + +struct Editor { + state: EditorState, + data: GdlData, +} + +struct EditorState { + scroll_pos: f32, + pts_per_second: f32, // zoom level + subdivisions: f32, +} + +struct GdlData { + green_lines: music::Lines, + orange_lines: music::Lines, + yellow_lines: music::Lines, + beat_rate: music::BeatRate, + time_signatures: music::TimeSignature, +} + +struct Song { + name: String, + id: i32, + decoder: rodio::Decoder, +} + +#[derive(Error, Debug)] +enum SongError { + #[error("Not a Newgrounds song")] + NotNewgrounds, + #[error("Song mp3 couldn't be downloaded")] + MissingFile(#[from] std::io::Error), + #[error("Couldn't decode mp3 file")] + BrokenSong(#[from] rodio::decoder::DecoderError), + #[error("Couldn't access song data on servers")] + GdServerError(#[from] gd::SongRequestError), + #[error("Couldn't fetch song from Newgrounds")] + NgServerError(#[from] reqwest::Error), + #[error("Missing download link")] + MissingLink, +} + +struct BeatRateWidget<'a> { + state: &'a mut EditorState, + beat_rate: &'a mut music::BeatRate, +} +struct TimeSignatureWidget<'a> { + state: &'a mut EditorState, + time_signatures: &'a mut music::TimeSignature, +} +struct LinesWidget<'a> { + state: &'a mut EditorState, + lines: &'a mut music::Lines, + color: Color, +} +struct WaveformWidget<'a> { + state: &'a mut EditorState, + song: &'a Song, +} + +fn allocate_editor_space(ui: &mut egui::Ui) -> (egui::Rect, egui::Response) { + let max_rect = ui.max_rect(); + let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0); + ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag()) +} + +impl From for eframe::epaint::Color32 { + fn from(rhs: Color) -> Self { + match rhs { + Color::Green => Self::from_rgb(0, 255, 0), + Color::Orange => Self::from_rgb(255, 127, 0), + Color::Yellow => Self::from_rgb(255, 255, 0), + } + } +} + +impl Song { + pub fn try_new(gd_song: &gd::Song) -> Result { + if let &gd::Song::Newgrounds { id } = gd_song { + let song_response = gd_song.get_response(); + let song_path = gd::save_path().join(format!("{id}.mp3")); + + let (file, name) = match (File::open(&song_path), song_response) { + (Ok(file), response) => { + let name = response + .ok() + .and_then(|response| response.name().map(Into::into)) + .unwrap_or_default(); + + (file, name) + } + (Err(_), Ok(response)) => { + let song_blob = response + .download_link() + .ok_or(SongError::MissingLink) + .and_then(|link| Ok(req::get(link)?.bytes()?))?; + + let mut file = File::open(&song_path)?; + file.write_all(&song_blob)?; + + (file, response.name().unwrap_or("").into()) + } + (Err(err), Err(_)) => return Err(err.into()), + }; + + let decoder = rodio::Decoder::new_mp3(file)?; + + Ok(Self { name, id, decoder }) + } else { + Err(SongError::NotNewgrounds) + } + } +} + +impl Editor { + pub fn beat_rate_widget(&mut self) -> BeatRateWidget { + BeatRateWidget { + state: &mut self.state, + beat_rate: &mut self.data.beat_rate, + } + } + + pub fn time_signature_widget(&mut self) -> TimeSignatureWidget { + TimeSignatureWidget { + state: &mut self.state, + time_signatures: &mut self.data.time_signatures, + } + } + + pub fn lines_widget(&mut self, col: Color) -> LinesWidget { + LinesWidget { + state: &mut self.state, + lines: match col { + Color::Green => &mut self.data.green_lines, + Color::Yellow => &mut self.data.yellow_lines, + Color::Orange => &mut self.data.orange_lines, + }, + color: col, + } + } + + pub fn waveform_widget<'a>(&'a mut self, song: &'a Song) -> WaveformWidget { + WaveformWidget { + state: &mut self.state, + song, + } + } +} + +impl<'a> egui::Widget for BeatRateWidget<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let (rect, res) = allocate_editor_space(ui); + // handle interactions + // draw widget + if ui.is_rect_visible(rect) { + ui.painter() + .rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0)); + } + res + } +} + +impl<'a> egui::Widget for TimeSignatureWidget<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + // 1. choose size + let max_rect = ui.max_rect(); + let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0); + // 2. allocate space + let (rect, res) = ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag()); + // 3. handle interactions + // 4. draw widget + if ui.is_rect_visible(rect) { + ui.painter() + .rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0)); + } + res + } +} + +impl<'a> egui::Widget for LinesWidget<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + // 1. choose size + let max_rect = ui.max_rect(); + let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0); + // 2. allocate space + let (rect, res) = ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag()); + // 3. handle interactions + // 4. draw widget + if ui.is_rect_visible(rect) { + ui.painter() + .rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0)); + } + res + } +} + +impl<'a> egui::Widget for WaveformWidget<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + // 1. choose size + let max_rect = ui.max_rect(); + let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0); + // 2. allocate space + let (rect, res) = ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag()); + // 3. handle interactions + // 4. draw widget + if ui.is_rect_visible(rect) { + ui.painter() + .rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0)); + } + res + } +} + +impl PipeDash { + fn new(_cc: &eframe::CreationContext) -> Self { + Self { + selected_level: None, + msg_queue: VecDeque::new(), + level_list: gd::OuterLevel::load_all(), + loaded_song: None, + loaded_level_checksum: None, + error: None, + editor: Editor { + state: EditorState { + scroll_pos: 0f32, + pts_per_second: 5f32, + subdivisions: 4.0, + }, + data: GdlData { + beat_rate: music::StaticBeatRate::from_bpm(120f32).into(), + time_signatures: music::StaticTimeSignature::new(4, 4).into(), + green_lines: music::Lines::new(), + orange_lines: music::Lines::new(), + yellow_lines: music::Lines::new(), + }, + }, + } + } + + fn side_panel(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + egui::SidePanel::left("level_picker") + .default_width(100f32) + .show(ctx, |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + if ui + .add_enabled( + self.selected_level.is_some(), + egui::Button::new("Load Level"), + ) + .clicked() + { + self.msg_queue.push_back(Message::LoadLevel); + } + egui::ScrollArea::vertical().show(ui, |ui| { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| { + for (idx, level) in self.level_list.iter().enumerate() { + if ui + .selectable_label( + self.selected_level == Some(idx), + level.display_name(), + ) + .clicked() + { + self.msg_queue.push_back(Message::LevelSelected(idx)); + } + } + }) + }); + }); + }); + } + + fn center_panel(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered_justified(|ui| { + ui.label( + egui::RichText::new(match &self.loaded_song { + Some(song) => &song.name, + None => "No song loaded...", + }) + .size(32.0), + ); + ui.label( + egui::RichText::new(match &self.loaded_song { + Some(song) => song.id.to_string(), + None => "No song loaded...".into(), + }) + .size(20.0), + ); + + if let Some(song) = &self.loaded_song { + ui.add(self.editor.time_signature_widget()); + ui.add(self.editor.beat_rate_widget()); + ui.add(self.editor.lines_widget(Color::Green)); + ui.add(self.editor.lines_widget(Color::Orange)); + ui.add(self.editor.lines_widget(Color::Yellow)); + ui.add(self.editor.waveform_widget(song)); + } + }); + }); + } + + fn handle_message(&mut self, message: Message) { + match message { + Message::LevelSelected(idx) => self.selected_level = Some(idx), + Message::CloseError => self.error = None, + Message::LoadLevel => { + // Load song & GdlData + let level = self + .selected_level + .and_then(|idx| self.level_list.get(idx)) + .unwrap() // will not panic. selected_level range is same as level_list... + .clone(); // ...length - 1; message will not be sent if selected_level is none + + todo!(); + } + } + } + + fn handle_messages(&mut self) { + for message in mem::take(&mut self.msg_queue) { + println!("{message:?}"); + self.handle_message(message); + } + } +} + +impl eframe::App for PipeDash { + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + ctx.set_pixels_per_point(2f32); + + if let Some(boxed_err) = &self.error { + egui::CentralPanel::default().show(ctx, |ui| { + ui.label(boxed_err.to_string()); + if ui.button("Close").clicked() { + self.msg_queue.push_back(Message::CloseError); + } + }); + } else { + self.side_panel(ctx, frame); + self.center_panel(ctx, frame); + } + + self.handle_messages(); + } +} + +fn main() { + let app: PipeDash; + let opts = eframe::NativeOptions::default(); + eframe::run_native("PipeDash", opts, Box::new(|cc| Box::new(PipeDash::new(cc)))); +} diff --git a/Cargo.lock b/Cargo.lock index 4258b50..20164d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,15 @@ dependencies = [ "syn", ] +[[package]] +name = "directories" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "4.0.0" @@ -826,6 +835,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + [[package]] name = "emath" version = "0.20.0" @@ -1350,6 +1365,15 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.5" @@ -1813,6 +1837,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -2028,14 +2061,18 @@ version = "0.1.0" dependencies = [ "base64 0.21.0", "chrono", + "directories", "eframe", "flate2", "gd_plist", "home", + "itertools", + "log", "md5", "ordered-float", "reqwest", "rodio", + "simplelog", "thiserror", "urlencoding", ] @@ -2412,6 +2449,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" +[[package]] +name = "simplelog" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48dfff04aade74dd495b007c831cd6f4e0cee19c344dd9dc0884c0289b70a786" +dependencies = [ + "log", + "termcolor", + "time 0.3.17", +] + [[package]] name = "slab" version = "0.4.7" @@ -2574,9 +2622,9 @@ dependencies = [ [[package]] name = "termcolor" -version = "1.2.0" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" dependencies = [ "winapi-util", ] @@ -2619,6 +2667,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ "itoa", + "libc", + "num_threads", "serde", "time-core", "time-macros", diff --git a/Cargo.toml b/Cargo.toml index e8a1004..5020889 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,7 @@ thiserror = "1.0.38" reqwest = {version = "0.11.14", features = [ "blocking" ]} urlencoding = "2.1.2" md5 = "0.7.0" +itertools = "0.10.5" +directories = "4.0.1" +simplelog = "0.12.0" +log = "0.4.17" diff --git a/src/gd.rs b/src/gd.rs index bc99fea..8fbdf55 100644 --- a/src/gd.rs +++ b/src/gd.rs @@ -1,8 +1,11 @@ +use crate::music::Lines; use base64::engine::{general_purpose::URL_SAFE, Engine}; +use chrono::Duration; use eframe::egui::TextFormat; use eframe::epaint::text::LayoutJob; use flate2::read::GzDecoder; use gd_plist::Value; +use itertools::Itertools; use reqwest::blocking as req; use std::fs::File; use std::io::{Cursor, Read}; @@ -10,11 +13,6 @@ use std::num::ParseIntError; use std::path::PathBuf; use thiserror::Error; -struct User { - name: String, - id: Option, -} - pub enum Song { Official { id: i32 /*k8*/ }, Newgrounds { id: i32 /*k45*/ }, @@ -35,8 +33,16 @@ pub struct OuterLevel { revision: Option, // k46 } +#[derive(Debug)] pub struct InnerLevel(String); +#[derive(Debug, Default)] +pub struct RawLinesTriplet { + orange: Lines, // 0.8 + yellow: Lines, // 0.9 + green: Lines, // 1.0 +} + #[derive(Debug, Error)] pub enum SongRequestError { #[error("Request failed")] @@ -48,16 +54,50 @@ pub enum SongRequestError { } impl InnerLevel { - pub fn try_from_encoded_ils(ils: &str) -> Option { - let b64 = URL_SAFE.decode(ils).ok()?; + pub fn try_from_encoded_ils(encoded_ils: &str) -> Option { + let b64 = URL_SAFE.decode(encoded_ils).ok()?; let mut decoder = GzDecoder::<&[u8]>::new(b64.as_ref()); let mut ils = String::new(); decoder.read_to_string(&mut ils).ok()?; - Some(Self(ils.into())) + Some(Self(ils)) + } + + pub fn get_property<'a>(&'a self, key: &str) -> Option<&'a str> { + self.0 + .split(';') + .next()? + .split(',') + .tuples() + .find(|(k, _)| k == &key) + .map(|(_, v)| v) } - pub fn hash(self) -> md5::Digest { - md5::compute(self.0) + pub fn get_lines(&self) -> RawLinesTriplet { + let mut lines = RawLinesTriplet::default(); + self.0 + .split('~') + .tuples() + .for_each(|(timestamp, color_code)| { + let Ok(code_num) = color_code.parse::().map(|x| (10f64 * x).round() as i32) else { + log::info!("{} could not be parsed", color_code); + return; + }; + let Ok(duration) = timestamp.parse::().map(|t| Duration::nanoseconds((t * 1_000_000_000f64) as i64)) else { + log::info!("{} could not be parsed", timestamp); + return; + }; + match code_num { + 8 => {lines.orange.insert(duration);}, + 9 => {lines.yellow.insert(duration);}, + 10 => {lines.green.insert(duration);}, + _ => {log::info!("{} at timestamp {} was invalid", code_num, timestamp)} + }; + }); + lines + } + + pub fn hash(&self) -> md5::Digest { + md5::compute(self.0.clone()) } } @@ -112,7 +152,11 @@ impl SongResponse { self.0[8].as_deref().and_then(|s| s.parse().ok()) } pub fn download_link(&self) -> Option { - self.0[9].as_deref().and_then(|url| urlencoding::decode(url).ok().map(std::borrow::Cow::into_owned)) + self.0[9].as_deref().and_then(|url| { + urlencoding::decode(url) + .ok() + .map(std::borrow::Cow::into_owned) + }) } } @@ -148,10 +192,12 @@ impl OuterLevel { .as_dictionary() .unwrap() .iter() - .find(|(key, val)| key.as_str() != "_isArr" && { - let props = val.as_dictionary().unwrap(); - props.get("k2").unwrap().as_string().unwrap() == self.name - && props.get("k46").and_then(|rev| rev.as_signed_integer()) == self.revision + .find(|(key, val)| { + key.as_str() != "_isArr" && { + let props = val.as_dictionary().unwrap(); + props.get("k2").unwrap().as_string().unwrap() == self.name + && props.get("k46").and_then(|rev| rev.as_signed_integer()) == self.revision + } }) .unwrap() .1 @@ -177,8 +223,7 @@ impl OuterLevel { }, ); job - } - else { + } else { let mut job = LayoutJob::default(); job.append(&self.name, 0f32, TextFormat::default()); job @@ -279,7 +324,7 @@ fn get_local_level_plist() -> Value { let mut decoder = GzDecoder::<&[u8]>::new(data_post_b64.as_ref()); let mut plist = String::new(); if decoder.read_to_string(&mut plist).is_err() { - println!("Warning: Game save likely corrupted (gzip decode failed)"); + log::warn!("Game save likely corrupted (gzip decode failed)"); } Value::from_reader(Cursor::new(plist)).unwrap() } diff --git a/src/main.rs b/src/main.rs index b06279b..8c89cdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,11 +9,15 @@ use eframe::egui; use reqwest::blocking as req; use std::boxed::Box; use std::collections::VecDeque; +use std::error::Error; use std::fs::File; use std::io::Write; -use thiserror::Error; -use std::error::Error; use std::mem; +use thiserror::Error; + +fn project_dirs() -> directories::ProjectDirs { + directories::ProjectDirs::from("xyz", "interestingzinc", "pipedash").expect("Home dir missing?") +} struct PipeDash { msg_queue: VecDeque, @@ -22,7 +26,7 @@ struct PipeDash { loaded_song: Option, loaded_level_checksum: Option<(gd::OuterLevel, md5::Digest)>, editor: Editor, - error: Option> + error: Option>, } #[derive(Debug, PartialEq, Eq)] @@ -144,7 +148,7 @@ impl Song { }; let decoder = rodio::Decoder::new_mp3(file)?; - + Ok(Self { name, id, decoder }) } else { Err(SongError::NotNewgrounds) @@ -152,7 +156,6 @@ impl Song { } } - impl Editor { pub fn beat_rate_widget(&mut self) -> BeatRateWidget { BeatRateWidget { @@ -283,7 +286,13 @@ impl PipeDash { .default_width(100f32) .show(ctx, |ui| { ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - if ui.add_enabled(self.selected_level.is_some(), egui::Button::new("Load Level")).clicked() { + if ui + .add_enabled( + self.selected_level.is_some(), + egui::Button::new("Load Level"), + ) + .clicked() + { self.msg_queue.push_back(Message::LoadLevel); } egui::ScrollArea::vertical().show(ui, |ui| { @@ -341,20 +350,20 @@ impl PipeDash { Message::CloseError => self.error = None, Message::LoadLevel => { // Load song & GdlData - let level = self.selected_level + let level = self + .selected_level .and_then(|idx| self.level_list.get(idx)) - .unwrap() // will not panic. selected_level range is same as level_list... - .clone(); // ...length - 1; message will not be sent if selected_level is none + .unwrap() // will not panic. selected_level range is same as level_list... + .clone(); // ...length - 1; message will not be sent if selected_level is none todo!(); - }, + } } - } fn handle_messages(&mut self) { for message in mem::take(&mut self.msg_queue) { - println!("{message:?}"); + log::info!("{message:?}"); self.handle_message(message); } } @@ -381,6 +390,13 @@ impl eframe::App for PipeDash { } fn main() { + simplelog::WriteLogger::init( + simplelog::LevelFilter::Info, + simplelog::Config::default(), + File::create(project_dirs().data_local_dir().join("run.log")) + .expect("file creation failed?"), + ) + .map_err(|e| println!("Logging uninitialized")); let app: PipeDash; let opts = eframe::NativeOptions::default(); eframe::run_native("PipeDash", opts, Box::new(|cc| Box::new(PipeDash::new(cc)))); diff --git a/src/music.rs b/src/music.rs index 7e1e353..388e01d 100644 --- a/src/music.rs +++ b/src/music.rs @@ -25,9 +25,12 @@ pub struct StaticTimeSignature { denominator: u32, } -#[derive(Default)] -pub struct Lines { - positions: BTreeSet, +#[derive(Debug)] +pub struct Lines +where + T: Ord, +{ + positions: BTreeSet, } impl StaticBeatRate { @@ -133,22 +136,40 @@ impl TimeSignature { } } -impl Lines { +impl Default for Lines +where + T: Ord, +{ + fn default() -> Self { + Self { + positions: BTreeSet::new(), + } + } +} + +impl Lines +where + T: Ord, +{ pub fn new() -> Self { - Self::default() + Default::default() } - pub fn insert(&mut self, pos: BeatPosition) -> bool { + pub fn insert(&mut self, pos: T) -> bool { self.positions.insert(pos) } - pub fn remove(&mut self, pos: BeatPosition) -> bool { + pub fn remove(&mut self, pos: T) -> bool { self.positions.remove(&pos) } - pub fn get_positions(&mut self) -> &BTreeSet { + pub fn get_positions(&self) -> &BTreeSet { &self.positions } + + pub fn empty(&self) -> bool { + self.positions.is_empty() + } } #[cfg(test)]