//! The described screens, called with no host in sight. //! //! Every test here builds a `Settings`, calls the router, and reads the `Screen` //! that came back. No egui, no window, no `BrowserState`: that is the property //! the description layer exists to give, and it is why these run in //! milliseconds where the panel they replace cannot be tested at all. use std::cell::RefCell; use std::collections::BTreeMap; use audiofiles_core::config_key::ConfigKey; use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen}; use super::{ Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown, Config, Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Editing, Export, Failure, Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format, Group, Halted, Holding, Importing, Integrity, Keys, Knob, Library, Measure, Measures, Migrating, Naming, Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue, Queued, Reviewed, Sample, Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source, Spread, Stage, State, Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router, }; /// A config store in memory. /// /// Two methods, which is the whole of what a described screen needs. Standing /// this up against `Backend` itself would have meant implementing vfs, tags and /// search to test a checkbox, and that cost is what `Config` exists to refuse. #[derive(Default)] struct Store { values: RefCell>, } impl Config for Store { fn get(&self, key: ConfigKey) -> Result, String> { Ok(self.values.borrow().get(key.as_str()).cloned()) } fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> { self.values .borrow_mut() .insert(key.as_str().to_owned(), value.to_owned()); Ok(()) } } impl Store { fn with(pairs: &[(ConfigKey, &str)]) -> Self { let store = Self::default(); for (key, value) in pairs { store .values .borrow_mut() .insert(key.as_str().to_owned(), (*value).to_owned()); } store } fn get(&self, key: ConfigKey) -> Option { self.values.borrow().get(key.as_str()).cloned() } } /// A file list in memory, recording what was asked of it. /// /// The reads are plain data and the writes are recorded, which is the shape the /// real adapter has for a reason the fixture makes visible: selecting a row is /// `&mut BrowserState`, so a route can only ask. #[derive(Default)] struct FakeFiles { samples: Vec, shown: ColumnsShown, by: String, ascending: bool, current: Option, asked: RefCell>, } impl FakeFiles { fn with(samples: Vec) -> Self { Self { samples, shown: ColumnsShown { duration: true, bpm: true, key: true, peak_db: false, tags: true, }, by: "Name".to_owned(), ascending: true, ..Self::default() } } fn asked(&self) -> Vec { self.asked.borrow().clone() } } impl Files for FakeFiles { fn samples(&self) -> Vec { self.samples.clone() } fn columns(&self) -> ColumnsShown { self.shown } fn sort(&self) -> (String, bool) { (self.by.clone(), self.ascending) } fn current(&self) -> Option { self.current } fn open(&self, id: i64) { self.asked.borrow_mut().push(format!("open:{id}")); } fn play(&self, id: i64) { self.asked.borrow_mut().push(format!("play:{id}")); } fn sort_by(&self, column: &str) { self.asked.borrow_mut().push(format!("sort:{column}")); } // The row menu's seven. Recorded rather than performed, same as the three // above: what a test of a described screen asserts is that pressing a // described act reaches the capability, and the app is what does it. fn enter(&self, id: i64) { self.asked.borrow_mut().push(format!("enter:{id}")); } fn reveal(&self, id: i64) { self.asked.borrow_mut().push(format!("reveal:{id}")); } fn as_instrument(&self, id: i64) { self.asked.borrow_mut().push(format!("instrument:{id}")); } fn reanalyze(&self, id: i64) { self.asked.borrow_mut().push(format!("reanalyze:{id}")); } fn delete(&self, id: i64) { self.asked.borrow_mut().push(format!("delete:{id}")); } fn download(&self, id: i64) { self.asked.borrow_mut().push(format!("download:{id}")); } fn remove_from_collection(&self, id: i64) { self.asked.borrow_mut().push(format!("uncollect:{id}")); } fn add_to_collection(&self, id: i64, collection: i64) { self.asked .borrow_mut() .push(format!("collect:{id}->{collection}")); } } /// An export flow that is not running, for the screens that are not about one. /// /// Its own type rather than a `FakeExport` in the idle phase, because every /// method on it is a refusal: the other screens' tests should not be able to /// start an export by accident, and a fake that recorded the call would let one. struct Idle; impl Export for Idle { fn phase(&self) -> Phase { Phase::Idle } fn open(&self) {} fn configure(&self, _setting: Setting, _value: &str) {} fn start(&self) {} fn cancel(&self) {} fn dismiss(&self) {} } /// An export flow in memory, recording what was asked of it. /// /// The phase is fixed per test rather than advancing, which is the honest shape: /// what moves the phase is the app applying an intent, and these tests are of /// the description rather than of the host. What is recorded is the asking. struct FakeExport { phase: Phase, asked: RefCell>, } impl FakeExport { fn at(phase: Phase) -> Self { Self { phase, asked: RefCell::new(Vec::new()), } } } impl Export for FakeExport { fn phase(&self) -> Phase { self.phase.clone() } fn open(&self) { self.asked.borrow_mut().push("open".to_owned()); } fn configure(&self, setting: Setting, value: &str) { self.asked .borrow_mut() .push(format!("set:{}={value}", setting.as_str())); } fn start(&self) { self.asked.borrow_mut().push("start".to_owned()); } fn cancel(&self) { self.asked.borrow_mut().push("cancel".to_owned()); } fn dismiss(&self) { self.asked.borrow_mut().push("dismiss".to_owned()); } } /// Settings as the app defaults them: copy as-is, into a tree. fn defaults() -> Settings { Settings { format: Format::Original, sample_rate: None, bit_depth: None, channels: Channels::Original, flatten: false, sidecar: false, naming_pattern: None, destination: "/tmp/export".to_owned(), device_profile: None, } } /// One sample about to be exported. fn subject(name: &str, seconds: f64) -> Subject { Subject { name: name.to_owned(), ext: "wav".to_owned(), duration: Some(seconds), bpm: Some(120.0), musical_key: Some("Am".to_owned()), } } /// A router call against this export flow. fn exporting(export: &FakeExport, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The screen the export flow answers, at whatever phase it is in. fn exported(export: &FakeExport) -> Screen { screen_of(&exporting(export, Request::get("/export")).unwrap()).clone() } /// Every node on a screen, in order. fn nodes(screen: &Screen) -> Vec<&Node> { screen .slots .iter() .flat_map(|slot| &slot.body) .map(|placed| &placed.node) .collect() } /// Every node on a screen, descending into regions. /// /// [`nodes`] stops at the top level, which is enough for a flat screen. The sync /// screen nests: a body region holds a subscription region holds the forms, so a /// test that asks what the screen says has to walk down. fn nodes_deep(screen: &Screen) -> Vec { fn walk(node: &Node, out: &mut Vec) { out.push(node.clone()); if let Node::Region(slot) = node { for placed in &slot.body { walk(&placed.node, out); } } } let mut out = Vec::new(); for slot in &screen.slots { for placed in &slot.body { walk(&placed.node, &mut out); } } out } /// What the screen says, regions included. See [`said`]. fn said_deep(screen: &Screen) -> String { nodes_deep(screen) .iter() .filter_map(|node| match node { Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => { Some(text.clone()) } Node::StandIn { message, .. } => Some(message.clone()), _ => None, }) .collect::>() .join(" | ") } /// The text of every prose and notice node on a screen, joined. /// /// Assertions read against this rather than against node positions: what a /// screen *says* is the described fact, and where the renderer puts it is not. fn said(screen: &Screen) -> String { nodes(screen) .iter() .filter_map(|node| match node { Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => { Some(text.clone()) } Node::StandIn { message, .. } => Some(message.clone()), _ => None, }) .collect::>() .join(" | ") } /// A sample with the fields a row names. fn sample(id: i64, name: &str) -> Sample { Sample { id, name: name.to_owned(), duration: Some(1.5), bpm: Some(120.0), key: Some("Am".to_owned()), peak_db: Some(-3.2), tags: vec!["drums".to_owned(), "loop".to_owned()], directory: false, cloud_only: false, } } /// A folder row, which offers a different menu. fn folder(id: i64, name: &str) -> Sample { Sample { duration: None, bpm: None, key: None, peak_db: None, tags: Vec::new(), directory: true, ..sample(id, name) } } /// A sample whose bytes are only in the cloud. fn cloud_only(id: i64, name: &str) -> Sample { Sample { cloud_only: true, ..sample(id, name) } } /// A router call against this file list. fn listing(files: &FakeFiles, request: Request) -> Result { let store = Store::default(); let sync = Offline; let themes = themes(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The same call, with a collection open. /// /// One menu entry turns on it -- Remove from Collection means nothing outside one /// -- so the file list asks the library whether any collection is active, and /// this is the fixture that says yes. `Empty` answers no and is what every other /// file-list test wants. fn listing_in_collection( files: &FakeFiles, request: Request, ) -> Result { struct Showing; impl Library for Showing { fn vaults(&self) -> Vec { Vec::new() } fn collections(&self) -> Vec { vec![Collection { id: 3, name: "Kicks".to_owned(), holding: Holding::Fixed(4), active: true, }] } fn tags(&self) -> Vec { Vec::new() } fn open_vault(&self, _id: i64) {} fn delete_vault(&self, _id: i64) {} fn toggle_tag(&self, _path: &str) {} fn remove_tag(&self, _path: &str) {} fn open_collection(&self, _id: i64) {} fn close_collection(&self) {} fn delete_collection(&self, _id: i64) {} } let store = Store::default(); let sync = Offline; let themes = themes(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Showing, bar: &Still, config: &store, sync: &sync, files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The table on a screen. /// /// Descends into a `Node::Region`, because a region is a slot inside a node and /// `nodes` only walks the screen's own slots. The rename preview lives in one so /// that a fragment can replace it. fn table_of(screen: &Screen) -> (Vec, Vec) { fn find( body: &[quasi_router::Ranked], ) -> Option<(Vec, Vec)> { for placed in body { match &placed.node { Node::Table { columns, rows, .. } => { return Some((columns.clone(), rows.clone())); } Node::Region(slot) => { if let Some(found) = find(&slot.body) { return Some(found); } } _ => {} } } None } screen .slots .iter() .find_map(|slot| find(&slot.body)) .expect("the screen draws a table") } /// Sync that reports nothing and does nothing. /// /// The settings tests do not touch it, and it is here because `Panels` is one /// state for every screen: a router is one table, so a settings test still has /// to name a sync. That is the cost of sharing, and it is a fixture rather than /// a design problem. struct Offline; impl Sync for Offline { fn status(&self) -> Status { Status { state: State::Disconnected, last_sync_at: None, pending_changes: 0, last_error: None, auto_sync_enabled: false, sync_interval_minutes: 15, } } fn connect(&self) -> Result { Err("offline".to_owned()) } fn cancel(&self) {} fn set_password(&self, _password: &str, _is_new: bool) {} fn sync_now(&self) {} fn set_auto(&self, _enabled: bool) {} fn set_interval(&self, _minutes: u32) {} fn clear_error(&self) {} fn disconnect(&self) {} fn subscription(&self) -> Option { None } fn pricing(&self) -> Option { None } fn synced_library_bytes(&self) -> Option { None } fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 { 0 } fn refresh_subscription(&self) {} fn subscribe(&self, _cap_bytes: i64, _annual: bool) {} fn queue_cap_change(&self, _cap_bytes: i64) {} } fn themes() -> Vec { vec![ ThemeChoice { id: "audiofiles".into(), name: "audiofiles".into(), variant: "light".into(), source: Some("[color]\nink = \"#111111\"\n".into()), }, // No source, which is the built-in-with-nothing-to-read case: Export // must offer nothing rather than offer an empty file. ThemeChoice { id: "nord".into(), name: "Nord".into(), variant: "dark".into(), source: None, }, ] } /// The screen out of a response, or a failure naming what came instead. /// /// Either kind of screen, because an overlay is a screen drawn over something /// rather than a different thing: what a test asks of `/vaults/1/rename` is /// what it says, and whether it is over the main window is /// [`overlaid`](overlaid)'s question. fn screen_of(response: &Response) -> &Screen { match &response.outcome { Outcome::Screen(screen) | Outcome::Over(screen) => screen, other => panic!("expected a screen, got {other:?}"), } } /// Every field on the screen, by name. /// /// Standing alone or inside a form: the two are the same fact about what the /// screen asks for, and only the submit differs. fn fields(screen: &Screen) -> BTreeMap> { let mut found = BTreeMap::new(); for slot in &screen.slots { for placed in &slot.body { match &placed.node { Node::Field(field) => { found.insert(field.name.clone(), field.value.clone()); } Node::Form { fields, .. } => { for field in fields { found.insert(field.name.clone(), field.value.clone()); } } _ => {} } } } found } #[test] fn the_screen_answers_with_every_control_it_describes() { let store = Store::default(); let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; let response = router() .handle(&state, Request::get("/settings")) .expect("the route answered"); let screen = screen_of(&response); let named = fields(screen); // The four describable sections, as the controls a user sees. assert!(named.contains_key(ConfigKey::PreviewLoop.as_str())); assert!(named.contains_key(ConfigKey::PreviewAutoplay.as_str())); assert!(named.contains_key(ConfigKey::ForgeAutoTrimOvershoot.as_str())); assert!(named.contains_key(ConfigKey::RowHeight.as_str())); for column in ["column.bpm", "column.key", "column.tags"] { assert!(named.contains_key(column), "{column} is not on the screen"); } } #[test] fn every_control_writes_through_one_route() { // The shape goingson's settings port settled: a screen that is a key/value // editor reads as one, and carries no second list of what it may name. let table: Vec<(Method, String)> = router().routes().map(|(m, p)| (m, p.to_owned())).collect(); assert!(table.contains(&(Method::Get, "/settings".to_owned()))); assert!(table.contains(&(Method::Post, "/settings/config/{key}".to_owned()))); // Counted per screen rather than in total, so a second screen landing in the // same table does not read as this one growing routes. // // Four, and the two that are not the key/value route are not exceptions to // it: `columns/reset` is one write to one key, and `theme/export` is not a // write at all -- it hands back a file. What this asserts is that no control // grew an address of its own, which is the drift it exists to catch. let settings = table .iter() .filter(|(_, path)| path.starts_with("/settings")) .count(); assert_eq!(settings, 4, "{table:?}"); } /// A router call against the settings screen, over a given config store. fn settling(store: &Store, request: Request) -> Result { let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } #[test] fn exporting_a_theme_hands_back_the_file_rather_than_naming_a_path() { // First consumer of `Outcome::File` on this host (`67881a88`). The // description never names a path: the route answers with the bytes and a // suggested name, and where they land is the host's -- a save dialog here, // the working directory on a terminal, a download in a browser. let store = Store::with(&[(ConfigKey::Theme, "audiofiles")]); let response = settling(&store, Request::post("/settings/theme/export")) .expect("the active theme has a source, so it exports"); let Outcome::File { name, kind, bytes } = &response.outcome else { panic!("expected a file, got {:?}", response.outcome); }; assert_eq!(name, "audiofiles.toml"); assert_eq!(kind, &quasi_router::Accepted::suffix(".toml")); assert!(String::from_utf8_lossy(bytes).contains("[color]")); } #[test] fn a_theme_with_no_readable_source_is_not_offered_for_export() { // `nord` in the fixture has `source: None`, which is a built-in with nothing // to read. Offering Export on it would hand the user an empty file. let store = Store::with(&[(ConfigKey::Theme, "nord")]); assert!( settling(&store, Request::post("/settings/theme/export")).is_err(), "a theme with no source was exported anyway" ); let screen = match settling(&store, Request::get("/settings")) .expect("settings answers") .outcome { Outcome::Screen(screen) => screen, other => panic!("expected a screen, got {other:?}"), }; assert!( !acts(&screen) .iter() .any(|label| label == "Export current theme"), "the act is on the screen for a theme that cannot answer it" ); } #[test] fn a_toggle_reads_what_is_stored_and_writes_what_was_sent() { let store = Store::with(&[(ConfigKey::PreviewLoop, "1")]); let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; let response = router() .handle(&state, Request::get("/settings")) .expect("answered"); let on = fields(screen_of(&response)); assert_eq!( on.get(ConfigKey::PreviewLoop.as_str()), Some(&Some("on".to_owned())), "a stored 1 draws as ticked" ); router() .handle( &state, Request::post(format!( "/settings/config/{}", ConfigKey::PreviewLoop.as_str() )) .sending(Params::new().with(ConfigKey::PreviewLoop.as_str().to_owned(), String::new())), ) .expect("answered"); assert_eq!(store.get(ConfigKey::PreviewLoop).as_deref(), Some("")); } #[test] fn a_setting_this_app_never_declared_is_a_not_found() { // `ConfigKey::from_key` is the same refusal the rest of the app makes, and // the address is reachable by typing, so it is a 404 rather than a 500. let store = Store::default(); let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; let refused = router().handle( &state, Request::post("/settings/config/not_a_setting") .sending(Params::new().with("not_a_setting".to_owned(), "x".to_owned())), ); let error = refused.expect_err("an undeclared key is refused"); assert_eq!(error.class, quasi_router::Class::NotFound); } #[test] fn a_column_is_five_described_names_against_one_stored_blob() { // The reconciliation this port owns: the user sees five booleans and the // store keeps one JSON value, and the route is where the two meet. A // description that named the blob would be describing a storage format. let store = Store::default(); let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; // Absent means shown, which is what a fresh install does. let response = router() .handle(&state, Request::get("/settings")) .expect("answered"); assert_eq!( fields(screen_of(&response)).get("column.bpm"), Some(&Some("on".to_owned())) ); // Turning one off leaves the others alone. router() .handle( &state, Request::post("/settings/config/column.bpm") .sending(Params::new().with("column.bpm".to_owned(), String::new())), ) .expect("answered"); let stored = store.get(ConfigKey::ColumnConfig).expect("written"); assert!(stored.contains("\"show_bpm\":false"), "{stored}"); let response = router() .handle(&state, Request::get("/settings")) .expect("answered"); let named = fields(screen_of(&response)); assert_eq!(named.get("column.bpm"), Some(&Some(String::new()))); assert_eq!( named.get("column.key"), Some(&Some("on".to_owned())), "turning one column off turned another off too" ); } #[test] fn the_theme_picker_offers_what_the_host_resolved() { // The settled host-boundary rule, applied first time out: a host fact // readable at startup goes in `S` rather than through a capability surface. let store = Store::with(&[(ConfigKey::Theme, "nord")]); let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; let response = router() .handle(&state, Request::get("/settings")) .expect("answered"); // A field and not a `Node::Select`: `Selector` is a strip of a handful of // choices, and thirty-odd themes that fold away is a dropdown. let picker = screen_of(&response) .slots .iter() .flat_map(|slot| &slot.body) .find_map(|placed| match &placed.node { Node::Field(field) if field.name == ConfigKey::Theme.as_str() => Some(field), _ => None, }) .expect("the screen offers a theme picker"); assert_eq!(picker.kind, quasi_router::layout::FieldKind::Select); assert_eq!(picker.value.as_deref(), Some("nord")); assert_eq!(picker.options.len(), 2); // The finding, asserted rather than described in prose: the variant is in // the label because `Choice` has nowhere else to put it. When grouping // arrives, this assertion is what should have to change. assert!( picker .options .iter() .any(|choice| choice.label.contains("dark")), "the variant survived only by riding in the label" ); } #[test] fn resetting_the_columns_says_it_did() { let store = Store::with(&[(ConfigKey::ColumnConfig, "{\"show_bpm\":false}")]); let themes = themes(); let sync = Offline; let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; let response = router() .handle(&state, Request::post("/settings/columns/reset")) .expect("answered"); assert_eq!(store.get(ConfigKey::ColumnConfig).as_deref(), Some("")); assert!( response.notice.is_some(), "a destructive-looking control that says nothing is one the user cannot tell worked" ); } /// Sync in whatever state a test wants, recording what was asked of it. struct FakeSync { status: Status, calls: RefCell>, /// What the subscription fetch has answered, if it has. subscription: Option, /// Whether pricing has arrived. priced: bool, /// What the vault says would upload, if the screen can look. library_bytes: Option, } impl FakeSync { fn in_state(state: State) -> Self { Self { status: Status { state, last_sync_at: None, pending_changes: 0, last_error: None, auto_sync_enabled: false, sync_interval_minutes: 15, }, calls: RefCell::new(Vec::new()), subscription: None, priced: true, library_bytes: Some(0), } } fn called(&self) -> Vec { self.calls.borrow().clone() } fn note(&self, what: &str) { self.calls.borrow_mut().push(what.to_owned()); } } impl Sync for FakeSync { fn status(&self) -> Status { self.status.clone() } fn connect(&self) -> Result { self.note("connect"); Ok("https://makenot.work/auth?code=abc".to_owned()) } fn cancel(&self) { self.note("cancel"); } fn set_password(&self, _password: &str, is_new: bool) { self.note(if is_new { "set_password:new" } else { "set_password:unlock" }); } fn sync_now(&self) { self.note("sync_now"); } fn set_auto(&self, enabled: bool) { self.note(if enabled { "auto:on" } else { "auto:off" }); } fn set_interval(&self, minutes: u32) { self.note(&format!("interval:{minutes}")); } fn clear_error(&self) { self.note("clear_error"); } fn disconnect(&self) { self.note("disconnect"); } fn subscription(&self) -> Option { self.subscription.clone() } fn pricing(&self) -> Option { self.priced.then_some(Pricing { min_bytes: 10 * GIB, max_bytes: 2048 * GIB, }) } fn synced_library_bytes(&self) -> Option { self.library_bytes } fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 { // A stand-in for the server's model: a dollar a gibibyte a month, and // two months free on the year. The screen never computes a price, so // what matters here is only that the number reaches the label. let monthly = (cap_bytes / GIB) * 100; if annual { monthly * 10 } else { monthly } } fn refresh_subscription(&self) { self.note("refresh_subscription"); } fn subscribe(&self, cap_bytes: i64, annual: bool) { self.note(&format!( "subscribe:{}:{}", cap_bytes / GIB, if annual { "annual" } else { "monthly" } )); } fn queue_cap_change(&self, cap_bytes: i64) { self.note(&format!("cap:{}", cap_bytes / GIB)); } } /// One gibibyte, as the routes count caps. const GIB: i64 = 1024 * 1024 * 1024; /// A subscription that is running. fn active(limit_gib: i64, used_gib: i64) -> Subscription { Subscription { active: true, limit_bytes: limit_gib * GIB, used_bytes: used_gib * GIB, interval: "monthly".to_owned(), pending_limit_bytes: None, } } /// A router call against a sync in this state. fn syncing(sync: &FakeSync, request: Request) -> Result { let store = Store::default(); let themes = themes(); let files = FakeFiles::default(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// Every act on a screen, by label. fn acts(screen: &Screen) -> Vec { screen .slots .iter() .flat_map(|slot| &slot.body) .filter_map(|placed| match &placed.node { Node::Act(act) => Some(act.label.clone()), _ => None, }) .collect() } #[test] fn one_route_answers_four_shapes_because_a_state_is_not_an_address() { // A user cannot navigate to Authenticating; they arrive there because // something happened. Four addresses would be four places you could bookmark // into a lie. for (state, expected) in [ (State::Disconnected, "Connect"), (State::Authenticating, "Cancel"), (State::Ready, "Sync now"), ] { let sync = FakeSync::in_state(state); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let labels = acts(screen_of(&response)); assert!( labels.iter().any(|label| label == expected), "{state:?} offered {labels:?}, wanted {expected}" ); } } #[test] fn connecting_answers_with_somewhere_to_go_rather_than_a_screen() { // The case `Destination::External` exists for, and what replaces the three // `#[cfg(target_os)]` branches the shipped panel keeps inside a drawing // function. let sync = FakeSync::in_state(State::Disconnected); let response = syncing(&sync, Request::post("/sync/connect")).expect("answered"); match &response.outcome { Outcome::Goto(action) => { assert!(action.destination.is_external(), "{action:?}"); assert!(action.destination.as_str().starts_with("https://")); } other => panic!("expected somewhere to go, got {other:?}"), } assert_eq!(sync.called(), ["connect"]); } #[test] fn the_password_screen_knows_whether_it_is_setting_or_unlocking() { // `has_server_key` changes what is said and not what shape it is, and the // manager needs it: choosing a password is not the same call as supplying // one. for (has_server_key, expected) in [(false, "set_password:new"), (true, "set_password:unlock")] { let sync = FakeSync::in_state(State::NeedsEncryption { has_server_key }); syncing( &sync, Request::post("/sync/encryption") .sending(Params::new().with("password".to_owned(), "hunter2".to_owned())), ) .expect("answered"); assert_eq!(sync.called(), [expected]); } } #[test] fn an_empty_password_is_refused_without_reaching_the_manager() { let sync = FakeSync::in_state(State::NeedsEncryption { has_server_key: false, }); let response = syncing( &sync, Request::post("/sync/encryption") .sending(Params::new().with("password".to_owned(), String::new())), ) .expect("answered"); assert!(response.notice.is_some(), "the refusal said nothing"); assert!( sync.called().is_empty(), "an empty password reached the manager" ); } #[test] fn the_password_is_never_carried_back_into_the_description() { // `39057019`: a Secret field refuses to hold a value, so the runtime's // buffer is the only place the typed password has ever lived. let sync = FakeSync::in_state(State::NeedsEncryption { has_server_key: false, }); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let carried: Vec<_> = screen_of(&response) .slots .iter() .flat_map(|slot| &slot.body) .filter_map(|placed| match &placed.node { Node::Form { fields, .. } => Some(fields.clone()), _ => None, }) .flatten() .collect(); let password = carried .iter() .find(|field| field.name == "password") .expect("the screen asks for a password"); assert_eq!(password.kind, quasi_router::layout::FieldKind::Secret); assert_eq!(password.value, None, "the description carried a secret"); } #[test] fn a_running_sync_is_drawn_and_does_not_answer() { // Present, visible and not answering, which is what a control that is // already running should be. let sync = FakeSync::in_state(State::Syncing); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let now = screen_of(&response) .slots .iter() .flat_map(|slot| &slot.body) .find_map(|placed| match &placed.node { Node::Act(act) if act.label == "Sync now" => Some(act), _ => None, }) .expect("the screen offers Sync now"); // Read off the member rather than through a predicate: `Act::disabled` is // the builder on this type, where `makeover_layout::Act::disabled` is the // question. One name, two crates, opposite parts of speech. assert_eq!( now.state, Some(quasi_router::layout::State::Disabled), "a sync already running still answered" ); } #[test] fn an_interval_the_panel_does_not_offer_is_refused() { let sync = FakeSync::in_state(State::Ready); let refused = syncing( &sync, Request::post("/sync/interval") .sending(Params::new().with(Node::SELECTED.to_owned(), "7".to_owned())), ); assert!(refused.is_err(), "an unoffered cadence was accepted"); assert!(sync.called().is_empty()); let accepted = syncing( &sync, Request::post("/sync/interval") .sending(Params::new().with(Node::SELECTED.to_owned(), "30".to_owned())), ); assert!(accepted.is_ok()); assert_eq!(sync.called(), ["interval:30"]); } #[test] fn a_failure_is_reported_in_every_state_and_retry_only_where_it_means_something() { for (state, retryable) in [(State::Disconnected, false), (State::Ready, true)] { let mut sync = FakeSync::in_state(state); sync.status.last_error = Some("the server said no".to_owned()); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let screen = screen_of(&response); let said = screen.slots.iter().flat_map(|slot| &slot.body).any( |placed| matches!(&placed.node, Node::Notice { text, .. } if text.contains("said no")), ); assert!(said, "{state:?} swallowed the error"); let labels = acts(screen); assert!(labels.iter().any(|label| label == "Dismiss")); assert_eq!( labels.iter().any(|label| label == "Retry"), retryable, "{state:?} offered the wrong escape: {labels:?}" ); } } /// Every form on a screen, as (submit label, action path, field names). fn forms(screen: &Screen) -> Vec<(String, String, Vec)> { screen .slots .iter() .flat_map(|slot| &slot.body) .flat_map(|placed| match &placed.node { Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(), other => vec![other.clone()], }) .filter_map(|node| match node { Node::Form { submit, action, fields, } => Some(( submit, action.destination.as_str().to_owned(), fields.iter().map(|f| f.name.clone()).collect(), )), _ => None, }) .collect() } #[test] fn a_subscription_that_has_not_arrived_is_pending_rather_than_absent() { // `None` is "not fetched yet", which is what Readiness says and what the // shipped panel spends two Instants and a thirty-second timeout on. let sync = FakeSync::in_state(State::Ready); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let region = screen_of(&response) .slots .iter() .flat_map(|slot| &slot.body) .find_map(|placed| match &placed.node { Node::Region(slot) if slot.id == "subscription" => Some(slot), _ => None, }) .expect("the screen has a subscription region"); assert_eq!(region.readiness, quasi_router::layout::Readiness::Pending); } #[test] fn one_form_carries_the_cap_and_the_cadence_because_a_form_has_one_submit() { // The redesign the vocabulary forced: the shipped panel has one cap and two // priced buttons, and `Node::Form` carries one action and one submit. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(Subscription { active: false, limit_bytes: 0, used_bytes: 0, interval: "monthly".to_owned(), pending_limit_bytes: None, }); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let found = forms(screen_of(&response)); let offer = found .iter() .find(|(_, action, _)| action == "/sync/subscribe") .expect("the screen offers a subscription"); assert_eq!(offer.0, "Subscribe"); assert_eq!(offer.2, ["cap_gib", "cadence"], "{offer:?}"); } #[test] fn subscribing_sends_the_cap_and_the_cadence_it_was_given() { let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(Subscription { active: false, limit_bytes: 0, used_bytes: 0, interval: "monthly".to_owned(), pending_limit_bytes: None, }); syncing( &sync, Request::post("/sync/subscribe").sending( Params::new() .with("cap_gib".to_owned(), "100".to_owned()) .with("cadence".to_owned(), "annual".to_owned()), ), ) .expect("answered"); assert_eq!(sync.called(), ["subscribe:100:annual"]); } #[test] fn a_cap_outside_what_is_sold_never_reaches_the_manager() { // Money: the route checks the bounds again rather than trusting the field, // because the address is reachable by typing. let sync = FakeSync::in_state(State::Ready); for out_of_range in ["1", "9999"] { let refused = syncing( &sync, Request::post("/sync/subscribe").sending( Params::new() .with("cap_gib".to_owned(), out_of_range.to_owned()) .with("cadence".to_owned(), "monthly".to_owned()), ), ); assert!(refused.is_err(), "{out_of_range} GiB was accepted"); } assert!( sync.called().is_empty(), "a cap outside the offer reached the manager" ); // And one inside it does. syncing( &sync, Request::post("/sync/cap") .sending(Params::new().with("cap_gib".to_owned(), "50".to_owned())), ) .expect("answered"); assert_eq!(sync.called(), ["cap:50"]); } #[test] fn a_cap_asked_for_before_pricing_arrived_is_refused_rather_than_guessed() { // Without pricing there are no bounds, and a purchase route that cannot // check its bounds must not proceed. let mut sync = FakeSync::in_state(State::Ready); sync.priced = false; let refused = syncing( &sync, Request::post("/sync/subscribe").sending( Params::new() .with("cap_gib".to_owned(), "50".to_owned()) .with("cadence".to_owned(), "monthly".to_owned()), ), ); assert!(refused.is_err()); assert!(sync.called().is_empty()); } #[test] fn a_running_subscription_shows_what_it_holds_and_how_full_it_is() { let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(active(100, 90)); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let meter = screen_of(&response) .slots .iter() .flat_map(|slot| &slot.body) .flat_map(|placed| match &placed.node { Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(), other => vec![other.clone()], }) .find_map(|node| match node { Node::Meter(meter) => Some(meter), _ => None, }) .expect("a subscription draws how full it is"); assert_eq!(meter.done, 90); assert_eq!(meter.total, 100); // The tone is carried because no renderer can work it out: 90% of a paid cap // is a warning and 90% of a subtask rollup is a success. assert_eq!(meter.tone, quasi_router::layout::Tone::Warning); } #[test] fn every_offered_cap_carries_its_own_price() { // This replaces `the_price_shown_is_the_committed_cap_and_not_a_live_quote`, // which asserted the old shape: one hint, pricing the committed cap, because // nothing describes a display derived from a control's own uncommitted // value (quasicoherent `57c21152`). // // The redesign dissolves that finding for this control rather than working // around it. The cap is now chosen from named sizes and each option carries // its own price, so there is no uncommitted value to derive a display from - // the price is on the label the user is reading when they choose. Both // cadences are on every label too, so the cadence field cannot leave a price // stale underneath it. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(active(20, 1)); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let screen = screen_of(&response); assert!( forms(screen) .iter() .any(|(_, action, _)| action == "/sync/cap"), "a running subscription offers a cap change" ); let cap = cap_field_of(screen).expect("the cap is chosen from named sizes"); assert!( !cap.options.is_empty(), "the cap is a choice, not a bare number" ); for choice in &cap.options { let gib: i64 = choice.value.parse().expect("a choice is a cap in GiB"); // The fixture prices a dollar a gibibyte a month, ten months a year. assert!( choice.label.contains(&format!("${gib} a month")), "every option prices itself monthly: {}", choice.label ); assert!( choice.label.contains(&format!("${} a year", gib * 10)), "and annually, so the cadence field cannot stale it: {}", choice.label ); } // The committed cap is 20 GiB, which is not one of the named sizes. It is // still selected, rather than the group reading as unanswered. assert_eq!(cap.value.as_deref(), Some("20")); assert!( cap.options.iter().any(|c| c.value == "20"), "a cap off the named list is still one of the options" ); } /// The cap field, wherever in the screen's regions it landed. fn cap_field_of(screen: &Screen) -> Option { nodes_deep(screen).into_iter().find_map(|node| match node { Node::Form { fields, .. } => fields .iter() .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Radio) .cloned(), _ => None, }) } /// A subscription that has lapsed, which is what puts the subscribe screen up. fn lapsed() -> Subscription { Subscription { active: false, limit_bytes: 0, used_bytes: 0, interval: "monthly".to_owned(), pending_limit_bytes: None, } } #[test] fn the_subscribe_screen_sizes_the_proposal_to_the_library() { // The measurement the redesign turns on: the app already knows how much // would upload, so it proposes a cap instead of soliciting one. 400 GiB of // samples wants half again as headroom - 600 - and the smallest named size // that covers 600 is 1024. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(lapsed()); sync.library_bytes = Some(400 * GIB); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let screen = screen_of(&response); assert!( said_deep(screen).contains("400 GiB"), "the screen states the need it sized against: {}", said_deep(screen) ); let cap = cap_field_of(screen).expect("a cap is offered"); assert_eq!( cap.value.as_deref(), Some("1024"), "the smallest named cap covering 400 GiB plus half again" ); } #[test] fn nothing_set_to_sync_proposes_the_floor_and_says_why() { // `Some(0)` is a real answer and a different one from "cannot look": no // vault has file sync on, so nothing would upload. Proposing the floor is // right, and so is saying why rather than showing a confident 250 GiB with // no reason attached. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(lapsed()); sync.library_bytes = Some(0); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let screen = screen_of(&response); assert!( said_deep(screen).contains("No vault is set to sync"), "{}", said_deep(screen) ); let cap = cap_field_of(screen).expect("a cap is offered"); assert_eq!( cap.value.as_deref(), Some("250"), "the cheapest thing on offer" ); } #[test] fn a_library_that_cannot_be_read_claims_no_size() { // `None` is "cannot look". The screen must not invent a need, and must not // print "0" as though it had measured one. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(lapsed()); sync.library_bytes = None; let response = syncing(&sync, Request::get("/sync")).expect("answered"); let screen = screen_of(&response); let text = said_deep(screen); assert!( !text.contains("would upload"), "no measurement is claimed: {text}" ); assert!( !text.contains("Proposed:"), "and nothing is proposed as sized: {text}" ); let cap = cap_field_of(screen).expect("a cap is still offered"); assert_eq!(cap.value.as_deref(), Some("250")); } #[test] fn a_filling_cap_warns_before_the_upload_fails() { // Item 5 of the redesign. Today the first news of a full cap is a 402 from // the blob route, which the user meets as a sync that broke. The screen // knows the number, so it says the consequence first - and says that // metadata sync carries on, which is the half that makes it not an outage. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(active(1024, 1000)); sync.library_bytes = Some(1000 * GIB); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let text = said_deep(screen_of(&response)); assert!(text.contains("close to your storage cap"), "{text}"); assert!( text.contains("everything else keeps syncing"), "the consequence is bounded, not an outage: {text}" ); // 1000 GiB plus half again is 1500, so 2048 is the smallest named cap that // holds it, and the warning carries what that costs. assert!( text.contains("2048 GiB") || text.contains("2.0 TiB"), "{text}" ); assert!( text.contains("$2048 a month"), "priced, at the fixture rate: {text}" ); } #[test] fn a_full_cap_says_uploads_have_already_stopped() { // The other side of the same sentence, and it is a different one: "will // stop" and "have stopped" are not degrees of one message. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(active(1024, 1024)); sync.library_bytes = Some(1024 * GIB); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let text = said_deep(screen_of(&response)); assert!(text.contains("cap is full"), "{text}"); assert!(text.contains("not uploading"), "{text}"); } #[test] fn a_cap_with_room_says_nothing_about_filling() { // The warning is a warning. A subscription at 5% must not carry it, or it // stops being read. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(active(1024, 50)); sync.library_bytes = Some(50 * GIB); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let text = said_deep(screen_of(&response)); assert!(!text.contains("storage cap"), "{text}"); assert!(!text.contains("cap is full"), "{text}"); } #[test] fn the_exact_cap_form_carries_the_bounds_it_claims() { // The old `cap_field` was a bare `FieldKind::Number` with no min and no max, // despite a doc comment saying the bounds were what a renderer draws. The // only thing rejecting an out-of-range cap was `cap_from`, after submit. let mut sync = FakeSync::in_state(State::Ready); sync.subscription = Some(lapsed()); let response = syncing(&sync, Request::get("/sync")).expect("answered"); let screen = screen_of(&response); let exact = nodes_deep(screen) .into_iter() .find_map(|node| match node { Node::Form { fields, .. } => fields .iter() .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Number) .cloned(), _ => None, }) .expect("an exact cap can be typed"); assert_eq!(exact.min.as_deref(), Some("10"), "the fixture's floor"); assert_eq!(exact.max.as_deref(), Some("2048"), "the fixture's ceiling"); } #[test] fn the_file_list_describes_a_column_per_shown_flag() { // The columns were described before this port existed: // `ui::file_list::describe` already built `makeover_layout::Column`s. What // the port adds is the address a heading calls. let files = FakeFiles::with(vec![sample(1, "kick.wav")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (columns, rows) = table_of(screen_of(&response)); let names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); // Peak is off in the fixture, so it is not described at all. assert_eq!(names, ["Name", "Duration", "BPM", "Key", "Tags", "Play"]); assert_eq!(rows.len(), 1); assert_eq!(rows[0].values.len(), columns.len(), "a cell per column"); } #[test] fn only_the_columns_with_a_sort_carry_an_address() { // Peak and Tags have no sort of their own and never had one, which is what // `Column::sortable` says when it is false: headings rather than controls. let files = FakeFiles::with(vec![sample(1, "kick.wav")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (columns, _) = table_of(screen_of(&response)); for column in &columns { let addressed = column.reorder.is_some(); let expected = matches!(column.name.as_str(), "Name" | "Duration" | "BPM" | "Key"); assert_eq!( addressed, expected, "{} carried the wrong address", column.name ); } } #[test] fn the_column_in_force_carries_its_caret_and_the_others_do_not() { let mut files = FakeFiles::with(vec![sample(1, "kick.wav")]); files.by = "BPM".to_owned(); files.ascending = false; let response = listing(&files, Request::get("/files")).expect("answered"); let (columns, _) = table_of(screen_of(&response)); for column in &columns { let sorted = column.sorted; if column.name == "BPM" { assert_eq!(sorted, Some(quasi_router::layout::Sort::Descending)); } else { assert_eq!(sorted, None, "{} claimed a sort", column.name); } } } #[test] fn a_row_is_addressed_by_its_own_id_and_not_by_where_it_sits() { // An index is a fact about the current filter and sort, which is exactly // what an address should not be. let files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); let opens: Vec = rows .iter() .filter_map(|row| row.activate.as_ref()) .map(|action| action.destination.as_str().to_owned()) .collect(); assert_eq!(opens, ["/files/7/open", "/files/9/open"]); } #[test] fn pressing_a_row_and_a_heading_reaches_the_app() { let files = FakeFiles::with(vec![sample(7, "kick.wav")]); listing(&files, Request::post("/files/7/open")).expect("answered"); listing(&files, Request::post("/files/7/play")).expect("answered"); listing(&files, Request::post("/files/sort/BPM")).expect("answered"); assert_eq!(files.asked(), ["open:7", "play:7", "sort:BPM"]); } #[test] fn a_sample_row_offers_the_menu_the_shipped_one_offers() { // `Cells::menu`, the member this screen asked quasi-router for. The // assertion is against `draw_context_menu`'s sample branch: same acts, same // order, same words. let files = FakeFiles::with(vec![sample(7, "kick.wav")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); assert_eq!( labels, [ "Preview", "Copy Path", crate::ui::file_list_menus::reveal_label(), "Find Similar", "Find Duplicates", "Edit...", "Play as Instrument", "Export...", "Re-analyze...", "Delete", ], "the described menu drifted from the shipped one" ); // Addressed by the row's own id, like `activate` and for the same reason. let delete = rows[0].menu.last().expect("the menu ends in Delete"); assert_eq!(delete.action.destination.as_str(), "/files/7/delete"); // The one destructive entry says so, and carries the question rather than // leaving each renderer to invent one. assert_eq!(delete.tone, quasi_router::layout::Tone::Danger); assert_eq!(delete.confirm.as_deref(), Some("Delete kick.wav?")); // No collection is open in the fixture, so the entry that only makes sense // inside one is absent rather than drawn dead. assert!(!labels.contains(&"Remove from Collection")); } #[test] fn a_folder_row_offers_the_folder_menu_and_nothing_about_samples() { // The branch. A folder has no analysis to redo and nothing to preview, and // the shipped menu offers it five entries instead of ten. let files = FakeFiles::with(vec![folder(4, "Drums")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (columns, rows) = table_of(screen_of(&response)); let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); assert_eq!( labels, ["Open", "New Folder", "Rename", "Export...", "Delete"] ); // The two that were already described point at the addresses that already // answer them rather than at new ones. assert_eq!(rows[0].menu[1].action.destination.as_str(), "/folders/new"); assert_eq!( rows[0].menu[2].action.destination.as_str(), "/folders/4/rename" ); // And a folder still fills every column, because cells are positional: the // Play cell is empty rather than missing. assert_eq!(rows[0].values.len(), columns.len()); assert!( !rows[0] .values .last() .expect("a Play cell") .carries_control(), "a folder offered something to play" ); } #[test] fn a_cloud_only_row_offers_the_fetch_and_withholds_what_needs_the_bytes() { // The third branch, and the one a description could get quietly wrong: the // acts are all still *sayable* for a sample nobody has fetched, and offering // them would be offering acts that fail on a file that is not there. let files = FakeFiles::with(vec![cloud_only(9, "snare.wav")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); assert_eq!( labels, [ "Download", "Copy Path", "Find Similar", "Find Duplicates", "Delete", ] ); // Named rather than left to the count above: these four are the ones that // need the file on disk. for withheld in ["Preview", "Edit...", "Play as Instrument", "Re-analyze..."] { assert!(!labels.contains(&withheld), "{withheld} was offered"); } } #[test] fn a_collection_being_shown_adds_the_entry_that_only_means_something_there() { let files = FakeFiles::with(vec![sample(7, "kick.wav")]); let response = listing_in_collection(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); assert!(labels.contains(&"Remove from Collection"), "{labels:?}"); } #[test] fn add_to_collection_is_one_entry_that_asks_which_one_rather_than_a_submenu() { // The member this entry waited on was never a submenu. `Act::asking` is a // control that wants a value before it fires, so the menu holds one line // however many collections exist, and the list is on the act. let files = FakeFiles::with(vec![sample(7, "kick.wav")]); let response = listing_in_collection(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); let add = rows[0] .menu .iter() .find(|act| act.label == "Add to Collection") .expect("the entry is offered when there is a collection to offer"); assert_eq!(add.action.destination.as_str(), "/files/7/collection/add"); let asked = add.asks.first().expect("it asks which collection"); assert_eq!(asked.name, "collection"); assert_eq!(asked.kind, quasi_router::layout::FieldKind::Select); // The value is the id and the label is the name, so the handler reads a // number and the user reads a collection. let offered: Vec<(&str, &str)> = asked .options .iter() .map(|choice| (choice.value.as_str(), choice.label.as_str())) .collect(); assert_eq!(offered, [("3", "Kicks")]); } #[test] fn nothing_offers_add_to_collection_when_there_is_no_collection() { // Not drawn dead: an act asking a question with no answers is a control the // user can press and cannot satisfy. let files = FakeFiles::with(vec![sample(7, "kick.wav")]); let response = listing(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); assert!(!labels.contains(&"Add to Collection"), "{labels:?}"); } #[test] fn adding_to_a_collection_carries_both_ids_to_the_app() { // The row comes from the address and the collection from what the act // asked, which is the whole difference between this entry and every other // one in the menu. let files = FakeFiles::with(vec![sample(7, "kick.wav")]); let request = Request::post("/files/7/collection/add") .sending(Params::new().with("collection".to_owned(), "3".to_owned())); listing_in_collection(&files, request).expect("answered"); assert_eq!(files.asked(), ["collect:7->3"]); } #[test] fn a_collection_that_went_away_is_refused_rather_than_guessed_at() { // The list the act offered was built when the menu opened. A value outside // it means the collection was deleted since, and adding to a collection // that is not there is not something to do quietly. let files = FakeFiles::with(vec![sample(7, "kick.wav")]); let request = Request::post("/files/7/collection/add") .sending(Params::new().with("collection".to_owned(), "99".to_owned())); let refused = listing_in_collection(&files, request); assert!(refused.is_err(), "an unknown collection is not an add"); assert!(files.asked().is_empty(), "and nothing reached the app"); } #[test] fn every_menu_entry_reaches_the_app_at_the_row_it_was_opened_on() { // The whole point of the member: the acts are addresses, and pressing one // has to arrive with the row's id rather than with whatever is selected. Two // rows in the fixture so an id that came from the selection would show up. let files = FakeFiles::with(vec![sample(7, "kick.wav"), folder(4, "Drums")]); for verb in [ "enter", "path/copy", "reveal", "similar", "duplicates", "edit", "instrument", "reanalyze", "delete", "download", "collection/remove", ] { listing(&files, Request::post(format!("/files/7/{verb}"))).expect("answered"); } assert_eq!( files.asked(), [ "enter:7", // The five that borrow a capability select the row and then let the // detail handle act on it, so what this fake sees is the selection. "open:7", "reveal:7", "open:7", "open:7", "open:7", "instrument:7", "reanalyze:7", "delete:7", "download:7", "uncollect:7", ] ); } #[test] fn a_column_with_no_sort_is_refused_rather_than_ordered_by() { let files = FakeFiles::with(vec![sample(1, "kick.wav")]); let refused = listing(&files, Request::post("/files/sort/Tags")); assert!(refused.is_err(), "Tags was accepted as a sort"); assert!(files.asked().is_empty()); } #[test] fn an_empty_list_says_so_and_offers_the_way_out() { // `703f4cd2`: the sentence and the way out are both on the node, because a // region with a heading and no rows still has content. let files = FakeFiles::with(Vec::new()); let response = listing(&files, Request::get("/files")).expect("answered"); let stand_in = screen_of(&response) .slots .iter() .flat_map(|slot| &slot.body) .find_map(|placed| match &placed.node { Node::StandIn { message, act, .. } => Some((message.clone(), act.clone())), _ => None, }) .expect("an empty list says so"); assert!(stand_in.0.contains("Nothing here")); assert!(stand_in.1.is_some(), "the empty list offered no way out"); } #[test] fn the_row_the_app_is_pointing_at_is_the_current_one() { let mut files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]); files.current = Some(9); let response = listing(&files, Request::get("/files")).expect("answered"); let (_, rows) = table_of(screen_of(&response)); assert_eq!( rows.iter().map(|row| row.current).collect::>(), [false, true] ); } // --- the export flow --- #[test] fn nothing_to_export_says_so_and_offers_no_way_to_start_one() { // The flow is entered from the file list, so a control here would be an // affordance the shipped app does not have. let export = FakeExport::at(Phase::Idle); let screen = exported(&export); assert!(matches!( nodes(&screen).as_slice(), [Node::StandIn { act: None, .. }] )); } #[test] fn the_configure_screen_names_what_is_going_and_what_it_will_be_written_as() { let export = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0), subject("snare", 1.0)], profiles: Vec::new(), settings: defaults(), }); let screen = exported(&export); let said = said(&screen); assert!(said.contains("2 samples to export"), "{said}"); // No profiles, so the picker is not offered at all rather than offered // empty: an empty dropdown is a control that cannot be used. assert!(!said.contains("Device Profile"), "{said}"); assert!(said.contains("Format"), "{said}"); assert!(said.contains("Destination"), "{said}"); } #[test] fn copying_as_is_says_nothing_about_rates_and_re_encoding_says_everything() { // The rate and depth exist only when something is being re-encoded, which // is the shipped screen's own rule. A described screen that named them // under Original would be describing controls that do nothing. let original = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: Vec::new(), settings: defaults(), }); let said_of_original = said(&exported(&original)); assert!( !said_of_original.contains("Sample Rate"), "{said_of_original}" ); assert!( !said_of_original.contains("strips embedded metadata"), "{said_of_original}" ); let wav = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: Vec::new(), settings: Settings { format: Format::Wav, ..defaults() }, }); let said_of_wav = said(&exported(&wav)); assert!(said_of_wav.contains("Sample Rate"), "{said_of_wav}"); assert!(said_of_wav.contains("Bit Depth"), "{said_of_wav}"); assert!( said_of_wav.contains("strips embedded metadata"), "{said_of_wav}" ); } #[test] fn a_device_profile_takes_the_audio_settings_off_the_screen() { // The profile owns them, so a control for them would be one the export // pipeline overrides. The shipped screen hides the whole block; so does // this, and it says what the lock is hiding instead. let export = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: vec![ProfileChoice { name: "SP-404 MKII".to_owned(), manufacturer: "Roland".to_owned(), summary: Some("WAV, 44.1k, 16-bit, Mono".to_owned()), category: Some("Sampler".to_owned()), notes: None, max_file_size_bytes: None, }], settings: Settings { device_profile: Some("SP-404 MKII".to_owned()), ..defaults() }, }); let said = said(&exported(&export)); assert!(said.contains("Device Profile"), "{said}"); assert!(said.contains("by Roland"), "{said}"); assert!(said.contains("WAV, 44.1k, 16-bit, Mono"), "{said}"); assert!(!said.contains("Sample Rate"), "{said}"); assert!(!said.contains("Channels"), "{said}"); } #[test] fn a_sample_too_long_for_an_aiff_chunk_is_warned_about_before_anything_is_written() { // Four gigabytes at 48 kHz / 24-bit stereo is about 4 hours, so five hours // is over and one minute is not. The arithmetic is the shipped screen's. let over = FakeExport::at(Phase::Configuring { subjects: vec![subject("drone", 5.0 * 3600.0)], profiles: Vec::new(), settings: Settings { format: Format::Aiff, ..defaults() }, }); assert!(said(&exported(&over)).contains("AIFF chunks cap at 4 GB"),); let under = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 60.0)], profiles: Vec::new(), settings: Settings { format: Format::Aiff, ..defaults() }, }); assert!(!said(&exported(&under)).contains("AIFF chunks cap"),); } #[test] fn a_sample_too_big_for_the_device_is_warned_about_by_name_when_it_is_the_only_one() { let profile = |cap: u64| ProfileChoice { name: "SP-404 MKII".to_owned(), manufacturer: "Roland".to_owned(), summary: None, category: None, notes: None, max_file_size_bytes: Some(cap), }; let configuring = |subjects: Vec, cap: u64| { FakeExport::at(Phase::Configuring { subjects, profiles: vec![profile(cap)], settings: Settings { device_profile: Some("SP-404 MKII".to_owned()), ..defaults() }, }) }; // One over the cap is named; two are counted. The difference is the shipped // screen's and it is worth keeping: a name is actionable and a count is not. let one = configuring( vec![subject("drone", 600.0), subject("kick", 0.5)], 1_000_000, ); let said_of_one = said(&exported(&one)); assert!( said_of_one.contains("\"drone\" may exceed"), "{said_of_one}" ); let two = configuring( vec![subject("drone", 600.0), subject("pad", 700.0)], 1_000_000, ); let said_of_two = said(&exported(&two)); assert!( said_of_two.contains("2 samples may exceed"), "{said_of_two}" ); } #[test] fn a_naming_pattern_is_previewed_against_the_first_sample_and_a_typo_is_reported() { let flattened = |pattern: &str| { FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: Vec::new(), settings: Settings { flatten: true, naming_pattern: Some(pattern.to_owned()), ..defaults() }, }) }; let good = flattened("{name}-{bpm}"); let said_of_good = said(&exported(&good)); assert!(said_of_good.contains("Preview: kick-120"), "{said_of_good}"); // The point of the preview: a typo is caught before two hundred files are // written under it. let bad = flattened("{nmae}"); let said_of_bad = said(&exported(&bad)); assert!(said_of_bad.contains("Pattern:"), "{said_of_bad}"); assert!(!said_of_bad.contains("Preview:"), "{said_of_bad}"); } #[test] fn a_naming_pattern_is_only_described_when_the_tree_is_being_flattened() { // It names files in one folder. With the tree preserved there is nothing // for it to do, and the shipped screen does not draw it either. let export = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: Vec::new(), settings: Settings { flatten: false, naming_pattern: Some("{name}".to_owned()), ..defaults() }, }); assert!(!said(&exported(&export)).contains("Naming Pattern"),); } #[test] fn every_control_writes_through_one_route_and_an_undeclared_setting_is_refused() { let export = FakeExport::at(Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: Vec::new(), settings: defaults(), }); exporting( &export, Request { method: Method::Post, path: "/export/set/format".to_owned(), captures: Params::new().with("setting", "format"), payload: Params::new().with("format", "wav"), carried: Params::new(), }, ) .unwrap(); assert_eq!(export.asked.borrow().as_slice(), ["set:format=wav"]); // An address is reachable by typing, so a name the description does not // carry is a refusal rather than a panic or a silent no-op. let refused = exporting( &export, Request { method: Method::Post, path: "/export/set/bitrate".to_owned(), captures: Params::new().with("setting", "bitrate"), payload: Params::new(), carried: Params::new(), }, ) .unwrap_err(); assert_eq!(refused.class, quasi_router::Class::NotFound); assert_eq!(export.asked.borrow().len(), 1, "the refusal wrote nothing"); } #[test] fn a_worker_that_has_not_counted_the_files_yet_reads_as_pending_not_as_finished() { // A meter of 0 of 0 draws full, which would say the export is done before // it has started. Readiness is what says "working on it". let starting = FakeExport::at(Phase::Running { done: 0, total: 0, current: String::new(), }); let screen = exported(&starting); assert!(nodes(&screen).iter().any(|node| matches!( node, Node::StandIn { state: quasi_router::layout::Readiness::Pending, .. } ))); assert!( !nodes(&screen) .iter() .any(|node| matches!(node, Node::Meter(_))) ); let running = FakeExport::at(Phase::Running { done: 3, total: 10, current: "kick.wav".to_owned(), }); let screen = exported(&running); let meter = nodes(&screen) .into_iter() .find_map(|node| match node { Node::Meter(meter) => Some(meter.clone()), _ => None, }) .expect("a counted export describes its proportion"); assert_eq!((meter.done, meter.total), (3, 10)); assert!(said(&screen).contains("Exporting: kick.wav")); } #[test] fn cancelling_a_running_export_asks_the_app_rather_than_deciding_itself() { let export = FakeExport::at(Phase::Running { done: 3, total: 10, current: "kick.wav".to_owned(), }); exporting(&export, Request::post("/export/cancel")).unwrap(); assert_eq!(export.asked.borrow().as_slice(), ["cancel"]); } #[test] fn a_clean_finish_says_so_and_a_dirty_one_names_every_file_that_failed() { let clean = FakeExport::at(Phase::Finished { total: 12, errors: Vec::new(), destination: Some("/tmp/export".to_owned()), }); let screen = exported(&clean); assert!(said(&screen).contains("Successfully exported 12 files")); let dirty = FakeExport::at(Phase::Finished { total: 10, errors: vec![ ("kick.wav".to_owned(), "disk full".to_owned()), ("snare.wav".to_owned(), "permission denied".to_owned()), ], destination: None, }); let screen = exported(&dirty); assert!(said(&screen).contains("10 files with 2 errors")); // Every failure in one list, because the set is what is being reported. let rows = nodes(&screen) .into_iter() .find_map(|node| match node { Node::List { rows, .. } => Some(rows.clone()), _ => None, }) .expect("the failures are a list"); assert_eq!(rows.len(), 2); } #[test] fn where_the_files_went_is_offered_only_when_the_app_knows_where_that_was() { let known = FakeExport::at(Phase::Finished { total: 1, errors: Vec::new(), destination: Some("/tmp/export".to_owned()), }); let screen = exported(&known); assert!(said(&screen).contains("Export Complete")); assert!( nodes(&screen).iter().any(|node| matches!( node, Node::Act(act) if act.action.destination.route().is_none() )), "the folder is opened by the host, so the destination is external", ); let unknown = FakeExport::at(Phase::Finished { total: 1, errors: Vec::new(), destination: None, }); let screen = exported(&unknown); assert!( !nodes(&screen).iter().any(|node| matches!( node, Node::Act(act) if act.action.destination.route().is_none() )), "an export with nowhere recorded offers no folder to open", ); } #[test] fn a_cancelled_export_says_what_landed_and_where_it_is() { // The whole reason the shipped app has this screen: partial files are on // the disk and the user needs to know whether to re-run or clean up. let export = FakeExport::at(Phase::Cancelled { done: 4, total: 10, destination: Some("/tmp/export".to_owned()), }); let said = said(&exported(&export)); assert!(said.contains("4 of 10 samples were written"), "{said}"); assert!(said.contains("/tmp/export"), "{said}"); } #[test] fn every_way_out_of_the_flow_goes_through_one_route() { // Done, Cancel-before-starting and Done-after-cancelling are one act: the // flow is over. Three routes would be three places to forget to reset it. for phase in [ Phase::Configuring { subjects: vec![subject("kick", 2.0)], profiles: Vec::new(), settings: defaults(), }, Phase::Finished { total: 1, errors: Vec::new(), destination: None, }, Phase::Cancelled { done: 1, total: 2, destination: None, }, ] { let export = FakeExport::at(phase); exporting(&export, Request::post("/export/dismiss")).unwrap(); assert_eq!(export.asked.borrow().as_slice(), ["dismiss"]); } } // The detail panel. /// A detail panel with nothing chosen. /// /// [`Offline`]'s peer, and here for the same reason: `Panels` is one state for /// every screen, so a settings test still has to name a detail panel. struct Unfocused; impl Detail for Unfocused { fn focus(&self) -> Focus { Focus::Nothing } fn add_tag(&self, _tag: &str) {} fn remove_tag(&self, _tag: &str) {} fn suggest(&self) {} fn accept(&self, _tag: &str) {} fn copy_path(&self) {} fn edit(&self) {} fn forge(&self) {} fn find_similar(&self) {} fn find_duplicates(&self) {} fn spread_tag(&self, _tag: &str) {} fn strip_tag(&self, _tag: &str) {} } /// A detail panel in memory, recording what was asked of it. struct FakeDetail { focus: Focus, asked: RefCell>, } impl FakeDetail { fn at(focus: Focus) -> Self { Self { focus, asked: RefCell::new(Vec::new()), } } fn note(&self, what: impl Into) { self.asked.borrow_mut().push(what.into()); } fn asked(&self) -> Vec { self.asked.borrow().clone() } } impl Detail for FakeDetail { fn focus(&self) -> Focus { self.focus.clone() } fn add_tag(&self, tag: &str) { self.note(format!("add {tag}")); } fn remove_tag(&self, tag: &str) { self.note(format!("remove {tag}")); } fn suggest(&self) { self.note("suggest"); } fn accept(&self, tag: &str) { self.note(format!("accept {tag}")); } fn copy_path(&self) { self.note("copy"); } fn edit(&self) { self.note("edit"); } fn forge(&self) { self.note("forge"); } fn find_similar(&self) { self.note("similar"); } fn find_duplicates(&self) { self.note("duplicates"); } fn spread_tag(&self, tag: &str) { self.note(format!("spread {tag}")); } fn strip_tag(&self, tag: &str) { self.note(format!("strip {tag}")); } } /// A router call against this detail panel. fn detailing(detail: &FakeDetail, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The screen the detail panel answers, at whatever it is focused on. fn detailed(detail: &FakeDetail) -> Screen { screen_of(&detailing(detail, Request::get("/detail")).unwrap()).clone() } /// A sample with everything analysis can find. fn analysed() -> Detailed { Detailed { id: 7, name: "kick.wav".to_owned(), path: Some("/vault/kick.wav".to_owned()), analysis: Some(Analysis { duration: 1.5, sample_rate: 48_000, channels: 2, bpm: Some(120.0), musical_key: Some("Am".to_owned()), peak_db: Some(-3.2), rms_db: Some(-14.0), lufs: Some(-11.5), is_loop: Some(false), }), tags: vec![ Tagged { name: "drums".to_owned(), source: Source::Manual, }, Tagged { name: "kick".to_owned(), source: Source::Rule, }, ], suggestions: Vec::new(), is_sample: true, has_spectral: true, has_fingerprint: true, } } /// One sample, focused. fn one(sample: Detailed) -> Focus { Focus::One(Box::new(sample)) } /// Several samples, focused. fn several(spread: Spread) -> Focus { Focus::Several(Box::new(spread)) } /// A selection of two that agrees about nothing. fn mixed() -> Spread { Spread { samples: 2, folders: 1, bpm: Shared::Varies, musical_key: Shared::Absent, duration: Shared::Same("1.5s".to_owned()), tags: vec![ Coverage { name: "drums".to_owned(), on: 2, }, Coverage { name: "loop".to_owned(), on: 1, }, ], } } /// Every act on a screen that is not answering, by label. fn dead(screen: &Screen) -> Vec { nodes(screen) .iter() .filter_map(|node| match node { Node::Act(act) if act.state == Some(quasi_router::layout::State::Disabled) => { Some(act.label.clone()) } _ => None, }) .collect() } #[test] fn nothing_chosen_says_so_and_offers_nothing() { let detail = FakeDetail::at(Focus::Nothing); let screen = detailed(&detail); assert!(said(&screen).contains("Select a sample")); assert!(acts(&screen).is_empty()); } #[test] fn one_sample_reports_every_field_analysis_found() { let detail = FakeDetail::at(one(analysed())); let screen = detailed(&detail); let (_, rows) = table_of(&screen); let facts: Vec<(String, String)> = rows .iter() .map(|row| (cell_text(row, 0), cell_text(row, 1))) .collect(); let field = |name: &str| { facts .iter() .find(|(field, _)| field == name) .map(|(_, value)| value.clone()) }; assert_eq!(field("Duration").as_deref(), Some("1.5s")); assert_eq!(field("BPM").as_deref(), Some("120")); assert_eq!(field("Key").as_deref(), Some("Am")); assert_eq!(field("Sample rate").as_deref(), Some("48000 Hz")); assert_eq!(field("Channels").as_deref(), Some("2")); assert_eq!(field("Peak").as_deref(), Some("-3.2 dB")); assert_eq!(field("RMS").as_deref(), Some("-14.0 dB")); assert_eq!(field("LUFS").as_deref(), Some("-11.5")); assert_eq!(field("Loop").as_deref(), Some("No")); } #[test] fn a_field_analysis_did_not_find_is_absent_rather_than_blank() { let mut sample = analysed(); if let Some(analysis) = sample.analysis.as_mut() { analysis.bpm = None; analysis.musical_key = None; analysis.lufs = None; } let detail = FakeDetail::at(one(sample)); let (_, rows) = table_of(&detailed(&detail)); let fields: Vec = rows.iter().map(|row| cell_text(row, 0)).collect(); assert!(!fields.iter().any(|field| field == "BPM")); assert!(!fields.iter().any(|field| field == "Key")); assert!(!fields.iter().any(|field| field == "LUFS")); assert!(fields.iter().any(|field| field == "Duration")); } #[test] fn a_tag_carries_where_it_came_from_and_removes_itself() { let detail = FakeDetail::at(one(analysed())); let screen = detailed(&detail); let tokens: Vec = nodes(&screen) .iter() .filter_map(|node| match node { Node::Token(tag) => Some(tag.clone()), _ => None, }) .collect(); assert_eq!(tokens.len(), 2); assert!(tokens[0].label.contains("drums")); assert!(tokens[0].label.contains("manual")); assert!(tokens[1].label.contains("rule")); // Every one of them is removable and says where the removal goes, which is // what the shipped panel's `tag_chip_removable` does with a bool. for tag in &tokens { assert_eq!( tag.kind, quasi_router::layout::Token::Chip { removable: true } ); assert!(tag.action.is_some()); } } #[test] fn removing_a_tag_asks_for_that_tag() { let detail = FakeDetail::at(one(analysed())); detailing(&detail, Request::post("/detail/tags/drums/remove")).unwrap(); assert_eq!(detail.asked(), ["remove drums"]); } #[test] fn adding_a_tag_carries_what_was_typed_and_refuses_an_empty_one() { let detail = FakeDetail::at(one(analysed())); detailing( &detail, Request::post("/detail/tags") .sending(Params::new().with("tag".to_owned(), "genre.house".to_owned())), ) .unwrap(); assert_eq!(detail.asked(), ["add genre.house"]); let empty = FakeDetail::at(one(analysed())); let response = detailing( &empty, Request::post("/detail/tags").sending(Params::new().with("tag".to_owned(), String::new())), ) .unwrap(); assert!(empty.asked().is_empty()); assert!(response.notice.is_some()); } #[test] fn a_suggestion_says_its_score_and_how_many_carry_it() { let mut sample = analysed(); sample.suggestions = vec![Suggested { tag: "percussion".to_owned(), score: 0.82, neighbours: 4, }]; let detail = FakeDetail::at(one(sample)); let labels = acts(&detailed(&detail)); let offer = labels .iter() .find(|label| label.contains("percussion")) .expect("the suggestion is offered"); assert!(offer.contains("82%")); assert!(offer.contains('4')); } #[test] fn discovery_is_offered_dead_with_its_precondition_said_beside_it() { let mut sample = analysed(); sample.has_spectral = false; sample.has_fingerprint = false; let detail = FakeDetail::at(one(sample)); let screen = detailed(&detail); // Offered rather than hidden, which is the shipped panel's choice: a // control that vanishes teaches nothing. assert!(acts(&screen).iter().any(|label| label == "Find Similar")); assert_eq!(dead(&screen), ["Find Similar", "Find Duplicates"]); // And the sentence that would revive each is said. THE FINDING is that it // is said beside the control rather than on it -- see the module header. let says = said(&screen); assert!(says.contains("spectral features")); assert!(says.contains("fingerprinting")); } #[test] fn discovery_answers_where_the_features_are_there() { let detail = FakeDetail::at(one(analysed())); let screen = detailed(&detail); assert!(dead(&screen).is_empty()); detailing(&detail, Request::post("/detail/similar")).unwrap(); detailing(&detail, Request::post("/detail/duplicates")).unwrap(); assert_eq!(detail.asked(), ["similar", "duplicates"]); } #[test] fn discovery_refuses_a_typed_request_the_control_would_have_refused() { let mut sample = analysed(); sample.has_spectral = false; sample.has_fingerprint = false; let detail = FakeDetail::at(one(sample)); assert!(detailing(&detail, Request::post("/detail/similar")).is_err()); assert!(detailing(&detail, Request::post("/detail/duplicates")).is_err()); assert!(detail.asked().is_empty()); } #[test] fn a_folder_is_offered_neither_the_editors_nor_discovery() { let mut sample = analysed(); sample.is_sample = false; let detail = FakeDetail::at(one(sample)); let labels = acts(&detailed(&detail)); assert!(!labels.iter().any(|label| label == "Edit")); assert!(!labels.iter().any(|label| label == "Forge")); assert!(!labels.iter().any(|label| label == "Find Similar")); // The path is still copyable: a folder has one. assert!(labels.iter().any(|label| label == "Copy Path")); } #[test] fn several_chosen_says_what_they_agree_on_three_ways() { let detail = FakeDetail::at(several(mixed())); let screen = detailed(&detail); let (_, rows) = table_of(&screen); let facts: Vec<(String, String)> = rows .iter() .map(|row| (cell_text(row, 0), cell_text(row, 1))) .collect(); // Three answers rather than the shipped panel's two strings: disagreement // and absence are different facts and each renderer can now tell them // apart. assert_eq!(facts[0], ("BPM".to_owned(), "varies".to_owned())); assert_eq!(facts[1], ("Key".to_owned(), "\u{2014}".to_owned())); assert_eq!(facts[2], ("Duration".to_owned(), "1.5s".to_owned())); assert!(said(&screen).contains("2 samples \u{b7} 1 folders selected")); } #[test] fn a_partly_covered_tag_says_how_far_it_reaches_and_offers_both_ways() { let detail = FakeDetail::at(several(mixed())); let screen = detailed(&detail); let rows = list_of(&screen); let full = &rows[0]; let partial = &rows[1]; // The count is in the row rather than in a hover, so a reader with no // pointer still has it. assert_eq!(meta_of(full), None); assert_eq!(meta_of(partial).as_deref(), Some("1 of 2")); // A tag every sample carries has nothing to spread, so only the removal is // offered. One that is partial offers both. assert_eq!(full.menu.len(), 1); assert_eq!(partial.menu.len(), 2); assert!(partial.menu[0].label.contains("Apply to remaining (1)")); assert_eq!(partial.menu[1].tone, quasi_router::layout::Tone::Danger); } #[test] fn spreading_and_stripping_name_the_tag_they_act_on() { let detail = FakeDetail::at(several(mixed())); detailing(&detail, Request::post("/detail/selection/tags/loop/spread")).unwrap(); detailing(&detail, Request::post("/detail/selection/tags/drums/strip")).unwrap(); assert_eq!(detail.asked(), ["spread loop", "strip drums"]); } #[test] fn a_selection_of_folders_alone_has_nothing_to_summarize() { let detail = FakeDetail::at(several(Spread { samples: 0, folders: 3, bpm: Shared::Absent, musical_key: Shared::Absent, duration: Shared::Absent, tags: Vec::new(), })); let screen = detailed(&detail); assert!(said(&screen).contains("No sample metadata to summarize")); assert!(dead(&screen).is_empty()); } #[test] fn the_host_acts_are_asked_for_rather_than_performed() { let detail = FakeDetail::at(one(analysed())); for address in [ "/detail/path/copy", "/detail/edit", "/detail/forge", "/detail/tags/suggest", "/detail/tags/percussion/accept", ] { detailing(&detail, Request::post(address)).unwrap(); } assert_eq!( detail.asked(), ["copy", "edit", "forge", "suggest", "accept percussion"] ); } /// The text of one cell in a table row. fn cell_text(row: &quasi_router::Cells, at: usize) -> String { row.values[at] .parts .iter() .filter_map(|node| match node { Node::Text { text, .. } => Some(text.clone()), _ => None, }) .collect::() } /// A row's trailing fact, if it has one. fn meta_of(row: &quasi_router::Row) -> Option { row.parts .iter() .find(|part| part.role == quasi_router::layout::RowPart::Meta) .and_then(|part| match &part.node { Node::Text { text, .. } => Some(text.clone()), _ => None, }) } /// The rows of the list on a screen. fn list_of(screen: &Screen) -> Vec { nodes(screen) .iter() .find_map(|node| match node { Node::List { rows, .. } => Some(rows.clone()), _ => None, }) .expect("the screen draws a list") } // The bulk modals. /// A selection with nothing in it. /// /// [`Unfocused`]'s peer, and here for the same reason `Offline` is. struct Unchosen; impl Bulk for Unchosen { fn done(&self) {} fn chosen(&self) -> Chosen { Chosen { names: Vec::new(), samples: 0, } } fn known_tags(&self) -> Vec { Vec::new() } fn folders(&self) -> Vec { Vec::new() } fn previews(&self, _pattern: &str) -> Result, String> { Ok(Vec::new()) } fn tag(&self, _tag: &str, _adding: bool) {} fn move_to(&self, _folder: Option) {} fn rename(&self, _pattern: &str) {} } /// A selection in memory, recording what was asked of it. struct FakeBulk { chosen: Chosen, tags: Vec, folders: Vec, asked: RefCell>, /// Whether the modal was told it is finished with. /// /// Its own field rather than a row in `asked`, for `FakeNaming::finished`'s /// reason: `asked` answers what this screen did to the library, and putting /// a window away does nothing to it. finished: std::cell::Cell, } impl FakeBulk { fn of(names: &[&str], samples: usize) -> Self { Self { chosen: Chosen { names: names.iter().map(|name| (*name).to_owned()).collect(), samples, }, tags: vec!["drums".to_owned(), "loop".to_owned()], folders: vec![ Folder { id: 3, path: "/kits".to_owned(), }, Folder { id: 4, path: "/kits/808".to_owned(), }, ], asked: RefCell::new(Vec::new()), finished: std::cell::Cell::new(false), } } fn finished(&self) -> bool { self.finished.get() } fn asked(&self) -> Vec { self.asked.borrow().clone() } } impl Bulk for FakeBulk { fn done(&self) { self.finished.set(true); } fn chosen(&self) -> Chosen { self.chosen.clone() } fn known_tags(&self) -> Vec { self.tags.clone() } fn folders(&self) -> Vec { self.folders.clone() } /// A stand-in for the app's rename engine, with the two answers the screen /// branches on: a pattern with no `{` is a literal, which renames every file /// to the same name, and an unclosed `{` is what half-typed looks like. fn previews(&self, pattern: &str) -> Result, String> { if pattern.contains('{') && !pattern.contains('}') { return Err("unclosed token".to_owned()); } Ok(self .chosen .names .iter() .map(|name| { let new = if pattern.contains("{name}") { pattern.replace("{name}", name.trim_end_matches(".wav")) } else { pattern.to_owned() }; (name.clone(), format!("{new}.wav")) }) .collect()) } fn tag(&self, tag: &str, adding: bool) { self.asked .borrow_mut() .push(format!("{} {tag}", if adding { "add" } else { "remove" })); } fn move_to(&self, folder: Option) { self.asked.borrow_mut().push(match folder { Some(id) => format!("move {id}"), None => "move root".to_owned(), }); } fn rename(&self, pattern: &str) { self.asked.borrow_mut().push(format!("rename {pattern}")); } } /// A router call against this selection. fn bulking(bulk: &FakeBulk, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The screen a bulk address answers, and the outcome it came in. fn overlay(response: &Response) -> &Screen { match &response.outcome { Outcome::Over(screen) => screen, other => panic!("expected an overlay, got {other:?}"), } } #[test] fn every_bulk_modal_is_drawn_over_what_is_showing() { // THE POINT OF THIS PORT. `Outcome::Over` is what a modal is, and nothing // had handed one to the egui renderer before this: a modal that answered // `Screen` would replace the list underneath instead of covering it. let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2); for address in ["/bulk/tag", "/bulk/move", "/bulk/rename"] { let response = bulking(&bulk, Request::get(address)).unwrap(); assert!( matches!(response.outcome, Outcome::Over(_)), "{address} did not answer an overlay" ); } } #[test] fn a_bulk_modal_refuses_to_open_over_nothing() { let empty = FakeBulk::of(&[], 0); for address in ["/bulk/tag", "/bulk/move", "/bulk/rename"] { assert!(bulking(&empty, Request::get(address)).is_err()); } // Folders can be moved and renamed but not tagged, which is the shipped // app's rule: `open_bulk_tag_modal` returns early on an empty hash list. let folders = FakeBulk::of(&["kits", "loops"], 0); assert!(bulking(&folders, Request::get("/bulk/tag")).is_err()); assert!(bulking(&folders, Request::get("/bulk/move")).is_ok()); assert!(bulking(&folders, Request::get("/bulk/rename")).is_ok()); } #[test] fn the_tag_modal_names_what_it_will_touch_and_what_the_vault_knows() { let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2); let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap(); let screen = overlay(&response); assert!(said(screen).contains("Tag 2 samples")); // The subjects are rows rather than prose, so a host can scroll them as a // list and the description can say how many were withheld. let named: Vec = list_of(screen).iter().filter_map(primary_of).collect(); assert_eq!(named, ["kick.wav", "snare.wav"]); // The known tags, as badges. THE FINDING is that a field cannot say what // completes it, so the set is named beside it -- the same workaround the // export port's naming tokens use, and its second consumer. let known: Vec = nodes(screen) .iter() .filter_map(|node| match node { Node::Token(tag) => Some(tag.label.clone()), _ => None, }) .collect(); assert_eq!(known, ["drums", "loop"]); } #[test] fn tagging_carries_the_typed_tag_and_which_way_it_goes() { let bulk = FakeBulk::of(&["kick.wav"], 1); bulking( &bulk, Request::post("/bulk/tag").sending( Params::new() .with("tag".to_owned(), "genre.house".to_owned()) .with("mode".to_owned(), "add".to_owned()), ), ) .unwrap(); bulking( &bulk, Request::post("/bulk/tag").sending( Params::new() .with("tag".to_owned(), "drums".to_owned()) .with("mode".to_owned(), "remove".to_owned()), ), ) .unwrap(); assert_eq!(bulk.asked(), ["add genre.house", "remove drums"]); } #[test] fn removing_a_tag_the_vault_does_not_know_is_refused_by_the_route() { // The shipped modal disables Apply on this condition; the route refuses it // too, because an address is reachable by typing. let bulk = FakeBulk::of(&["kick.wav"], 1); let refused = bulking( &bulk, Request::post("/bulk/tag").sending( Params::new() .with("tag".to_owned(), "nothing-has-this".to_owned()) .with("mode".to_owned(), "remove".to_owned()), ), ); assert!(refused.is_err()); assert!(bulk.asked().is_empty()); // Adding one the vault has never seen is fine: that is how a vault learns a // tag. bulking( &bulk, Request::post("/bulk/tag").sending( Params::new() .with("tag".to_owned(), "nothing-has-this".to_owned()) .with("mode".to_owned(), "add".to_owned()), ), ) .unwrap(); assert_eq!(bulk.asked(), ["add nothing-has-this"]); } #[test] fn an_empty_tag_keeps_the_modal_open_and_says_why() { let bulk = FakeBulk::of(&["kick.wav"], 1); let response = bulking( &bulk, Request::post("/bulk/tag").sending(Params::new().with("tag".to_owned(), " ".to_owned())), ) .unwrap(); // Still an overlay: a refusal that navigated away would take the modal down // and lose what was typed. assert!(matches!(response.outcome, Outcome::Over(_))); assert!(response.notice.is_some()); assert!(bulk.asked().is_empty()); } #[test] fn the_move_modal_offers_the_root_and_every_folder() { let bulk = FakeBulk::of(&["kick.wav"], 1); let response = bulking(&bulk, Request::get("/bulk/move")).unwrap(); let (_, rows) = table_of(overlay(&response)); let paths: Vec = rows.iter().map(|row| cell_text(row, 0)).collect(); assert_eq!(paths, ["/", "/kits", "/kits/808"]); // Every row submits its own destination, so picking one is the whole // interaction. The shipped modal has a selection index and a separate Move // button. for row in &rows { assert!(row.activate.is_some()); } } #[test] fn moving_names_the_folder_by_id_and_the_root_by_absence() { let bulk = FakeBulk::of(&["kick.wav"], 1); bulking( &bulk, Request::post("/bulk/move") .sending(Params::new().with("folder".to_owned(), "4".to_owned())), ) .unwrap(); bulking( &bulk, Request::post("/bulk/move").sending(Params::new().with("folder".to_owned(), String::new())), ) .unwrap(); assert_eq!(bulk.asked(), ["move 4", "move root"]); } #[test] fn moving_somewhere_that_is_not_a_folder_is_refused() { let bulk = FakeBulk::of(&["kick.wav"], 1); for value in ["99", "not-a-number"] { assert!( bulking( &bulk, Request::post("/bulk/move") .sending(Params::new().with("folder".to_owned(), value.to_owned())), ) .is_err() ); } // And a request that names no destination at all is not the root. assert!(bulking(&bulk, Request::post("/bulk/move")).is_err()); assert!(bulk.asked().is_empty()); } #[test] fn the_rename_modal_previews_the_starting_pattern() { let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2); let response = bulking(&bulk, Request::get("/bulk/rename")).unwrap(); let screen = overlay(&response); let (_, rows) = table_of(screen); let pairs: Vec<(String, String)> = rows .iter() .map(|row| (cell_text(row, 0), cell_text(row, 1))) .collect(); assert_eq!(pairs[0], ("kick.wav".to_owned(), "kick.wav".to_owned())); assert_eq!(pairs[1], ("snare.wav".to_owned(), "snare.wav".to_owned())); } #[test] fn a_typed_pattern_answers_a_fragment_so_the_overlay_survives() { // THE OTHER FINDING. An overlay cannot re-answer itself: `Outcome::Screen` // clears the layer stack, so a live preview that answered a screen would // take the modal down on every keystroke. A fragment replaces one region of // what is showing, which is what a preview is. let bulk = FakeBulk::of(&["kick.wav"], 1); let response = bulking( &bulk, Request::post("/bulk/rename/preview") .sending(Params::new().with("pattern".to_owned(), "{name}_808".to_owned())), ) .unwrap(); let Outcome::Fragment { region, node } = &response.outcome else { panic!("expected a fragment, got {:?}", response.outcome); }; assert_eq!(region, "bulk-rename-preview"); let Node::Table { rows, .. } = node else { panic!("expected the preview table"); }; assert_eq!(cell_text(&rows[0], 1), "kick_808.wav"); // Nothing was renamed: a preview is a question, not an instruction. assert!(bulk.asked().is_empty()); } #[test] fn a_half_typed_pattern_says_what_is_wrong_rather_than_previewing_nothing() { let bulk = FakeBulk::of(&["kick.wav"], 1); let response = bulking( &bulk, Request::post("/bulk/rename/preview") .sending(Params::new().with("pattern".to_owned(), "{na".to_owned())), ) .unwrap(); let Outcome::Fragment { node, .. } = &response.outcome else { panic!("expected a fragment"); }; assert!(matches!(node, Node::Notice { .. })); } #[test] fn a_colliding_output_name_is_marked_on_the_row_rather_than_hovered() { // A literal pattern renames every file to the same name. The shipped modal // colours the duplicates and explains itself in `on_hover_text`, which a // reader with no pointer never sees. let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2); let response = bulking( &bulk, Request::post("/bulk/rename/preview") .sending(Params::new().with("pattern".to_owned(), "same".to_owned())), ) .unwrap(); let Outcome::Fragment { node: Node::Table { rows, .. }, .. } = &response.outcome else { panic!("expected the preview table"); }; for row in rows { let marked = row.values[1].parts.iter().any(|part| { matches!(part, Node::Token(tag) if tag.tone == quasi_router::layout::Tone::Warning) }); assert!(marked, "a colliding name is not marked"); } } #[test] fn renaming_carries_the_pattern_and_refuses_one_that_does_not_parse() { let bulk = FakeBulk::of(&["kick.wav"], 1); bulking( &bulk, Request::post("/bulk/rename") .sending(Params::new().with("pattern".to_owned(), "{name}_808".to_owned())), ) .unwrap(); assert_eq!(bulk.asked(), ["rename {name}_808"]); assert!( bulking( &bulk, Request::post("/bulk/rename") .sending(Params::new().with("pattern".to_owned(), "{na".to_owned())), ) .is_err() ); assert_eq!(bulk.asked().len(), 1); } #[test] fn a_finished_modal_goes_somewhere_because_that_is_all_it_can_say() { // FINDING 1, asserted rather than only written down: there is no action // meaning "close what is on top", so every way out of a described modal is // a navigation. Since the flip (2026-08-22) it is a navigation with a stop // on the way -- `/bulk/done`, which tells the host to put the window away, // because the host's own `bulk_modal` is what keeps it up and leaving the // address is not leaving the screen. The finding is unchanged: the // vocabulary still cannot say "this overlay is finished". let bulk = FakeBulk::of(&["kick.wav"], 1); let done = bulking( &bulk, Request::post("/bulk/tag").sending(Params::new().with("tag".to_owned(), "new".to_owned())), ) .unwrap(); assert!(matches!(done.outcome, Outcome::Goto(_))); assert!(done.notice.is_some()); // And Cancel is the same navigation, drawn as a control. let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap(); let cancel = nodes(overlay(&response)) .into_iter() .find_map(|node| match node { Node::Act(act) if act.label == "Cancel" => Some(act.clone()), _ => None, }) .expect("the modal offers a way out"); assert_eq!(cancel.key.as_deref(), Some("esc")); assert_eq!(cancel.action.destination.route(), Some("/bulk/done")); // And that route says both halves: the host is told, and the answer leaves. let left = bulking(&bulk, Request::post("/bulk/done")).unwrap(); assert!(bulk.finished()); match left.outcome { Outcome::Goto(action) => assert_eq!(action.destination.route(), Some("/detail")), other => panic!("expected a navigation, got {other:?}"), } } #[test] fn a_long_selection_says_how_many_it_did_not_name() { let many: Vec = (0..60).map(|at| format!("sample{at}.wav")).collect(); let names: Vec<&str> = many.iter().map(String::as_str).collect(); let bulk = FakeBulk::of(&names, 60); let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap(); let screen = overlay(&response); assert!(said(screen).contains("60 chosen")); // The overflow is a described fact as of quasi 0.15, not a sentence at the // end of the list: fifty rows, and `Rest` saying there are sixty. let rows = list_of(screen); assert_eq!(rows.len(), 50); let more = more_of(screen).expect("the list says it is not all of them"); assert_eq!(more.paging.window.of, Some(60)); assert_eq!(more.paging.window.count, 50); // Nothing to ask for: the cap is a rendering budget and the operation acts // on all sixty either way. assert!(more.forward.is_none()); } // The help overlay. /// A router call against the help overlay. /// /// It borrows nothing: the shortcuts are read off `help::chrome`, which is a /// free function, and the features tab is a constant. That is the point of the /// screen rather than an accident of the fixture -- a help overlay that needed /// app state would be describing something other than the app's own keys. fn helping(request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The shortcuts tab, read back as the headings and rows it draws. /// /// One table per group since `cf7872dc`, so `table_of` -- which answers with /// the first it finds -- would assert about the Bulk group and call it the /// whole screen. fn shortcut_sections(screen: &Screen) -> Vec<(Option, Vec<(String, String)>)> { fn walk( body: &[quasi_router::Ranked], found: &mut Vec<(Option, Vec<(String, String)>)>, heading: &mut Option, ) { for placed in body { match &placed.node { Node::Heading { text, .. } => *heading = Some(text.clone()), Node::Table { rows, .. } => found.push(( heading.take(), rows.iter() .map(|row| (cell_text(row, 0), cell_text(row, 1))) .collect(), )), Node::Region(slot) => walk(&slot.body, found, heading), _ => {} } } } let mut found = Vec::new(); let mut heading = None; for slot in &screen.slots { walk(&slot.body, &mut found, &mut heading); } found } #[test] fn the_help_overlay_lists_exactly_the_keys_that_are_bound() { // THE POINT OF THIS PORT. `Binding`'s own header says a help overlay is // otherwise "a second, hand-written copy of them, free to drift from what // the keys actually do", and the shipped tab is that copy in seven arrays. // Here the two cannot disagree, and this is what says so. // // Read across the groups, because the grouping is a heading over the same // one table: every key is still listed exactly once and in the order it was // bound, which is what a listing withholding nothing means. let response = helping(Request::get("/help")).unwrap(); let listed: Vec<(String, String)> = shortcut_sections(overlay(&response)) .into_iter() .flat_map(|(_, rows)| rows) .collect(); let bound: Vec<(String, String)> = super::help::chrome() .bindings .iter() .map(|binding| (binding.key.clone(), binding.label.clone())) .collect(); assert_eq!(listed, bound); assert!(!bound.is_empty()); } #[test] fn the_shortcuts_tab_is_grouped_under_the_headings_the_shipped_tab_uses() { // `cf7872dc`. At four rows a flat table was fine; at thirteen it is the // same wall the shipped tab broke into seven hand-written arrays, and the // arrays are the evidence that somebody already thought so. let response = helping(Request::get("/help")).unwrap(); let sections = shortcut_sections(overlay(&response)); let headings: Vec> = sections .iter() .map(|(heading, _)| heading.clone()) .collect(); assert_eq!( headings, [ Some("Bulk".to_owned()), Some("Discovery".to_owned()), Some("Toggles".to_owned()), Some("System".to_owned()), ], "the reading order is the app's, not the alphabet's" ); // Every row sits under a heading: an ungrouped run would come back with // `None`, and this table has none today. assert!( sections .iter() .all(|(heading, rows)| heading.is_some() && !rows.is_empty()) ); // And the group is a listing fact and nothing more -- the keys still work // the way they did, which is what `bound` answers. let chrome = super::help::chrome(); assert_eq!( chrome.bound("f1").and_then(|binding| binding.group.clone()), Some("System".to_owned()) ); assert_eq!( chrome.bound("f1").map(|binding| binding.action.clone()), Some(quasi_router::Action::get("/help")) ); } #[test] fn every_bound_key_points_at_an_address_this_router_serves() { // The other half of "cannot disagree": a binding naming a route that does // not exist would be a NotFound the first time it was pressed, which is a // lie in a table that only shows up under a finger. // // Asked of the route table rather than by calling each address, and the // difference started mattering when the table grew past the four safe keys. // A live route refuses a state it cannot act in -- `/undo` with an empty // stack, `/detail/similar` with nothing analysed -- and both refuse with // `NotFound`, which is indistinguishable from an address nobody serves. // Calling would have this test asserting that thirteen preconditions are // satisfiable by one fixture, which is not what it is for. let router = router(); let served: Vec<(Method, &str)> = router.routes().collect(); for binding in super::help::chrome().bindings { let path = binding .action .destination .route() .expect("a binding goes somewhere in the app"); assert!( served .iter() .any(|(method, pattern)| *method == binding.action.method && covers(pattern, path)), "{} points at {:?} {path}, which no route serves", binding.key, binding.action.method, ); } } /// Whether a registered route pattern is the one this address lands on. /// /// The five panel keys address `/panels/sidebar` and the router holds /// `/panels/{panel}`, so a string comparison answers no to a binding that works. /// Segment counts and literals have to agree; a `{name}` segment takes whatever /// is in its place, which is the only thing the router's own matching does that /// matters here. fn covers(pattern: &str, path: &str) -> bool { let pattern = pattern.split('/'); let mut path = path.split('/'); for expected in pattern { let Some(actual) = path.next() else { return false; }; let placeholder = expected.starts_with('{') && expected.ends_with('}'); if !placeholder && expected != actual { return false; } } path.next().is_none() } #[test] fn the_shifted_bindings_do_not_shadow_their_bare_twins() { // `f`/`shift+f` and `d`/`shift+d`. The renderer matches exactly as of // quasi-immediate 0.52.0, so these are four entries rather than two; before // that the bare one answered both and this table is the app that found it. let bindings = super::help::chrome().bindings; let key = |wanted: &str| { bindings .iter() .find(|binding| binding.key == wanted) .unwrap_or_else(|| panic!("{wanted} is not bound")) .action .route() .expect("bound to this app's own router") .to_owned() }; assert_ne!(key("f"), key("shift+f")); assert_ne!(key("d"), key("shift+d")); } #[test] fn the_help_overlay_is_drawn_over_what_is_showing() { let response = helping(Request::get("/help")).unwrap(); assert!(matches!(response.outcome, Outcome::Over(_))); } #[test] fn switching_tabs_answers_a_fragment_so_the_overlay_survives() { // Second consumer of the overlay-refresh finding, and a sharper one than // the rename preview: a tabbed overlay is not buildable at all without // fragments, because both outcomes that carry a screen destroy the layer. let response = helping( Request::post("/help/tab") .sending(Params::new().with(Node::SELECTED.to_owned(), "features".to_owned())), ) .unwrap(); let Outcome::Fragment { region, node } = &response.outcome else { panic!("expected a fragment, got {:?}", response.outcome); }; assert_eq!(region, "help-tab"); // The features tab is a document, so it is markdown source rather than a // tree of headings the description would have to invent structure for. assert!(matches!(node, Node::Rich { .. })); } #[test] fn a_tab_that_is_not_one_of_the_two_is_refused() { assert!( helping( Request::post("/help/tab") .sending(Params::new().with(Node::SELECTED.to_owned(), "elsewhere".to_owned())), ) .is_err() ); } #[test] fn the_shortcuts_tab_is_what_a_bare_help_request_answers() { let response = helping(Request::get("/help")).unwrap(); let screen = overlay(&response); let chosen = nodes(screen).iter().find_map(|node| match node { Node::Select { chosen, .. } => chosen.clone(), _ => None, }); assert_eq!(chosen.as_deref(), Some("shortcuts")); } /// A row's primary part. fn primary_of(row: &quasi_router::Row) -> Option { row.parts .iter() .find(|part| part.role == quasi_router::layout::RowPart::Primary) .and_then(|part| match &part.node { Node::Text { text, .. } => Some(text.clone()), _ => None, }) } /// What the list on a screen says it is not showing. fn more_of(screen: &Screen) -> Option { nodes(screen).iter().find_map(|node| match node { Node::List { more, .. } => more.clone(), _ => None, }) } // The main window. /// A window with nothing playing and nothing to say. struct Quiet; impl Shell for Quiet { fn playing(&self) -> Option { None } fn chosen(&self) -> usize { 0 } fn analysed(&self) -> Analysed { Analysed::default() } fn status(&self) -> Option<(String, Saying)> { None } fn hinting(&self) -> bool { false } fn device(&self) -> Option { Some("Built-in Output".to_owned()) } fn tags(&self) -> Vec { Vec::new() } fn migrating(&self) -> Option { None } fn stop(&self) {} fn dismiss_hint(&self) {} fn pause_migration(&self) {} } /// A window in memory, recording what was asked of it. #[derive(Default)] struct FakeShell { playing: Option, chosen: usize, analysed: Analysed, status: Option<(String, Saying)>, hinting: bool, device: Option, tags: Vec, migrating: Option, asked: RefCell>, } impl Shell for FakeShell { fn playing(&self) -> Option { self.playing.clone() } fn chosen(&self) -> usize { self.chosen } fn analysed(&self) -> Analysed { self.analysed } fn status(&self) -> Option<(String, Saying)> { self.status.clone() } fn hinting(&self) -> bool { self.hinting } fn device(&self) -> Option { self.device.clone() } fn tags(&self) -> Vec { self.tags.clone() } fn migrating(&self) -> Option { self.migrating } fn stop(&self) { self.asked.borrow_mut().push("stop".to_owned()); } fn dismiss_hint(&self) { self.asked.borrow_mut().push("dismiss".to_owned()); } fn pause_migration(&self) { self.asked.borrow_mut().push("pause".to_owned()); } } /// A router call against this window. fn showing(shell: &FakeShell, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::with(vec![sample(1, "kick.wav"), sample(2, "snare.wav")]); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The main screen. fn shown(shell: &FakeShell) -> Screen { screen_of(&showing(shell, Request::get("/")).unwrap()).clone() } /// The regions of a screen, by kind. fn regions(screen: &Screen) -> Vec<(String, quasi_router::RegionKind)> { screen .slots .iter() .map(|slot| (slot.id.clone(), slot.kind.clone())) .collect() } /// Every meter on a screen. fn meters(screen: &Screen) -> Vec { nodes(screen) .iter() .filter_map(|node| match node { Node::Meter(meter) => Some(meter.clone()), _ => None, }) .collect() } /// Every figure on a screen, as value and caption. fn figures(screen: &Screen) -> Vec<(String, String)> { nodes(screen) .iter() .filter_map(|node| match node { Node::Figure(figure) => Some((figure.value.clone(), figure.caption.clone())), _ => None, }) .collect() } #[test] fn the_main_screen_is_a_list_and_a_band() { // THE POINT OF THIS PORT. Every described screen before it was one Pane, so // the arrangement had nothing to arrange and RegionKind::Band had never been // written by this app. let shell = FakeShell::default(); let screen = shown(&shell); assert_eq!( regions(&screen), [ ("toolbar-bar".to_owned(), quasi_router::RegionKind::Band), ("library-side".to_owned(), quasi_router::RegionKind::Sidebar), ("files-body".to_owned(), quasi_router::RegionKind::Pane), ("shell-foot".to_owned(), quasi_router::RegionKind::Band), ] ); } #[test] fn the_list_region_is_the_same_description_the_files_window_answers() { // `files::body` has two callers and one definition. If that ever stops // being true this is what says so: the table in the main window and the // table in the standalone window are the same rows and the same columns. let shell = FakeShell::default(); let embedded = table_of(&shown(&shell)); let files = FakeFiles::with(vec![sample(1, "kick.wav"), sample(2, "snare.wav")]); let alone = table_of(screen_of(&listing(&files, Request::get("/files")).unwrap())); assert_eq!(embedded.0, alone.0); assert_eq!(embedded.1, alone.1); } #[test] fn a_playing_sample_reports_its_position_as_a_proportion() { // THE FINDING, and the second consumer of quasi:docs:meter-refuses-progress. // `Meter`'s header says it is "a proportion of a set and not the progress of // an operation", and playback position is exactly the refused case -- it // moves at the sample clock with nobody touching anything. It is described // as a Meter regardless, because `Runtime::reload` moved the premise the // paragraph rests on. let shell = FakeShell { playing: Some(Playing { name: "kick.wav".to_owned(), position: 42, total: 130, }), ..FakeShell::default() }; let screen = shown(&shell); let transport = &meters(&screen)[0]; assert_eq!((transport.done, transport.total), (42, 130)); // The clock is said as well as the bar, because a proportion is not a // duration and a reader wants both. assert!(said(&screen).contains("0:42/2:10")); assert!(said(&screen).contains("Playing: kick.wav")); assert!(acts(&screen).iter().any(|label| label == "Stop")); } #[test] fn nothing_playing_means_no_transport_at_all() { let shell = FakeShell::default(); let screen = shown(&shell); assert!(meters(&screen).is_empty()); assert!(!acts(&screen).iter().any(|label| label == "Stop")); } #[test] fn analysis_coverage_is_the_proportion_meter_was_added_for() { let shell = FakeShell { analysed: Analysed { samples: 200, analysed: 142, untagged: 17, }, ..FakeShell::default() }; let screen = shown(&shell); let coverage = &meters(&screen)[0]; assert_eq!((coverage.done, coverage.total), (142, 200)); assert_eq!(coverage.tone, quasi_router::layout::Tone::Neutral); // The untagged count is a separate fact about the same set rather than a // second proportion of it. assert_eq!(figures(&screen), [("17".to_owned(), "untagged".to_owned())]); } #[test] fn a_fully_analysed_set_says_so_in_its_tone() { let shell = FakeShell { analysed: Analysed { samples: 200, analysed: 200, untagged: 0, }, ..FakeShell::default() }; assert_eq!( meters(&shown(&shell))[0].tone, quasi_router::layout::Tone::Success ); } #[test] fn the_untagged_count_waits_for_analysis_to_produce_something() { // The shipped footer's rule: before the first result every sample is // untagged and the count says nothing. let shell = FakeShell { analysed: Analysed { samples: 200, analysed: 0, untagged: 200, }, ..FakeShell::default() }; assert!(figures(&shown(&shell)).is_empty()); } #[test] fn a_status_carries_its_tone_and_not_a_timer() { // The shipped footer picks the colour by matching substrings against the // message and then decides how long to keep it up from two constants and an // elapsed Instant. The first is a described fact; the second is renderer // policy and is gone. let failed = FakeShell { status: Some(("Import error: bad header".to_owned(), Saying::Failed)), ..FakeShell::default() }; let notices: Vec = nodes(&shown(&failed)) .iter() .filter_map(|node| match node { Node::Notice { tone, .. } => Some(*tone), _ => None, }) .collect(); assert_eq!(notices, [quasi_router::layout::Tone::Danger]); let fine = FakeShell { status: Some(("Imported 42 samples".to_owned(), Saying::Ordinary)), ..FakeShell::default() }; assert!(said(&shown(&fine)).contains("Imported 42 samples")); } #[test] fn the_first_launch_hint_shows_only_while_there_is_nothing_to_say() { let hinting = FakeShell { hinting: true, ..FakeShell::default() }; assert!( acts(&shown(&hinting)) .iter() .any(|label| label == "Dismiss") ); // A status displaces it, which is the shipped footer's `else if`. let both = FakeShell { hinting: true, status: Some(("Imported 42 samples".to_owned(), Saying::Ordinary)), ..FakeShell::default() }; assert!(!acts(&shown(&both)).iter().any(|label| label == "Dismiss")); } #[test] fn a_missing_preview_device_is_said_rather_than_left_out() { // The line exists so a silent preview is diagnosable without opening // Settings, so the case it exists for is the one that must not vanish. let none = FakeShell::default(); let screen = shown(&none); assert!(said(&screen).contains("Preview: no device")); let toned = nodes(&screen).iter().any(|node| { matches!(node, Node::Text { text, tone } if text.contains("no device") && *tone == quasi_router::layout::Tone::Warning) }); assert!(toned, "a missing device is a warning, not an ordinary fact"); } #[test] fn the_bands_writes_are_asked_for_rather_than_performed() { let shell = FakeShell::default(); showing(&shell, Request::post("/playback/stop")).unwrap(); showing(&shell, Request::post("/hint/dismiss")).unwrap(); assert_eq!(shell.asked.borrow().as_slice(), ["stop", "dismiss"]); } #[test] fn a_lone_selection_is_not_counted_at_the_reader() { // One row selected is what the app looks like most of the time, so saying // "1 selected" is noise. The shipped footer's threshold, kept. let one = FakeShell { chosen: 1, ..FakeShell::default() }; assert!(figures(&shown(&one)).is_empty()); let several = FakeShell { chosen: 4, ..FakeShell::default() }; assert_eq!( figures(&shown(&several)), [("4".to_owned(), "selected".to_owned())] ); } // The sidebar. /// A library with nothing in it but the one vault it must have. struct Empty; impl Library for Empty { fn vaults(&self) -> Vec { vec![Vault { id: 1, name: "Library".to_owned(), current: true, }] } fn collections(&self) -> Vec { Vec::new() } fn tags(&self) -> Vec { Vec::new() } fn open_vault(&self, _id: i64) {} fn delete_vault(&self, _id: i64) {} fn toggle_tag(&self, _path: &str) {} fn remove_tag(&self, _path: &str) {} fn open_collection(&self, _id: i64) {} fn close_collection(&self) {} fn delete_collection(&self, _id: i64) {} } /// A library in memory, recording what was asked of it. #[derive(Default)] struct FakeLibrary { vaults: Vec, collections: Vec, tags: Vec, asked: RefCell>, } impl FakeLibrary { fn stocked() -> Self { Self { vaults: vec![ Vault { id: 1, name: "Drums".to_owned(), current: true, }, Vault { id: 2, name: "Synths".to_owned(), current: false, }, ], collections: vec![ Collection { id: 10, name: "Favourites".to_owned(), holding: Holding::Fixed(12), active: false, }, Collection { id: 11, name: "Fast".to_owned(), holding: Holding::Dynamic, active: true, }, ], tags: vec![ Filter { path: "drums".to_owned(), on: false, }, Filter { path: "drums.kick".to_owned(), on: true, }, ], asked: RefCell::new(Vec::new()), } } fn only_one_vault() -> Self { Self { vaults: vec![Vault { id: 1, name: "Library".to_owned(), current: true, }], ..Self::default() } } fn note(&self, what: impl Into) { self.asked.borrow_mut().push(what.into()); } fn asked(&self) -> Vec { self.asked.borrow().clone() } } impl Library for FakeLibrary { fn vaults(&self) -> Vec { self.vaults.clone() } fn collections(&self) -> Vec { self.collections.clone() } fn tags(&self) -> Vec { self.tags.clone() } fn open_vault(&self, id: i64) { self.note(format!("open vault {id}")); } fn delete_vault(&self, id: i64) { self.note(format!("delete vault {id}")); } fn toggle_tag(&self, path: &str) { self.note(format!("toggle {path}")); } fn remove_tag(&self, path: &str) { self.note(format!("remove {path}")); } fn open_collection(&self, id: i64) { self.note(format!("open collection {id}")); } fn close_collection(&self) { self.note("close collection"); } fn delete_collection(&self, id: i64) { self.note(format!("delete collection {id}")); } } /// A router call against this library. fn browsing(library: &FakeLibrary, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::with(vec![sample(1, "kick.wav")]); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The main screen, seen through this library. fn browsed(library: &FakeLibrary) -> Screen { screen_of(&browsing(library, Request::get("/")).unwrap()).clone() } /// Every row on a screen, across every list, with its acts. fn all_rows(screen: &Screen) -> Vec { nodes(screen) .iter() .filter_map(|node| match node { Node::List { rows, .. } => Some(rows.clone()), _ => None, }) .flatten() .collect() } /// Every latched chip on a screen, by label. fn latched(screen: &Screen) -> Vec { nodes(screen) .iter() .filter_map(|node| match node { Node::Token(tag) if tag.latched => Some(tag.label.clone()), _ => None, }) .collect() } #[test] fn the_main_screen_now_has_all_three_region_kinds() { // The sidebar completes the window. Pane and Band landed with `shell`; // this is the third and last kind this app has a use for. let library = FakeLibrary::stocked(); assert_eq!( regions(&browsed(&library)), [ ("toolbar-bar".to_owned(), quasi_router::RegionKind::Band), ("library-side".to_owned(), quasi_router::RegionKind::Sidebar), ("files-body".to_owned(), quasi_router::RegionKind::Pane), ("shell-foot".to_owned(), quasi_router::RegionKind::Band), ] ); } #[test] fn a_destructive_control_carries_its_own_prompt() { // `ConfirmAction::DeleteVfs`, `::DeleteCollection` and `::RemoveTagGlobally` // written the other way round: the prompt lives on the act rather than in a // 140-line match that turns an enum variant back into a sentence. let library = FakeLibrary::stocked(); let screen = browsed(&library); let prompts: Vec = all_rows(&screen) .iter() .flat_map(|row| row.menu.clone()) .filter_map(|act| act.confirm.clone()) .collect(); assert!( prompts .iter() .any(|ask| ask == "Delete vault \"Drums\" and all its contents?") ); assert!( prompts .iter() .any(|ask| ask == "Delete collection \"Favourites\"?") ); assert!( prompts .iter() .any(|ask| ask == "Remove tag \"drums\" from every sample that has it?") ); // And every one of them is toned, which is the second half of what the // dialog's `danger` flag was carrying. for act in all_rows(&screen).iter().flat_map(|row| row.menu.clone()) { if act.confirm.is_some() { assert_eq!(act.tone, quasi_router::layout::Tone::Danger); } } } #[test] fn the_last_vault_offers_delete_dead_and_says_why() { // Offered rather than hidden, which is the shipped menu's own choice: // "Always render Delete so the user can see the capability exists." // FOURTH consumer of quasi:vocabulary:act-precondition -- the sentence that // would revive it sits beside the control instead of on it. let alone = FakeLibrary::only_one_vault(); let screen = browsed(&alone); let delete = all_rows(&screen) .iter() .flat_map(|row| row.menu.clone()) .find(|act| act.label == "Delete") .expect("the capability is still shown"); assert_eq!(delete.state, Some(quasi_router::layout::State::Disabled)); assert!(said(&screen).contains("audiofiles needs at least one")); // And the route refuses it too, because an address is reachable by typing. assert!(browsing(&alone, Request::post("/vaults/1/delete")).is_err()); assert!(alone.asked().is_empty()); } #[test] fn deleting_a_vault_is_allowed_once_there_are_two() { let library = FakeLibrary::stocked(); browsing(&library, Request::post("/vaults/2/delete")).unwrap(); assert_eq!(library.asked(), ["delete vault 2"]); } #[test] fn a_tag_filter_is_a_chip_that_latches() { // A filter is on or off, which is exactly what Token::Chip's `latched` // says, and what a plain badge could not. let library = FakeLibrary::stocked(); assert_eq!(latched(&browsed(&library)), ["drums.kick"]); browsing(&library, Request::post("/tags/drums/filter")).unwrap(); assert_eq!(library.asked(), ["toggle drums"]); } #[test] fn a_tag_is_named_by_its_whole_path_because_the_tree_is_not_described() { // THE FINDING. `RowPart` has no depth and no member holds rows inside a // row, so the shipped sidebar's TagNode tree flattens to full dotted paths. // Honest about what the filter operates on -- `required_tags` holds exact // paths -- and it loses the grouping, the collapse, and the // parent-that-is-only-a-parent distinction. let library = FakeLibrary::stocked(); let screen = browsed(&library); // The sidebar's own region, since the toolbar's panel toggles are chips too. let chips: Vec = screen .slots .iter() .filter(|slot| slot.id == "library-side") .flat_map(|slot| &slot.body) .filter_map(|placed| match &placed.node { Node::Token(tag) => Some(tag.label.clone()), _ => None, }) .collect(); assert_eq!(chips, ["drums", "drums.kick"]); } #[test] fn an_active_collection_offers_to_close_rather_than_to_open() { let library = FakeLibrary::stocked(); browsing(&library, Request::post("/collections/10/open")).unwrap(); browsing(&library, Request::post("/collections/close")).unwrap(); assert_eq!(library.asked(), ["open collection 10", "close collection"]); // Which one a row calls is a fact about whether it is showing. // The collection rows, not the vault rows: `current` marks the vault being // browsed as well as the collection being shown. let rows = all_rows(&browsed(&library)); let active = rows .iter() .filter(|row| { row.activate .as_ref() .and_then(|action| action.destination.route()) .is_some_and(|path| path.contains("collection")) }) .find(|row| row.current) .expect("one collection is showing"); assert_eq!( active .activate .as_ref() .and_then(|action| action.destination.route()), Some("/collections/close") ); } #[test] fn a_collection_says_what_it_holds_beside_its_name_rather_than_inside_it() { // The shipped row appends " (auto)" or " (12)" to the label. A token is // where a second fact about a row goes. let library = FakeLibrary::stocked(); let rows = all_rows(&browsed(&library)); let marks: Vec = rows .iter() .flat_map(|row| { row.parts .iter() .filter(|part| part.role == quasi_router::layout::RowPart::Tokens) .filter_map(|part| match &part.node { Node::Token(tag) => Some(tag.label.clone()), _ => None, }) .collect::>() }) .collect(); assert_eq!(marks, ["12", "auto"]); // And the name is just the name. assert!( rows.iter() .any(|row| primary_of(row).as_deref() == Some("Favourites")) ); } #[test] fn an_empty_library_says_so_in_each_section() { let bare = FakeLibrary::only_one_vault(); let says = said(&browsed(&bare)); assert!(says.contains("No collections yet.")); assert!(says.contains("No tags yet.")); } #[test] fn opening_a_vault_that_is_not_there_is_refused() { let library = FakeLibrary::stocked(); assert!(browsing(&library, Request::post("/vaults/99/open")).is_err()); assert!(browsing(&library, Request::post("/vaults/nope/open")).is_err()); assert!(library.asked().is_empty()); browsing(&library, Request::post("/vaults/2/open")).unwrap(); assert_eq!(library.asked(), ["open vault 2"]); } #[test] fn deleting_a_collection_and_a_tag_are_asked_for() { let library = FakeLibrary::stocked(); browsing(&library, Request::post("/collections/11/delete")).unwrap(); browsing(&library, Request::post("/tags/drums.kick/remove")).unwrap(); assert_eq!( library.asked(), ["delete collection 11", "remove drums.kick"] ); } #[test] fn new_vault_opens_a_described_modal_rather_than_asking_the_app_for_one() { // It was `Intent::NewVault`, which opened the *shipped* name modal: the one // control on this screen whose answer was still drawn by hand. It is a // navigation to `naming`'s address now, and the sidebar asks the app for // nothing. let library = FakeLibrary::stocked(); let opened = acts(&browsed(&library)); assert!(opened.contains(&"New vault".to_owned()), "{opened:?}"); // The address is `naming`'s now, both verbs of it, and the sidebar's own // capability is never asked for a vault it cannot make. assert!(matches!( browsing(&library, Request::get("/vaults/new")) .unwrap() .outcome, Outcome::Over(_) )); assert!(library.asked().is_empty()); } // The toolbar. /// A toolbar at the root with nothing typed. struct Still; impl Bar for Still { fn place(&self) -> Where { Where::Folder { trail: Vec::new() } } fn searching(&self) -> Searching { Searching { query: String::new(), everywhere: false, filtered: false, results: 0, filters: 0, describes: String::new(), } } fn showing(&self) -> Vec { Vec::new() } fn undoable(&self) -> bool { false } fn search(&self, _query: &str) {} fn set_scope(&self, _everywhere: bool) {} fn save_collection(&self, _name: &str) {} fn undo(&self) {} fn toggle(&self, _panel: Panel) {} fn go_root(&self) {} fn go_to(&self, _id: i64, _depth: usize) {} fn leave(&self) {} } /// A toolbar in memory, recording what was asked of it. struct FakeBar { place: Where, searching: Searching, showing: Vec, undoable: bool, asked: RefCell>, } impl FakeBar { fn at(place: Where) -> Self { Self { place, searching: Searching { query: String::new(), everywhere: false, filtered: false, results: 0, filters: 0, describes: String::new(), }, showing: Vec::new(), undoable: false, asked: RefCell::new(Vec::new()), } } fn deep() -> Self { Self::at(Where::Folder { trail: vec![ Crumb { id: 7, name: "kits".to_owned(), }, Crumb { id: 8, name: "808".to_owned(), }, ], }) } fn filtering() -> Self { let mut bar = Self::at(Where::Folder { trail: Vec::new() }); bar.searching = Searching { query: "kick".to_owned(), everywhere: true, filtered: true, results: 42, filters: 3, describes: "Kicks under 120 BPM".to_owned(), }; bar } fn note(&self, what: impl Into) { self.asked.borrow_mut().push(what.into()); } fn asked(&self) -> Vec { self.asked.borrow().clone() } } impl Bar for FakeBar { fn place(&self) -> Where { self.place.clone() } fn searching(&self) -> Searching { self.searching.clone() } fn showing(&self) -> Vec { self.showing.clone() } fn undoable(&self) -> bool { self.undoable } fn search(&self, query: &str) { self.note(format!("search {query}")); } fn set_scope(&self, everywhere: bool) { self.note(if everywhere { "everywhere" } else { "here" }); } fn save_collection(&self, name: &str) { self.note(format!("save {name}")); } fn undo(&self) { self.note("undo"); } fn toggle(&self, panel: Panel) { self.note(format!("toggle {}", panel.as_str())); } fn go_root(&self) { self.note("root"); } fn go_to(&self, id: i64, depth: usize) { self.note(format!("go {id} at {depth}")); } fn leave(&self) { self.note("leave"); } } /// A router call against this toolbar. fn barred(bar: &FakeBar, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::with(vec![sample(1, "kick.wav")]); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The main screen, seen through this toolbar. fn topped(bar: &FakeBar) -> Screen { screen_of(&barred(bar, Request::get("/")).unwrap()).clone() } /// Every link on a screen, as text and destination. fn links(screen: &Screen) -> Vec<(String, String)> { nodes(screen) .iter() .filter_map(|node| match node { Node::Link { text, action } => Some(( text.clone(), action.destination.route().unwrap_or_default().to_owned(), )), _ => None, }) .collect() } #[test] fn the_toolbar_is_the_window_s_fourth_region_and_its_first() { let bar = FakeBar::deep(); assert_eq!( regions(&topped(&bar)) .into_iter() .map(|(id, _)| id) .collect::>(), ["toolbar-bar", "library-side", "files-body", "shell-foot"] ); } #[test] fn a_breadcrumb_is_links_and_the_place_you_are_is_not_one() { // Links rather than acts, which is `Node::Link`'s own argument: "making // every linked value a button would put a row of bevels down the first // column of half a dashboard". let bar = FakeBar::deep(); let screen = topped(&bar); assert_eq!( links(&screen), [ ("/".to_owned(), "/here/root".to_owned()), ("kits".to_owned(), "/here/7/1".to_owned()), ] ); // The last crumb is where you are, so it goes nowhere at all rather than // being a link that does nothing. assert!(said(&screen).contains("808")); } #[test] fn walking_back_up_the_trail_names_how_far_along_it_went() { // The depth rides with the id because navigating to a crumb truncates the // trail behind it, and how far along a folder sits is a fact about this // trail rather than about the folder. let bar = FakeBar::deep(); barred(&bar, Request::post("/here/7/1")).unwrap(); barred(&bar, Request::post("/here/root")).unwrap(); assert_eq!(bar.asked(), ["go 7 at 1", "root"]); assert!(barred(&bar, Request::post("/here/seven/1")).is_err()); assert!(barred(&bar, Request::post("/here/7/deep")).is_err()); } #[test] fn a_mode_offers_a_way_out_rather_than_a_shorter_path() { for place in [ Where::Collection { name: "Favourites".to_owned(), }, Where::Similar { name: "kick.wav".to_owned(), }, ] { let bar = FakeBar::at(place); let screen = topped(&bar); assert!(links(&screen).is_empty(), "a mode is not a trail"); assert!( acts(&screen) .iter() .any(|label| label == "Back to browsing") ); } // One control for both, because leaving either means the same thing to the // user; which mode is showing is what `Where` already says. let bar = FakeBar::at(Where::Similar { name: "kick.wav".to_owned(), }); barred(&bar, Request::post("/here/leave")).unwrap(); assert_eq!(bar.asked(), ["leave"]); } #[test] fn the_similarity_mode_says_why_the_columns_stopped_sorting() { // The shipped breadcrumb moved this off the column headings, "where the // explanation lived on a control the user had no reason to point at". Here // it is prose beside the mode, which is where the mode is. let bar = FakeBar::at(Where::Similar { name: "kick.wav".to_owned(), }); assert!(said(&topped(&bar)).contains("ranked by similarity")); } #[test] fn searching_carries_what_was_typed_and_which_scope() { let bar = FakeBar::filtering(); barred( &bar, Request::post("/search").sending(Params::new().with("query".to_owned(), "808".to_owned())), ) .unwrap(); barred( &bar, Request::post("/search/scope") .sending(Params::new().with(Node::SELECTED.to_owned(), "all".to_owned())), ) .unwrap(); assert_eq!(bar.asked(), ["search 808", "everywhere"]); assert!( barred( &bar, Request::post("/search/scope") .sending(Params::new().with(Node::SELECTED.to_owned(), "sideways".to_owned())), ) .is_err() ); } #[test] fn the_result_count_and_save_appear_only_once_something_narrows_the_list() { let quiet = FakeBar::at(Where::Folder { trail: Vec::new() }); assert!(figures(&topped(&quiet)).is_empty()); assert!( !acts(&topped(&quiet)) .iter() .any(|label| label == "Save as collection") ); let filtering = FakeBar::filtering(); assert_eq!( figures(&topped(&filtering)), [("42".to_owned(), "results".to_owned())] ); assert!( acts(&topped(&filtering)) .iter() .any(|label| label == "Save as collection") ); } #[test] fn saving_a_collection_offers_the_name_the_app_would_give_it() { // `SearchFilter::describe` is the app's, so the screen asks for it rather // than writing a second one -- `Sync::quote_cents`'s rule. let bar = FakeBar::filtering(); let response = barred(&bar, Request::get("/search/save")).unwrap(); let filled = fields(overlay(&response)); assert_eq!( filled.get("name").cloned().flatten().as_deref(), Some("Kicks under 120 BPM") ); barred( &bar, Request::post("/search/save") .sending(Params::new().with("name".to_owned(), "Kicks".to_owned())), ) .unwrap(); assert_eq!(bar.asked(), ["save Kicks"]); // An unnamed collection is refused, and so is saving nothing. assert!(barred(&bar, Request::post("/search/save")).is_err()); let quiet = FakeBar::at(Where::Folder { trail: Vec::new() }); assert!(barred(&quiet, Request::get("/search/save")).is_err()); } #[test] fn undo_is_offered_dead_when_there_is_nothing_to_undo() { let nothing = FakeBar::at(Where::Folder { trail: Vec::new() }); assert!(dead(&topped(¬hing)).iter().any(|label| label == "Undo")); assert!(barred(¬hing, Request::post("/undo")).is_err()); let mut something = FakeBar::at(Where::Folder { trail: Vec::new() }); something.undoable = true; assert!( !dead(&topped(&something)) .iter() .any(|label| label == "Undo") ); barred(&something, Request::post("/undo")).unwrap(); assert_eq!(something.asked(), ["undo"]); } #[test] fn every_panel_toggle_latches_and_is_addressable() { let mut bar = FakeBar::at(Where::Folder { trail: Vec::new() }); bar.showing = vec![Panel::Sidebar, Panel::Loop]; let on = latched(&topped(&bar)); assert_eq!(on, ["Sidebar", "Loop"]); for panel in Panel::ALL { barred(&bar, Request::post(format!("/panels/{}", panel.as_str()))).unwrap(); } assert_eq!( bar.asked(), [ "toggle sidebar", "toggle detail", "toggle edit", "toggle instrument", "toggle loop", "toggle filters", ] ); assert!(barred(&bar, Request::post("/panels/nonsense")).is_err()); } #[test] fn the_filters_toggle_is_the_one_that_carries_a_count() { let bar = FakeBar::filtering(); let labelled: Vec = nodes(&topped(&bar)) .iter() .filter_map(|node| match node { Node::Token(tag) => Some(tag.label.clone()), _ => None, }) .collect(); assert!(labelled.iter().any(|label| label == "Filters (3)")); assert!(labelled.iter().any(|label| label == "Sidebar")); } #[test] fn the_toolbar_reaches_the_other_described_screens_by_address() { // The port stops being a set of windows here: Settings, Sync and Help are // screens this router serves, so getting to them is navigation. let bar = FakeBar::at(Where::Folder { trail: Vec::new() }); let screen = topped(&bar); let destinations: Vec = nodes(&screen) .iter() .filter_map(|node| match node { Node::Act(act) => act.action.destination.route().map(ToOwned::to_owned), _ => None, }) .collect(); for address in ["/settings", "/sync", "/help"] { assert!( destinations.iter().any(|to| to == address), "the toolbar does not reach {address}" ); assert!(barred(&bar, Request::get(address)).is_ok()); } } #[test] fn the_search_field_says_it_takes_the_room_the_buttons_do_not() { // What `trailing_width` was measuring for, said instead of measured. Filed // as quasicoherent `6d6a9160`, settled by Max the same day -- fill is // determined at the description stage -- and landed as `Field::width` in // quasi 0.17.0. let bar = FakeBar::at(Where::Folder { trail: Vec::new() }); let asked = nodes(&topped(&bar)) .iter() .find_map(|node| match node { Node::Field(field) if field.name == "query" => Some(field.width), _ => None, }) .expect("the toolbar has a search field"); assert_eq!(asked, quasi_router::layout::Width::Fill); } /// What every member of a region is worth, by the text it carries. fn worths(screen: &Screen, region: &str) -> Vec<(String, quasi_router::layout::Priority)> { screen .slots .iter() .find(|slot| slot.id == region) .expect("the region is on the screen") .body .iter() .map(|placed| { let name = match &placed.node { Node::Token(tag) => tag.label.clone(), Node::Act(act) => act.label.clone(), Node::Text { text, .. } => text.clone(), other => format!("{other:?}"), }; (name, placed.priority) }) .collect() } #[test] fn the_panel_toggles_say_what_they_are_worth_instead_of_collapsing_at_900px() { // The described replacement for `screen_w < 900.0`, which put all six // toggles into a View menu at a width this app chose. Ranked now, so a // narrow window keeps the two toggles that decide the shape of the window // and loses the three that open an inspector. use quasi_router::layout::Priority; let bar = FakeBar::deep(); let worth = worths(&topped(&bar), "toolbar-bar"); let of = |label: &str| { worth .iter() .find(|(name, _)| name.starts_with(label)) .unwrap_or_else(|| panic!("{label} is on the toolbar: {worth:?}")) .1 }; assert_eq!(of("Sidebar"), Priority::Essential); assert_eq!(of("Detail"), Priority::Essential); assert_eq!(of("Filters"), Priority::Secondary); for inspector in ["Edit", "Instrument", "Loop"] { assert_eq!(of(inspector), Priority::Optional, "{inspector}"); } // Help drops first of the three addresses because `f1` still reaches it. assert_eq!(of("Settings"), Priority::Secondary); assert_eq!(of("Cloud Sync"), Priority::Secondary); assert_eq!(of("Help"), Priority::Optional); // And nothing was hidden behind a width. The description names no pixels. assert!(!format!("{worth:?}").contains("900")); } #[test] fn the_footer_drops_only_what_the_detail_panel_says_twice() { // The footer's own `< 1000` reflows rather than drops, so ranking its // items would delete facts the shipped app keeps. The tag badges are the // exception: they repeat what the detail panel states in full. use quasi_router::layout::Priority; let shell = FakeShell { tags: vec!["drums".to_owned(), "loop".to_owned()], ..Default::default() }; let worth = worths(&shown(&shell), "shell-foot"); let optional: Vec<&String> = worth .iter() .filter(|(_, priority)| *priority != Priority::Essential) .map(|(name, _)| name) .collect(); assert_eq!(optional, ["drums", "loop"], "{worth:?}"); } // --- The name modals, the preflight and the loose-files warning -------------- /// A tree with nothing to name, for every test that is not about naming. struct Unnamed; impl Naming for Unnamed { fn done(&self) {} fn vault(&self, _id: i64) -> Option { None } fn folder(&self, _id: i64) -> Option { None } fn create_vault(&self, _name: &str) -> Result { Ok(String::new()) } fn rename_vault(&self, _id: i64, _name: &str) -> Result { Ok(String::new()) } fn create_folder(&self, _name: &str) -> Result { Ok(String::new()) } fn rename_folder(&self, _id: i64, _name: &str) -> Result { Ok(String::new()) } } /// Nothing is being imported, and nothing can be. /// /// [`Idle`]'s peer for the other flow, and the same argument: every method is a /// refusal, so a test of some other screen cannot start an import by accident. /// A fake that recorded the call would let one. struct NoImport; impl Importing for NoImport { fn waiting(&self) -> Option { None } fn stage(&self) -> Stage { Stage::Idle } fn sweeping(&self) -> Option { None } fn accept(&self, _again: bool) {} fn cancel(&self) {} fn open_folder(&self) {} fn open_quickly(&self) {} fn open_files(&self) {} fn change_source(&self) {} fn decide(&self, _decision: Decision, _value: &str) {} fn begin(&self) {} fn stop(&self) {} fn retry(&self) {} fn dismiss(&self) {} fn tag_folder(&self, _at: usize, _typed: &str) {} fn tag_every_folder(&self, _typed: &str) {} fn apply_folder_tags(&self) {} fn skip_folder_tags(&self) {} fn measure(&self, _measure: Measure, _wanted: bool) {} fn analyse(&self) {} fn back_to_tagging(&self) {} fn skip_analysis(&self) {} fn stop_analysis(&self) {} fn retry_analysis(&self) {} fn order(&self, _order: Order) {} fn read(&self, _at: usize) {} fn judge(&self, _at: usize, _tag: &str, _accepted: bool) {} fn judge_all(&self, _accepted: bool) {} fn apply_suggestions(&self) {} fn discard_suggestions(&self) {} fn keep_failed(&self) {} fn purge_failed(&self, _at: Option) {} fn stop_sweep(&self) {} } /// Every file is where it should be. struct Sound; impl Integrity for Sound { fn missing(&self) -> usize { 0 } fn dismiss(&self) {} fn locate(&self) {} fn purge(&self) {} } /// A namer in memory, recording what was asked of it and refusing on demand. #[derive(Default)] struct FakeNaming { vaults: Vec<(i64, String)>, folders: Vec<(i64, String)>, refusing: Option, asked: RefCell>, /// Whether the modal was told it is finished with. /// /// Its own field rather than a row in `asked`, because it is not a naming /// operation: `asked` answers "what did this screen do to the tree", and /// telling the host to put a window away does nothing to the tree. finished: std::cell::Cell, } impl FakeNaming { fn stocked() -> Self { Self { vaults: vec![(1, "Drums".to_owned())], folders: vec![(7, "kicks".to_owned())], ..Self::default() } } fn refusing(why: &str) -> Self { Self { refusing: Some(why.to_owned()), ..Self::stocked() } } fn asked(&self) -> Vec { self.asked.borrow().clone() } fn finished(&self) -> bool { self.finished.get() } fn did(&self, what: String) -> Result { self.asked.borrow_mut().push(what.clone()); match &self.refusing { Some(why) => Err(why.clone()), None => Ok(what), } } } impl Naming for FakeNaming { fn done(&self) { self.finished.set(true); } fn vault(&self, id: i64) -> Option { self.vaults .iter() .find(|(at, _)| *at == id) .map(|(_, name)| name.clone()) } fn folder(&self, id: i64) -> Option { self.folders .iter() .find(|(at, _)| *at == id) .map(|(_, name)| name.clone()) } fn create_vault(&self, name: &str) -> Result { self.did(format!("create vault {name}")) } fn rename_vault(&self, id: i64, name: &str) -> Result { self.did(format!("rename vault {id} to {name}")) } fn create_folder(&self, name: &str) -> Result { self.did(format!("create folder {name}")) } fn rename_folder(&self, id: i64, name: &str) -> Result { self.did(format!("rename folder {id} to {name}")) } } /// A post carrying what a form submitted. /// /// The captures are the router's to fill from the path; what a test supplies is /// the payload, which is the half a form sends. fn posting(path: &str, payload: Params) -> Request { Request { method: Method::Post, path: path.to_owned(), captures: Params::new(), payload, carried: Params::new(), } } /// A router call against this namer. fn naming(naming: &FakeNaming, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The one field on whatever modal answered. fn only_field(response: &Response) -> quasi_router::Field { let screen = screen_of(response); let mut found = Vec::new(); fn walk(body: &[quasi_router::Ranked], found: &mut Vec) { for placed in body { match &placed.node { Node::Field(field) => found.push((**field).clone()), Node::Form { fields, .. } => found.extend(fields.iter().cloned()), Node::Region(slot) => walk(&slot.body, found), _ => {} } } } for slot in &screen.slots { walk(&slot.body, &mut found); } assert_eq!(found.len(), 1, "{found:?}"); found.remove(0) } #[test] fn a_rename_modal_opens_holding_the_name_it_is_about_to_change() { let namer = FakeNaming::stocked(); let vault = naming(&namer, Request::get("/vaults/1/rename")).unwrap(); assert!(matches!(vault.outcome, Outcome::Over(_)), "{vault:?}"); assert_eq!(only_field(&vault).value.as_deref(), Some("Drums")); let folder = naming(&namer, Request::get("/folders/7/rename")).unwrap(); assert_eq!(only_field(&folder).value.as_deref(), Some("kicks")); // Nothing was renamed by looking at it. assert!(namer.asked().is_empty()); } #[test] fn a_modal_is_drawn_over_what_it_was_opened_from() { // All four, because `Outcome::Over` is what makes them modals rather than // places, and a screen answered here would clear the layer underneath. let namer = FakeNaming::stocked(); for address in [ "/vaults/new", "/vaults/1/rename", "/folders/new", "/folders/7/rename", ] { let response = naming(&namer, Request::get(address)).unwrap(); assert!( matches!(response.outcome, Outcome::Over(_)), "{address}: {response:?}" ); } } #[test] fn naming_something_that_is_not_there_is_a_refusal_rather_than_an_empty_modal() { let namer = FakeNaming::stocked(); assert!(naming(&namer, Request::get("/vaults/99/rename")).is_err()); assert!(naming(&namer, Request::get("/folders/99/rename")).is_err()); // An address is reachable by typing, so the id is checked rather than // trusted. assert!(naming(&namer, Request::get("/vaults/not-a-number/rename")).is_err()); } #[test] fn a_name_the_store_refuses_comes_back_on_the_field_it_was_typed_into() { // The whole reason this port's writes happen in the route: an intent // applied after the answer was built could not carry the refusal, so the // modal would close and the typed name would be gone. C-3, kept. let namer = FakeNaming::refusing("A vault called that already exists"); let response = naming( &namer, posting("/vaults/new", Params::new().with("name", "Drums")), ) .unwrap(); let Outcome::Fragment { region, node } = &response.outcome else { panic!("{response:?}"); }; // A fragment rather than a second `Over`, which would be two modals. See // `bulk`'s finding 2. assert_eq!(region, "naming-form"); let Node::Region(slot) = node else { panic!("{node:?}"); }; let Some(Node::Form { fields, .. }) = slot.body.first().map(|placed| &placed.node) else { panic!("{slot:?}"); }; assert_eq!( fields[0].error.as_deref(), Some("A vault called that already exists") ); // And it still holds what was typed. assert_eq!(fields[0].value.as_deref(), Some("Drums")); } #[test] fn an_empty_submit_closes_the_modal_and_names_nothing() { // The shipped rule, and the reason none of these fields is `required`: the // marker would claim a refusal that never happens. let namer = FakeNaming::stocked(); let response = naming( &namer, posting("/folders/new", Params::new().with("name", " ")), ) .unwrap(); assert!(matches!(response.outcome, Outcome::Goto(_)), "{response:?}"); assert!(namer.asked().is_empty()); // Leaving the address is not leaving the screen: the host's own flag is // what keeps a name modal up, so every exit says so. See `naming`'s `DONE`. assert!(namer.finished()); } #[test] fn a_named_thing_is_created_once_and_the_modal_leaves() { let namer = FakeNaming::stocked(); let response = naming( &namer, posting("/vaults/new", Params::new().with("name", " Synths ")), ) .unwrap(); // Trimmed, which is what the shipped modal submits. assert_eq!(namer.asked(), ["create vault Synths"]); assert!(matches!(response.outcome, Outcome::Goto(_)), "{response:?}"); assert!(response.notice.is_some()); } /// A router call against this waiting import. fn importing( importing: &dyn Importing, request: Request, ) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// Importing in memory, recording what was asked of it. /// /// One fixture for the preflight and the flow, because they are one capability: /// a test names the stage it is about and leaves the other half at rest. The /// stage is fixed per test rather than advancing, which is [`FakeExport`]'s /// honest shape for the same reason — what moves a stage is the app applying an /// intent, and these are tests of the description. struct FakeImport { waiting: Option, stage: Stage, sweep: Option, answered: RefCell>, } impl Default for FakeImport { fn default() -> Self { Self { waiting: None, stage: Stage::Idle, sweep: None, answered: RefCell::new(Vec::new()), } } } impl FakeImport { fn waiting() -> Self { Self { waiting: Some(Preflight { source: "/home/max/Downloads/packs".to_owned(), files: 412, size: "3.1 GB".to_owned(), }), ..Self::default() } } fn at(stage: Stage) -> Self { Self { stage, ..Self::default() } } fn sweeping(sweep: Sweep) -> Self { Self { sweep: Some(sweep), ..Self::default() } } fn answered(&self) -> Vec { self.answered.borrow().clone() } fn say(&self, said: impl Into) { self.answered.borrow_mut().push(said.into()); } } impl Importing for FakeImport { fn waiting(&self) -> Option { self.waiting.clone() } fn stage(&self) -> Stage { self.stage.clone() } fn sweeping(&self) -> Option { self.sweep.clone() } fn accept(&self, again: bool) { self.say(format!("accept, ask again: {again}")); } fn cancel(&self) { self.say("cancel"); } fn open_folder(&self) { self.say("open:folder"); } fn open_quickly(&self) { self.say("open:quick"); } fn open_files(&self) { self.say("open:files"); } fn change_source(&self) { self.say("open:source"); } fn decide(&self, decision: Decision, value: &str) { self.say(format!("set:{}={value}", decision.as_str())); } fn begin(&self) { self.say("begin"); } fn stop(&self) { self.say("stop"); } fn retry(&self) { self.say("retry"); } fn dismiss(&self) { self.say("dismiss"); } fn tag_folder(&self, at: usize, typed: &str) { self.say(format!("tag:{at}={typed}")); } fn tag_every_folder(&self, typed: &str) { self.say(format!("tag:all={typed}")); } fn apply_folder_tags(&self) { self.say("tags:apply"); } fn skip_folder_tags(&self) { self.say("tags:skip"); } fn measure(&self, measure: Measure, wanted: bool) { self.say(format!("measure:{}={wanted}", measure.as_str())); } fn analyse(&self) { self.say("analyse"); } fn back_to_tagging(&self) { self.say("analyse:back"); } fn skip_analysis(&self) { self.say("analyse:skip"); } fn stop_analysis(&self) { self.say("analyse:stop"); } fn retry_analysis(&self) { self.say("analyse:retry"); } fn order(&self, order: Order) { self.say(format!("order:{}", order.as_str())); } fn read(&self, at: usize) { self.say(format!("read:{at}")); } fn judge(&self, at: usize, tag: &str, accepted: bool) { self.say(format!("judge:{at}:{tag}={accepted}")); } fn judge_all(&self, accepted: bool) { self.say(format!("judge:all={accepted}")); } fn apply_suggestions(&self) { self.say("review:apply"); } fn discard_suggestions(&self) { self.say("review:discard"); } fn keep_failed(&self) { self.say("failed:keep"); } fn purge_failed(&self, at: Option) { self.say(match at { Some(at) => format!("failed:purge:{at}"), None => "failed:purge:all".to_owned(), }); } fn stop_sweep(&self) { self.say("sweep:stop"); } } #[test] fn the_preflight_says_what_is_about_to_happen_and_where_it_is_coming_from() { let import = FakeImport::waiting(); let response = importing(&import, Request::get("/import/preflight")).unwrap(); assert!(matches!(response.outcome, Outcome::Over(_)), "{response:?}"); let said = said(screen_of(&response)); assert!(said.contains("412 audio files"), "{said}"); assert!(said.contains("3.1 GB"), "{said}"); assert!(said.contains("/home/max/Downloads/packs"), "{said}"); // The reassurance is a fact about the operation, so it is on the screen // rather than on either answer. assert!(said.contains("Files stay where they are"), "{said}"); } #[test] fn dont_ask_again_travels_with_the_answer_it_qualifies() { // The shipped modal keeps this on `BrowserState` and resets it on both // exits. Here it is submitted with the answer and there is nothing to // reset. let import = FakeImport::waiting(); importing( &import, posting("/import/preflight", Params::new().with("again", "on")), ) .unwrap(); assert_eq!(import.answered(), ["accept, ask again: false"]); let plain = FakeImport::waiting(); importing(&plain, Request::post("/import/preflight")).unwrap(); assert_eq!(plain.answered(), ["accept, ask again: true"]); } #[test] fn there_is_no_preflight_screen_when_no_import_is_waiting() { // Rather than an empty modal. The address is reachable by typing and there // is no honest screen for it. assert!(importing(&NoImport, Request::get("/import/preflight")).is_err()); assert!(importing(&NoImport, Request::post("/import/preflight")).is_err()); } /// A vault with missing files, recording what was asked of it. struct FakeIntegrity { missing: usize, asked: RefCell>, } impl FakeIntegrity { fn missing(count: usize) -> Self { Self { missing: count, asked: RefCell::new(Vec::new()), } } fn asked(&self) -> Vec { self.asked.borrow().clone() } } impl Integrity for FakeIntegrity { fn missing(&self) -> usize { self.missing } fn dismiss(&self) { self.asked.borrow_mut().push("dismiss".to_owned()); } fn locate(&self) { self.asked.borrow_mut().push("locate".to_owned()); } fn purge(&self) { self.asked.borrow_mut().push("purge".to_owned()); } } /// A router call against this vault's health. fn checking( integrity: &dyn Integrity, request: Request, ) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } #[test] fn purge_carries_what_it_takes_on_the_control_that_does_it() { // The shipped modal draws the blast radius as a warning line near the // button. `Act::confirm` puts it on the button, which is the third // `ConfirmAction`-shaped thing this port has replaced with a method. let vault = FakeIntegrity::missing(3); let response = checking(&vault, Request::get("/library/loose-files")).unwrap(); let screen = screen_of(&response); let purge = nodes(screen) .iter() .find_map(|node| match node { Node::Act(act) if act.label == "Purge" => Some((*act).clone()), _ => None, }) .expect("a Purge act"); let question = purge.confirm.expect("Purge asks first"); assert!( question.contains("Tags, analysis results, and history"), "{question}" ); assert!(question.contains("permanently deleted"), "{question}"); } #[test] fn each_of_the_three_answers_does_one_thing_and_leaves() { for (address, expected) in [ ("/library/loose-files/dismiss", "dismiss"), ("/library/loose-files/locate", "locate"), ("/library/loose-files/purge", "purge"), ] { let vault = FakeIntegrity::missing(3); let response = checking(&vault, Request::post(address)).unwrap(); assert_eq!(vault.asked(), [expected], "{address}"); assert!( matches!(response.outcome, Outcome::Goto(_)), "{address}: {response:?}" ); } } #[test] fn a_healthy_vault_has_no_warning_to_open() { assert!(checking(&Sound, Request::get("/library/loose-files")).is_err()); // And purging nothing is refused rather than performed on an empty set. assert!(checking(&Sound, Request::post("/library/loose-files/purge")).is_err()); } #[test] fn the_band_says_what_is_missing_because_no_description_can_raise_the_overlay() { // The finding this pass filed, seen from the consumer's side: the shipped // app puts the warning up by itself after a vault load, and nothing a route // answers can do that. So the fact is in the band and the modal is one act // away. `quasi:vocabulary:unprompted-overlay`. let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let vault = FakeIntegrity::missing(4); let shell = FakeShell::default(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &shell, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &vault, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; let response = router().handle(&state, Request::get("/")).unwrap(); let screen = screen_of(&response); let said = said(screen); assert!(said.contains("4 samples cannot find their file"), "{said}"); assert!( acts(screen).contains(&"What is missing".to_owned()), "{:?}", acts(screen) ); } // --- The sample editor ------------------------------------------------------- /// Nothing is being edited, for every test that is not about editing. struct Unedited; impl super::Edit for Unedited { fn subject(&self) -> Option { None } fn trim(&self, _start: f32, _end: f32) {} fn gain(&self, _db: f64) {} fn normalize(&self, _peak: bool, _target: f64) {} fn reverse(&self) {} fn fade(&self, _fading_in: bool, _ms: f64, _curve: &str) {} fn insert_silence(&self, _at: f64, _ms: f64) {} fn remove_range(&self, _from: f64, _to: f64) {} fn cancel(&self) {} fn play(&self) {} fn stop(&self) {} fn remember(&self, _mode: &str) {} fn choose(&self, _mode: &str, _remember: bool) {} fn discard(&self) {} fn undo(&self) {} fn batch_normalize(&self, _peak: bool, _target: f64) {} fn batch_gain(&self, _db: f64) {} fn batch_reverse(&self) {} } /// An editor in memory, recording what was asked of it. struct FakeEditor { subject: Option, asked: RefCell>, } impl FakeEditor { fn editing() -> Self { Self { subject: Some(Editing { name: "kick.wav".to_owned(), sample_rate: 44_100, duration: Some(1.5), peak_db: Some(-2.0), playing: false, working: false, asking: false, result: None, chosen: 1, undoing: None, }), asked: RefCell::new(Vec::new()), } } fn with(mut self, change: impl FnOnce(&mut Editing)) -> Self { if let Some(subject) = self.subject.as_mut() { change(subject); } self } fn asked(&self) -> Vec { self.asked.borrow().clone() } fn note(&self, what: String) { self.asked.borrow_mut().push(what); } } impl super::Edit for FakeEditor { fn subject(&self) -> Option { self.subject.clone() } fn trim(&self, start: f32, end: f32) { self.note(format!("trim {start} {end}")); } fn gain(&self, db: f64) { self.note(format!("gain {db}")); } fn normalize(&self, peak: bool, target: f64) { self.note(format!("normalize peak={peak} {target}")); } fn reverse(&self) { self.note("reverse".to_owned()); } fn fade(&self, fading_in: bool, ms: f64, curve: &str) { self.note(format!("fade in={fading_in} {ms} {curve}")); } fn insert_silence(&self, at: f64, ms: f64) { self.note(format!("insert {at} {ms}")); } fn remove_range(&self, from: f64, to: f64) { self.note(format!("remove {from} {to}")); } fn cancel(&self) { self.note("cancel".to_owned()); } fn play(&self) { self.note("play".to_owned()); } fn stop(&self) { self.note("stop".to_owned()); } fn remember(&self, mode: &str) { self.note(format!("remember {mode}")); } fn choose(&self, mode: &str, remember: bool) { self.note(format!("choose {mode} remember={remember}")); } fn discard(&self) { self.note("discard".to_owned()); } fn undo(&self) { self.note("undo".to_owned()); } fn batch_normalize(&self, peak: bool, target: f64) { self.note(format!("batch normalize peak={peak} {target}")); } fn batch_gain(&self, db: f64) { self.note(format!("batch gain {db}")); } fn batch_reverse(&self) { self.note("batch reverse".to_owned()); } } /// A router call against this editor. fn editing(editor: &FakeEditor, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor, forge: &Unforged, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The editor screen. fn edited(editor: &FakeEditor) -> Screen { screen_of(&editing(editor, Request::get("/edit")).unwrap()).clone() } #[test] fn the_editor_refuses_to_exist_with_nothing_to_edit() { // The shipped window is only open because something is being edited, so a // screen for "no sample" would be a screen the app does not have. assert!( editing( &FakeEditor { subject: None, asked: RefCell::new(Vec::new()) }, Request::get("/edit") ) .is_err() ); } #[test] fn a_finished_edit_asks_at_the_same_address_it_was_started_from() { // One route, two shapes: the prompt is a state the user arrived at, not a // place they went. `sync`, `export` and `detail` settled this. let quiet = FakeEditor::editing(); assert!(said(&edited(&quiet)).contains("kick.wav")); let asking = FakeEditor::editing().with(|subject| subject.asking = true); let screen = edited(&asking); let said = said(&screen); assert!( said.contains("How should the edited sample be handled?"), "{said}" ); // And the editor's own controls are gone, which is what the shipped panel's // early return does. assert!( !acts(&screen).iter().any(|act| act == "Reverse"), "{:?}", acts(&screen) ); } #[test] fn trim_refuses_a_span_that_ends_before_it_starts() { // The pair the description cannot state. The shipped panel keeps it true by // writing one of the two every frame; an address reachable by typing needs // the refusal as well. `91114ff1`, second consumer. let editor = FakeEditor::editing(); assert!( editing( &editor, posting( "/edit/trim", Params::new().with("start", "0.8").with("end", "0.2") ), ) .is_err() ); assert!(editor.asked().is_empty()); editing( &editor, posting( "/edit/trim", Params::new().with("start", "0.1").with("end", "0.9"), ), ) .unwrap(); assert_eq!(editor.asked(), ["trim 0.1 0.9"]); } #[test] fn a_position_outside_the_sample_is_refused() { let editor = FakeEditor::editing(); for (start, end) in [("-0.5", "0.9"), ("0.1", "1.5"), ("nope", "0.9")] { assert!( editing( &editor, posting( "/edit/trim", Params::new().with("start", start).with("end", end) ), ) .is_err(), "{start} {end}" ); } assert!(editor.asked().is_empty()); } #[test] fn the_clipping_warning_moves_with_the_gain_and_leaves_the_screen_standing() { // `5672cad4`, third consumer: a valid answer that costs something has no // slot on the field, so it is a fragment beside it. let editor = FakeEditor::editing(); let quiet = editing( &editor, posting("/edit/gain/preview", Params::new().with("gain", "1.0")), ) .unwrap(); let Outcome::Fragment { region, node } = &quiet.outcome else { panic!("{quiet:?}"); }; assert_eq!(region, "edit-clipping"); // -2.0 + 1.0 is still under the ceiling, so it is a fact rather than a // warning. assert!(matches!(node, Node::Text { .. }), "{node:?}"); let loud = editing( &editor, posting("/edit/gain/preview", Params::new().with("gain", "6.0")), ) .unwrap(); let Outcome::Fragment { node, .. } = &loud.outcome else { panic!("{loud:?}"); }; let Node::Notice { tone, text, .. } = node else { panic!("{node:?}"); }; assert_eq!(*tone, quasi_router::layout::Tone::Danger); assert!(text.contains("clips!"), "{text}"); // A control mid-drag can send half a number, and that is not an error the // user should see. assert!( editing( &editor, posting("/edit/gain/preview", Params::new().with("gain", "-")), ) .is_ok() ); // None of it applied anything. assert!(editor.asked().is_empty()); } #[test] fn a_normalize_target_is_checked_against_the_mode_it_was_chosen_for() { // Peak runs to 0 dBFS and loudness stops at -6 LUFS. The description // carries the wider of the two ranges, so the route holds the narrower. let editor = FakeEditor::editing(); editing( &editor, posting( "/edit/normalize", Params::new().with("mode", "peak").with("target", "-1"), ), ) .unwrap(); assert_eq!(editor.asked(), ["normalize peak=true -1"]); assert!( editing( &editor, posting( "/edit/normalize", Params::new().with("mode", "lufs").with("target", "-1"), ), ) .is_err() ); } #[test] fn a_fade_curve_the_app_cannot_read_back_is_refused() { // The pairing `FadeCurve::as_value`/`from_value` exists so the value a // control submits is the variant the audio pipeline matches on. let editor = FakeEditor::editing(); assert!( editing( &editor, posting( "/edit/fade", Params::new() .with("in", "out") .with("length", "250") .with("curve", "exponential"), ), ) .is_err() ); editing( &editor, posting( "/edit/fade", Params::new() .with("in", "out") .with("length", "250") .with("curve", "s-curve"), ), ) .unwrap(); assert_eq!(editor.asked(), ["fade in=false 250 s-curve"]); } #[test] fn the_batch_section_appears_with_a_second_sample_and_carries_its_own_values() { // The shipped batch buttons read the single-sample sliders, which was // caught once (M-14) and answered by baking the number into the label. // Forms of their own delete the piggyback rather than labelling it. let alone = FakeEditor::editing(); assert!(!said(&edited(&alone)).contains("Batch")); let several = FakeEditor::editing().with(|subject| subject.chosen = 12); assert!(said(&edited(&several)).contains("Batch: 12 samples")); editing( &several, posting("/edit/batch/gain", Params::new().with("gain", "3.5")), ) .unwrap(); assert_eq!(several.asked(), ["batch gain 3.5"]); // And the address refuses when the selection is not a batch. assert!( editing( &alone, posting("/edit/batch/gain", Params::new().with("gain", "3.5")), ) .is_err() ); } #[test] fn reversing_more_than_ten_asks_first() { // `ConfirmAction::ReverseSamples` and the 140-line match behind it, as one // builder method. The fourth variant this port has replaced. let asks = |chosen: usize| { let editor = FakeEditor::editing().with(|subject| subject.chosen = chosen); nodes(&edited(&editor)).iter().find_map(|node| match node { Node::Act(act) if act.label.starts_with("Reverse ") => act.confirm.clone(), _ => None, }) }; assert!(asks(3).is_none()); let question = asks(40).expect("a large batch asks"); assert!(question.contains("40"), "{question}"); } #[test] fn the_undo_is_offered_only_while_there_is_something_to_take_back() { // See the module header: this is an act rather than `Response::undoable`, // because the edit finishes on a worker and no answer is being made then. let done = FakeEditor::editing().with(|subject| subject.undoing = Some("Trim".to_owned())); let offered = said(&edited(&done)); assert!(offered.contains("Last edit: Trim"), "{offered}"); editing(&done, Request::post("/edit/undo")).unwrap(); assert_eq!(done.asked(), ["undo"]); let fresh = FakeEditor::editing(); assert!(!said(&edited(&fresh)).contains("Last edit")); // The act is an affordance, so the address refuses on its own. assert!(editing(&fresh, Request::post("/edit/undo")).is_err()); } #[test] fn replace_mode_says_what_it_costs() { let replacing = FakeEditor::editing().with(|subject| subject.result = Some("replace".to_owned())); let warned = said(&edited(&replacing)); assert!( warned.contains("the original is removed from this vault"), "{warned}" ); let sibling = FakeEditor::editing().with(|subject| subject.result = Some("sibling".to_owned())); assert!(!said(&edited(&sibling)).contains("removed from this vault")); } #[test] fn answering_the_prompt_carries_whether_to_remember_it() { // The reason the prompt is a form and not the shipped three buttons: an act // cannot carry the value a control beside it is holding. makeover-layout // `28a777df`. let editor = FakeEditor::editing().with(|subject| subject.asking = true); editing( &editor, posting( "/edit/result/choose", Params::new() .with("result", "replace") .with("remember", "on"), ), ) .unwrap(); assert_eq!(editor.asked(), ["choose replace remember=true"]); let once = FakeEditor::editing().with(|subject| subject.asking = true); editing( &once, posting( "/edit/result/choose", Params::new().with("result", "sibling"), ), ) .unwrap(); assert_eq!(once.asked(), ["choose sibling remember=false"]); } #[test] fn every_operation_answers_the_editor_rather_than_going_anywhere() { // An edit is something done to what is on screen, and the shipped panel // stays open through all of them. let editor = FakeEditor::editing().with(|subject| subject.chosen = 4); for request in [ posting( "/edit/trim", Params::new().with("start", "0").with("end", "1"), ), posting("/edit/gain", Params::new().with("gain", "1")), posting( "/edit/normalize", Params::new().with("mode", "peak").with("target", "-1"), ), Request::post("/edit/reverse"), posting( "/edit/fade", Params::new() .with("in", "in") .with("length", "100") .with("curve", "linear"), ), posting( "/edit/silence/insert", Params::new().with("at", "0").with("length", "100"), ), posting( "/edit/silence/remove", Params::new().with("from", "0").with("to", "50"), ), Request::post("/edit/batch/reverse"), ] { let path = request.path.clone(); let response = editing(&editor, request).unwrap(); assert!( matches!(response.outcome, Outcome::Screen(_)), "{path}: {response:?}" ); assert!(response.notice.is_some(), "{path} said nothing"); } } // --- the import flow --- /// The screen the flow answers, at whatever stage it is at. fn imported(import: &FakeImport) -> Screen { screen_of(&importing(import, Request::get("/import")).unwrap()).clone() } /// Every node on a screen, descending into regions. /// /// [`nodes`] walks the screen's own slots and stops. The flow's tagging stage /// puts a field inside a group per folder and its review stage puts two panes /// inside a split, so a test of either has to go down. fn deep_nodes(screen: &Screen) -> Vec { fn walk(body: &[quasi_router::Ranked], into: &mut Vec) { for placed in body { into.push(placed.node.clone()); if let Node::Region(slot) = &placed.node { walk(&slot.body, into); } } } let mut found = Vec::new(); for slot in &screen.slots { walk(&slot.body, &mut found); } found } /// Every act on a screen, by label, descending into regions. fn deep_acts(screen: &Screen) -> Vec { deep_nodes(screen) .into_iter() .filter_map(|node| match node { Node::Act(act) => Some(act), _ => None, }) .collect() } /// The labels of every act on a screen, descending into regions. fn deep_labels(screen: &Screen) -> Vec { deep_acts(screen).into_iter().map(|act| act.label).collect() } /// Every field on a screen, by name, descending into regions. fn deep_fields(screen: &Screen) -> Vec { deep_nodes(screen) .into_iter() .flat_map(|node| match node { Node::Field(field) => vec![*field], Node::Form { fields, .. } => fields, _ => Vec::new(), }) .collect() } /// What one part of a row says. fn said_in(row: &quasi_router::Row, part: quasi_router::layout::RowPart) -> String { row.role(part) .filter_map(|node| match node { Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()), _ => None, }) .collect::>() .join(" ") } /// Every row of every list on a screen, descending into regions. fn deep_rows(screen: &Screen) -> Vec { deep_nodes(screen) .into_iter() .flat_map(|node| match node { Node::List { rows, .. } => rows, _ => Vec::new(), }) .collect() } /// Everything a screen says, descending into regions. fn deep_said(screen: &Screen) -> String { deep_nodes(screen) .iter() .filter_map(|node| match node { Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => { Some(text.clone()) } Node::StandIn { message, .. } => Some(message.clone()), _ => None, }) .collect::>() .join(" | ") } /// An import being configured, with whatever answers a test wants. fn configuring(strategy: Strategy, vault_name: &str, vaults: &[&str]) -> Stage { Stage::Configuring { source: "/home/max/Downloads/packs".to_owned(), files: 412, strategy, vault_name: vault_name.to_owned(), vaults: vaults .iter() .map(|name| VaultChoice { name: (*name).to_owned(), }) .collect(), merging_into: 0, } } /// One reviewed sample with the suggestions a test names. fn reviewed(name: &str, suggestions: &[(&str, f32, bool)]) -> Reviewed { Reviewed { name: name.to_owned(), duration: 1.25, sample_rate: 48_000, peak_db: Some(-3.2), bpm: Some(128.0), musical_key: Some("Am".to_owned()), suggestions: suggestions .iter() .map(|(tag, confidence, accepted)| Suggestion { tag: (*tag).to_owned(), confidence: *confidence, reason: format!("because of {tag}"), accepted: *accepted, }) .collect(), } } #[test] fn nine_stages_answer_one_address_because_none_of_them_is_a_place() { // `export`'s rule at three times the size. A user does not navigate to // "files are being copied"; they arrive there because they pressed Import. let stages = [ (Stage::Idle, "Nothing is being imported"), (configuring(Strategy::Flat, "", &[]), "Import Folder"), ( Stage::Scanning { found: 40, size: Some("1.2 GB".to_owned()), }, "Scanning for audio files", ), ( Stage::Copying { done: 3, total: 9, current: "kick.wav".to_owned(), size: None, in_place: false, failures: Vec::new(), }, "Importing: kick.wav", ), ( Stage::Tagging { folders: Vec::new(), }, "Tag Imported Folders", ), ( Stage::Choosing { samples: 12, measures: every_measure(), resumable: true, }, "12 samples to analyze", ), ( Stage::Analysing { done: 1, total: 4, current: "snare.wav".to_owned(), failures: Vec::new(), }, "Analysing: snare.wav", ), ( Stage::Reviewing { items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])], at: 0, order: Order::Arrival, }, "Review Tag Suggestions", ), ( Stage::Summary { rejected: Vec::new(), unanalysed: Vec::new(), }, "Import Summary", ), ]; for (stage, expected) in stages { let import = FakeImport::at(stage); let screen = imported(&import); let said = deep_said(&screen); assert!(said.contains(expected), "{expected} missing from: {said}"); } } /// Everything ticked, which is what the app defaults an analysis run to. fn every_measure() -> Measures { Measures { loudness: true, bpm: true, key: true, spectral: true, loops: true, suggestions: true, fingerprint: true, smart_skip: true, } } #[test] fn the_stage_rail_survives_as_prose_because_the_vocabulary_cannot_say_who_chose() { // `Slot::showing_one` carries the names and the position and also says the // reader may change which child is up, which a wizard's stage is not. See // the module header: `quasi:vocabulary:unchosen-stage`. let railed = [ ( configuring(Strategy::Flat, "", &[]), "Step 1 of 4: Configure", ), ( Stage::Tagging { folders: Vec::new(), }, "Step 2 of 4: Tag folders", ), ( Stage::Choosing { samples: 1, measures: every_measure(), resumable: false, }, "Step 3 of 4: Analyze", ), ( Stage::Reviewing { items: Vec::new(), at: 0, order: Order::Arrival, }, "Step 4 of 4: Review", ), ]; for (stage, expected) in railed { let import = FakeImport::at(stage); assert!( deep_said(&imported(&import)).contains(expected), "{expected} missing" ); } // And the two stages the shipped screen rails nothing on do not invent one. let stopped = FakeImport::at(Stage::Stopped { what: Halted::Import, done: 2, total: 9, }); assert!(!deep_said(&imported(&stopped)).contains("Step ")); } #[test] fn the_configure_screen_opens_only_the_follow_up_its_strategy_needs() { // A control that cannot be used is worse than one that is not there, which // is the settings screen's line and the shipped screen's own arrangement. let flat = FakeImport::at(configuring(Strategy::Flat, "", &["Drums"])); let named: Vec = deep_fields(&imported(&flat)) .into_iter() .map(|field| field.name) .collect(); assert_eq!(named, [Decision::Strategy.as_str()]); let new = FakeImport::at(configuring(Strategy::NewVault, "Kits", &["Drums"])); let named: Vec = deep_fields(&imported(&new)) .into_iter() .map(|field| field.name) .collect(); assert_eq!( named, [Decision::Strategy.as_str(), Decision::VaultName.as_str()] ); let merge = FakeImport::at(configuring(Strategy::Merge, "", &["Drums", "Synths"])); let named: Vec = deep_fields(&imported(&merge)) .into_iter() .map(|field| field.name) .collect(); assert_eq!( named, [Decision::Strategy.as_str(), Decision::MergeVault.as_str()] ); } #[test] fn a_new_vault_with_no_name_says_so_on_the_field_and_the_route_agrees() { // The state the shipped screen left to a hover: Import disabled itself and // told only the pointer why. let import = FakeImport::at(configuring(Strategy::NewVault, " ", &[])); let screen = imported(&import); let field = deep_fields(&screen) .into_iter() .find(|field| field.name == Decision::VaultName.as_str()) .expect("the vault name is asked for"); assert_eq!( field.error.as_deref(), Some("Enter a name for the new vault.") ); let go = deep_acts(&screen) .into_iter() .find(|act| act.label == "Import") .expect("Import is offered"); assert!(!go.interactive()); // And the address refuses, because a disabled control the reader can still // reach by typing is not disabled. assert!(importing(&import, Request::post("/import/start")).is_err()); } #[test] fn merging_with_nowhere_to_merge_is_refused_on_the_choice_that_says_why() { // `Choice::unless` is the member `Act::disabled` is missing: not pickable // yet, and why. See the module header's second finding. let import = FakeImport::at(configuring(Strategy::Flat, "", &[])); let screen = imported(&import); let strategy = deep_fields(&screen) .into_iter() .find(|field| field.name == Decision::Strategy.as_str()) .expect("the strategy is asked for"); let merge = strategy .options .iter() .find(|choice| choice.value == Strategy::Merge.as_str()) .expect("merging is on offer"); assert!(!merge.available()); assert_eq!( merge.unavailable.as_deref(), Some("No existing vaults to merge into.") ); // The other two stay pickable, because they are. for value in [Strategy::Flat.as_str(), Strategy::NewVault.as_str()] { let choice = strategy .options .iter() .find(|choice| choice.value == value) .expect("on offer"); assert!(choice.available(), "{value} should be pickable"); } } #[test] fn the_one_way_edge_is_said_before_the_control_that_crosses_it() { // Configure to Importing is the only transition here that cannot be walked // back: cancelling mid-copy keeps what landed. So Import is a commit, and // the sentence is what stops it reading as a preview. let import = FakeImport::at(configuring(Strategy::Flat, "", &[])); assert!( deep_said(&imported(&import)).contains("copies already made will stay in the library"), "the commit is not said" ); } #[test] fn the_walk_is_pending_rather_than_a_meter_of_nothing() { // There is no total until the walk lands, and a meter of 0/0 draws as // finished. `export`'s reading, and here it is a whole stage rather than a // branch because the shipped screen answers it with a different body. let import = FakeImport::at(Stage::Scanning { found: 0, size: None, }); let screen = imported(&import); assert!(deep_nodes(&screen).iter().any(|node| matches!( node, Node::StandIn { state: quasi_router::layout::Readiness::Pending, .. } ))); assert!( !deep_nodes(&screen) .iter() .any(|node| matches!(node, Node::Meter(_))) ); // Cancel is present and dead, and the reason is a line of its own because // `Act::disabled` cannot carry one. let cancel = deep_acts(&screen) .into_iter() .find(|act| act.label == "Cancel") .expect("Cancel is offered"); assert!(!cancel.interactive()); assert!(deep_said(&screen).contains("once the scan completes")); } #[test] fn copying_says_whether_the_files_are_being_duplicated() { // The whole point of the line: referencing files where they sit costs no // disk and copying them costs this much. let copied = FakeImport::at(Stage::Copying { done: 1, total: 9, current: String::new(), size: Some("1.2 GB".to_owned()), in_place: false, failures: Vec::new(), }); assert!(deep_said(&imported(&copied)).contains("~1.2 GB will be duplicated into vault")); let referenced = FakeImport::at(Stage::Copying { done: 1, total: 9, current: String::new(), size: Some("1.2 GB".to_owned()), in_place: true, failures: Vec::new(), }); assert!(deep_said(&imported(&referenced)).contains("referenced in place, no copies")); } #[test] fn a_running_screen_reports_how_much_is_going_wrong_rather_than_where() { // One list across both halves of the run, which is what `draw_error_log` // does: at which stage a file failed is a fact for the summary. let import = FakeImport::at(Stage::Copying { done: 2, total: 9, current: String::new(), size: None, in_place: false, failures: vec![ Failure { name: "/packs/broken.wav".to_owned(), error: "unsupported codec".to_owned(), }, Failure { name: "hiss.aif".to_owned(), error: "decode failed".to_owned(), }, ], }); let screen = imported(&import); assert!(deep_said(&screen).contains("2 errors")); assert_eq!(deep_rows(&screen).len(), 2); // Retry appears only because something failed. assert!(deep_labels(&screen).contains(&"Retry".to_owned())); let clean = FakeImport::at(Stage::Copying { done: 2, total: 9, current: String::new(), size: None, in_place: false, failures: Vec::new(), }); assert!(!deep_labels(&imported(&clean)).contains(&"Retry".to_owned())); } #[test] fn applying_no_tags_is_refused_because_skip_is_the_discard_path() { // The shipped button's stated reason: Apply Tags stopped doubling as a // no-op Skip. let empty = FakeImport::at(Stage::Tagging { folders: vec![FolderTags { name: "kicks".to_owned(), samples: 12, typed: " ".to_owned(), invalid: Vec::new(), }], }); let screen = imported(&empty); let apply = deep_acts(&screen) .into_iter() .find(|act| act.label == "Apply Tags") .expect("Apply Tags is offered"); assert!(!apply.interactive()); assert!(deep_said(&screen).contains("Add at least one tag, or use Skip.")); assert!(importing(&empty, Request::post("/import/folders/apply")).is_err()); // Skip is never refused: it is the explicit discard. importing(&empty, Request::post("/import/folders/skip")).unwrap(); assert_eq!(empty.answered(), ["tags:skip"]); } #[test] fn an_invalid_tag_is_named_beside_the_folder_it_was_typed_against() { // Validated by the app's own rule rather than by a second copy of it here. let import = FakeImport::at(Stage::Tagging { folders: vec![FolderTags { name: "kicks".to_owned(), samples: 12, typed: "drums, NOT A TAG".to_owned(), invalid: vec!["NOT A TAG".to_owned()], }], }); let screen = imported(&import); assert!(deep_said(&screen).contains("Invalid: NOT A TAG")); // And Apply is live, because something valid was typed too. let apply = deep_acts(&screen) .into_iter() .find(|act| act.label == "Apply Tags") .expect("Apply Tags is offered"); assert!(apply.interactive()); } #[test] fn broadcasting_a_tag_set_is_a_form_rather_than_a_field() { // A `changes` on it would copy a half-typed tag into every input on the way // to the whole one. let import = FakeImport::at(Stage::Tagging { folders: vec![FolderTags { name: "kicks".to_owned(), samples: 12, typed: String::new(), invalid: Vec::new(), }], }); let submits = forms(&imported(&import)); assert_eq!( submits, [( "Apply to all".to_owned(), "/import/folders/all".to_owned(), vec!["tags".to_owned()] )] ); importing( &import, posting( "/import/folders/all", Params::new().with("tags", "one-shots"), ), ) .unwrap(); assert_eq!(import.answered(), ["tag:all=one-shots"]); // And nothing is broadcast when nothing was typed. let blank = FakeImport::at(Stage::Tagging { folders: Vec::new(), }); assert!( importing( &blank, posting("/import/folders/all", Params::new().with("tags", " ")) ) .is_err() ); } #[test] fn every_measure_is_a_control_and_an_address_and_the_set_is_closed() { let import = FakeImport::at(Stage::Choosing { samples: 12, measures: Measures { bpm: false, ..every_measure() }, resumable: true, }); let screen = imported(&import); let named: Vec = deep_fields(&screen) .into_iter() .map(|field| field.name) .collect(); let expected: Vec = Measure::ALL .into_iter() .map(|measure| measure.as_str().to_owned()) .collect(); assert_eq!(named, expected); // What is off reads as off. let bpm = deep_fields(&screen) .into_iter() .find(|field| field.name == Measure::Bpm.as_str()) .expect("BPM is asked about"); assert_eq!(bpm.value.as_deref(), Some("")); importing( &import, posting( "/import/measure/bpm", Params::new().with(Measure::Bpm.as_str(), "on"), ), ) .unwrap(); assert_eq!(import.answered(), ["measure:bpm=true"]); // A name the description does not know is a refusal rather than a no-op: // the address is reachable by typing. assert!(importing(&import, Request::post("/import/measure/vibes")).is_err()); } #[test] fn going_back_is_refused_where_there_is_no_tagging_step_behind_it() { // What the shipped Back button is disabled on: the flow was entered // somewhere other than a folder import, so nothing was stashed. let stranded = FakeImport::at(Stage::Choosing { samples: 12, measures: every_measure(), resumable: false, }); let screen = imported(&stranded); let back = deep_acts(&screen) .into_iter() .find(|act| act.label == "Back") .expect("Back is offered"); assert!(!back.interactive()); assert!(importing(&stranded, Request::post("/import/analyse/back")).is_err()); let resumable = FakeImport::at(Stage::Choosing { samples: 12, measures: every_measure(), resumable: true, }); importing(&resumable, Request::post("/import/analyse/back")).unwrap(); assert_eq!(resumable.answered(), ["analyse:back"]); } #[test] fn suggestions_are_read_best_first_and_the_route_does_not_sort_to_get_there() { // The shipped screen sorts `item.suggestions` in place every frame, which a // handler holding `&S` cannot. The adapter sorts the copy, so the order is // a fact the description arrives carrying. let import = FakeImport::at(Stage::Reviewing { items: vec![reviewed( "kick.wav", &[("drums/kick", 0.91, true), ("percussion", 0.42, false)], )], at: 0, order: Order::Arrival, }); let screen = imported(&import); let tagged: Vec = deep_rows(&screen) .into_iter() .filter_map(|row| row.parts.first().map(|_| row.primary())) .collect(); assert!( tagged.iter().any(|said| said.contains("drums/kick")), "{tagged:?}" ); // The confidence rides as a trailing fact rather than as a colour: what is // described is how sure the analysis is. assert!( deep_rows(&screen) .iter() .any(|row| said_in(row, quasi_router::layout::RowPart::Meta) == "91%") ); } #[test] fn judging_flips_rather_than_sets_because_a_tick_submits_no_state() { // `Row::toggling` says the tick *is* the write, and a renderer fires it // with no value of its own. So the route reads what is true and answers // with the other one. let import = FakeImport::at(Stage::Reviewing { items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])], at: 0, order: Order::Arrival, }); importing( &import, posting( "/import/review/0/judge", Params::new().with("tag", "drums/kick"), ), ) .unwrap(); assert_eq!(import.answered(), ["judge:0:drums/kick=true"]); let accepted = FakeImport::at(Stage::Reviewing { items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, true)])], at: 0, order: Order::Arrival, }); importing( &accepted, posting( "/import/review/0/judge", Params::new().with("tag", "drums/kick"), ), ) .unwrap(); assert_eq!(accepted.answered(), ["judge:0:drums/kick=false"]); // A tag nothing suggested is a refusal, not a write. assert!( importing( &import, posting( "/import/review/0/judge", Params::new().with("tag", "invented") ) ) .is_err() ); } #[test] fn applying_nothing_is_refused_because_a_zero_commit_is_a_control_that_lies() { let none = FakeImport::at(Stage::Reviewing { items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])], at: 0, order: Order::Arrival, }); let screen = imported(&none); let apply = deep_acts(&screen) .into_iter() .find(|act| act.label.starts_with("Apply ")) .expect("Apply is offered"); assert_eq!(apply.label, "Apply 0 Tags"); assert!(!apply.interactive()); assert!(importing(&none, Request::post("/import/review/apply")).is_err()); let one = FakeImport::at(Stage::Reviewing { items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, true)])], at: 0, order: Order::Arrival, }); let screen = imported(&one); let apply = deep_acts(&screen) .into_iter() .find(|act| act.label.starts_with("Apply ")) .expect("Apply is offered"); // The count is on the control, so the blast radius is read before the press. assert_eq!(apply.label, "Apply 1 Tag"); assert!(apply.interactive()); } #[test] fn the_batch_is_summarised_so_the_analysis_can_be_eyeballed_before_it_commits() { let import = FakeImport::at(Stage::Reviewing { items: vec![ Reviewed { bpm: Some(90.0), musical_key: Some("Am".to_owned()), ..reviewed("kick.wav", &[("drums/kick", 0.9, false)]) }, Reviewed { bpm: Some(174.0), musical_key: Some("Am".to_owned()), ..reviewed("snare.wav", &[]) }, ], at: 0, order: Order::Arrival, }); let figures: Vec<(String, String)> = deep_nodes(&imported(&import)) .into_iter() .flat_map(|node| match node { Node::Stats { figures } => figures, _ => Vec::new(), }) .map(|(figure, _)| (figure.value, figure.caption)) .collect(); assert!( figures.contains(&("90 - 174".to_owned(), "BPM".to_owned())), "{figures:?}" ); assert!( figures.contains(&("2".to_owned(), "Am".to_owned())), "{figures:?}" ); } #[test] fn the_summary_keeps_the_two_kinds_of_failure_apart() { // One is remediable from this screen and one is not, which is what the // shipped copy says and why they are not the single list the progress // screens show. let import = FakeImport::at(Stage::Summary { rejected: vec![Failure { name: "/packs/broken.wav".to_owned(), error: "unsupported codec".to_owned(), }], unanalysed: vec![Failure { name: "hiss.wav".to_owned(), error: "decode failed".to_owned(), }], }); let screen = imported(&import); let said = deep_said(&screen); assert!(said.contains("1 file failed analysis"), "{said}"); assert!(said.contains("1 file failed to import"), "{said}"); assert!(said.contains("couldn't be analysed"), "{said}"); assert!(said.contains("Re-running the import"), "{said}"); // Only the analysis failures can be removed from here. let removable: Vec = deep_rows(&screen) .into_iter() .flat_map(|row| row.menu.into_iter().map(|act| act.label)) .collect(); assert_eq!(removable, ["Remove"]); } #[test] fn removing_failed_samples_asks_on_the_control_that_does_it() { // `Act::confirm`, which is what `ConfirmAction`'s ten variants were. let import = FakeImport::at(Stage::Summary { rejected: Vec::new(), unanalysed: vec![ Failure { name: "hiss.wav".to_owned(), error: "decode failed".to_owned(), }, Failure { name: "click.wav".to_owned(), error: "decode failed".to_owned(), }, ], }); let screen = imported(&import); let all = deep_acts(&screen) .into_iter() .find(|act| act.label == "Remove All Failed") .expect("Remove All Failed is offered"); assert_eq!(all.tone, quasi_router::layout::Tone::Danger); assert!( all.confirm .as_deref() .is_some_and(|asked| asked.contains("2 samples")), "{:?}", all.confirm ); let one = deep_rows(&screen) .into_iter() .flat_map(|row| row.menu) .next() .expect("a row offers Remove"); assert!( one.confirm .as_deref() .is_some_and(|asked| asked.contains("hiss.wav")), "{:?}", one.confirm ); importing(&import, Request::post("/import/summary/1/purge")).unwrap(); assert_eq!(import.answered(), ["failed:purge:1"]); // Past the end is a refusal rather than a delete of whatever is there. assert!(importing(&import, Request::post("/import/summary/9/purge")).is_err()); } #[test] fn the_doors_hand_off_to_the_host_and_say_where_the_answer_will_be() { // No outcome means "nothing here changed", so they answer the flow, which // is right by the time the picker returns. See the module header. for (address, expected) in [ ("/import/open/folder", "open:folder"), ("/import/open/quick", "open:quick"), ("/import/open/files", "open:files"), ] { let import = FakeImport::default(); let response = importing(&import, Request::post(address)).unwrap(); assert!( matches!(&response.outcome, Outcome::Goto(action) if action.destination.as_str() == "/import"), "{address} answered {:?}", response.outcome ); assert_eq!(import.answered(), [expected]); } } #[test] fn the_source_can_only_be_changed_while_there_is_one_being_configured() { let configuring = FakeImport::at(configuring(Strategy::Flat, "", &[])); importing(&configuring, Request::post("/import/source")).unwrap(); assert_eq!(configuring.answered(), ["open:source"]); let running = FakeImport::at(Stage::Copying { done: 1, total: 9, current: String::new(), size: None, in_place: false, failures: Vec::new(), }); assert!(importing(&running, Request::post("/import/source")).is_err()); } #[test] fn the_import_menu_is_an_overlay_holding_the_three_doors() { // The shipped control is a popup of three choices anchored to a button. // Described as an overlay, which is near enough and not exact -- the second // consumer of the note `toolbar` left on anchoring. let import = FakeImport::default(); let response = importing(&import, Request::get("/import/open")).unwrap(); assert!(matches!(response.outcome, Outcome::Over(_)), "{response:?}"); let labels = deep_labels(screen_of(&response)); assert!( labels.contains(&"Import folder...".to_owned()), "{labels:?}" ); assert!( labels.contains(&"Quick import folder...".to_owned()), "{labels:?}" ); assert!(labels.contains(&"Import files...".to_owned()), "{labels:?}"); // Each says what it does, which is the correction the shipped popup made to // itself: the two folder entries differ in commit semantics, not in name. let said = deep_said(screen_of(&response)); assert!(said.contains("no strategy or tagging review"), "{said}"); } #[test] fn an_idle_flow_offers_the_way_in_rather_than_only_saying_it_is_empty() { // Where this differs from `export`'s idle: an export is entered by choosing // samples on another screen and there is nothing honest to offer, and an // import is entered by choosing a folder, which is a control. let import = FakeImport::default(); let screen = imported(&import); let offered: Vec = deep_nodes(&screen) .into_iter() .flat_map(|node| match node { Node::StandIn { act, .. } => act.map(|act| act.label).into_iter().collect::>(), _ => Vec::new(), }) .collect(); assert_eq!(offered, ["Import..."]); } #[test] fn the_sweep_is_not_a_stage_of_the_flow_and_answers_its_own_address() { // It shares a shipped file and an app enum with four screens that are part // of the flow, and nothing else. See the module header. let idle = FakeImport::default(); assert!(importing(&idle, Request::get("/cleanup")).is_err()); let sweeping = FakeImport::sweeping(Sweep { done: 4, total: 20, current: "orphan.wav".to_owned(), }); let screen = screen_of(&importing(&sweeping, Request::get("/cleanup")).unwrap()).clone(); let said = deep_said(&screen); assert!(said.contains("Cleaning Up Samples"), "{said}"); assert!(said.contains("Removing: orphan.wav"), "{said}"); assert!( !said.contains("Step "), "the sweep is not a wizard step: {said}" ); // The flow itself is idle while a sweep runs, because they are different // operations that happen to share an enum. assert!(deep_said(&imported(&sweeping)).contains("Nothing is being imported")); importing(&sweeping, Request::post("/cleanup/stop")).unwrap(); assert_eq!(sweeping.answered(), ["sweep:stop"]); } #[test] fn a_sweep_that_has_not_counted_anything_is_pending_rather_than_finished() { let sweeping = FakeImport::sweeping(Sweep { done: 0, total: 0, current: String::new(), }); let screen = screen_of(&importing(&sweeping, Request::get("/cleanup")).unwrap()).clone(); assert!(deep_nodes(&screen).iter().any(|node| matches!( node, Node::StandIn { state: quasi_router::layout::Readiness::Pending, .. } ))); } #[test] fn the_flow_refuses_every_name_it_did_not_declare() { // Each of these addresses is reachable by typing, so an undeclared name is // a `NotFound` rather than a panic or a silent no-op. let import = FakeImport::at(configuring(Strategy::Flat, "", &[])); assert!(importing(&import, Request::post("/import/set/colour")).is_err()); assert!( importing( &import, posting( "/import/set/strategy", Params::new().with("strategy", "sideways") ) ) .is_err() ); let reviewing = FakeImport::at(Stage::Reviewing { items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])], at: 0, order: Order::Arrival, }); assert!( importing( &reviewing, posting("/import/review/order", Params::new().with("value", "vibes")) ) .is_err() ); assert!(importing(&reviewing, Request::post("/import/review/7/read")).is_err()); let tagging = FakeImport::at(Stage::Tagging { folders: Vec::new(), }); assert!( importing( &tagging, posting( "/import/folders/3/tags", Params::new().with("tags", "drums") ) ) .is_err() ); } #[test] fn the_stage_a_write_lands_on_is_the_stage_it_was_asked_from() { // Every write is refused from a stage that is not about it, which is what // keeps the flow's thirty-one routes from being thirty-one ways to reach // state the screen is not showing. let copying = FakeImport::at(Stage::Copying { done: 1, total: 9, current: String::new(), size: None, in_place: false, failures: Vec::new(), }); assert!(importing(©ing, Request::post("/import/start")).is_err()); assert!(importing(©ing, Request::post("/import/folders/apply")).is_err()); assert!(importing(©ing, Request::post("/import/review/apply")).is_err()); assert!(importing(©ing, Request::post("/import/analyse/back")).is_err()); // And the ones that are about giving up are not: they are what a running // stage is for. importing(©ing, Request::post("/import/stop")).unwrap(); importing(©ing, Request::post("/import/retry")).unwrap(); importing(©ing, Request::post("/import/dismiss")).unwrap(); assert_eq!(copying.answered(), ["stop", "retry", "dismiss"]); } #[test] fn cancelling_says_what_landed_and_what_is_left_for_either_half() { for (what, expected) in [ (Halted::Import, "duplicates will be skipped"), (Halted::Analysis, "run analysis again to complete them"), ] { let import = FakeImport::at(Stage::Stopped { what, done: 3, total: 9, }); let said = deep_said(&imported(&import)); assert!(said.contains("Stopped at 3 of 9"), "{said}"); assert!(said.contains(expected), "{said}"); } } #[test] fn the_toolbar_carries_both_doors_now_that_the_flows_have_them() { // The toolbar port left these out because they "belong with the import // flow, which is its own remaining pass". This is that pass. let labels = acts(&topped(&FakeBar::at(Where::Folder { trail: Vec::new() }))); assert!(labels.contains(&"Import".to_owned()), "{labels:?}"); assert!(labels.contains(&"Export".to_owned()), "{labels:?}"); } // --- the forge --- /// Nothing is in the forge, and nothing can be put there. /// /// [`Idle`] and [`NoImport`]'s third peer: every method is a refusal, so a test /// of some other screen cannot chop a sample by accident. struct Unforged; impl Forge for Unforged { fn forging(&self) -> Option { None } fn slice_by(&self, _how: Chop) {} fn turn(&self, _knob: Knob, _value: &str) {} fn preview(&self) {} fn chop(&self) {} fn choose_device(&self, _name: &str) {} fn conform(&self) {} fn trim_silence(&self) {} } /// A forge in memory, recording what was asked of it. struct FakeForge { forging: Option, asked: RefCell>, } impl FakeForge { fn with(forging: Forging) -> Self { Self { forging: Some(forging), asked: RefCell::new(Vec::new()), } } fn empty() -> Self { Self { forging: None, asked: RefCell::new(Vec::new()), } } fn asked(&self) -> Vec { self.asked.borrow().clone() } fn say(&self, said: impl Into) { self.asked.borrow_mut().push(said.into()); } } impl Forge for FakeForge { fn forging(&self) -> Option { self.forging.clone() } fn slice_by(&self, how: Chop) { self.say(format!("slice:{}", how.as_str())); } fn turn(&self, knob: Knob, value: &str) { self.say(format!("set:{}={value}", knob.as_str())); } fn preview(&self) { self.say("preview"); } fn chop(&self) { self.say("chop"); } fn choose_device(&self, name: &str) { self.say(format!("device={name}")); } fn conform(&self) { self.say("conform"); } fn trim_silence(&self) { self.say("trim"); } } /// A sample loaded into the forge, with whatever a test wants of it. fn forging() -> Forging { Forging { name: "break.wav".to_owned(), rate: 44_100, busy: false, how: Chop::Equal, sensitivity: 0.5, divisions: 8, bpm: 120.0, subdivisions: 1, slices: 0, devices: vec![ DeviceChoice { name: "SP-404".to_owned(), summary: "WAV 44.1k/16".to_owned(), }, DeviceChoice { name: "Digitakt".to_owned(), summary: String::new(), }, ], device: None, chosen: 1, threshold_db: -60.0, } } /// A router call against this forge. fn forged(forge: &FakeForge, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge, queue: &Unqueued, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The forge window, with whatever is loaded into it. fn forge_screen(forge: &FakeForge) -> Screen { screen_of(&forged(forge, Request::get("/forge")).unwrap()).clone() } #[test] fn an_empty_forge_says_what_would_fill_it() { let forge = FakeForge::empty(); let said = deep_said(&forge_screen(&forge)); assert!( said.contains("Select a sample and open the forge"), "{said}" ); // And every write is refused, because there is nothing to write to. for address in [ "/forge/preview", "/forge/chop", "/forge/conform", "/forge/trim", ] { assert!(forged(&forge, Request::post(address)).is_err(), "{address}"); } } #[test] fn the_forge_is_one_shape_because_busy_is_a_property_of_the_sample() { // The rule from the other side: a state a reader arrived at is a shape, and // a property of the subject is a field. Every section is still described // while a run is in flight. let busy = FakeForge::with(Forging { busy: true, chosen: 3, ..forging() }); let screen = forge_screen(&busy); let said = deep_said(&screen); assert!(said.contains("Working..."), "{said}"); assert!(said.contains("Chop"), "{said}"); assert!(said.contains("Batch"), "{said}"); // The conform section's heading is the picker's own label, which is the // shipped screen's call: the question names itself and the `strong` line // above it went. assert!( deep_fields(&screen) .into_iter() .any(|field| field.label == "Conform for device") ); // The acts can say they are dead. The fields cannot, which is the finding. let preview = deep_acts(&screen) .into_iter() .find(|act| act.label == "Preview slices") .expect("Preview is offered"); assert!(!preview.interactive()); } #[test] fn only_the_parameters_the_chosen_method_reads_are_described() { // The shipped window's own `match`, and the settings screen's line: a // control that cannot be used is worse than one that is not there. let transient = FakeForge::with(Forging { how: Chop::Transient, ..forging() }); let named: Vec = deep_fields(&forge_screen(&transient)) .into_iter() .map(|field| field.name) .collect(); assert!( named.contains(&Knob::Sensitivity.as_str().to_owned()), "{named:?}" ); assert!(!named.contains(&Knob::Bpm.as_str().to_owned()), "{named:?}"); let grid = FakeForge::with(Forging { how: Chop::Bpm, ..forging() }); let named: Vec = deep_fields(&forge_screen(&grid)) .into_iter() .map(|field| field.name) .collect(); assert!(named.contains(&Knob::Bpm.as_str().to_owned()), "{named:?}"); assert!( !named.contains(&Knob::Sensitivity.as_str().to_owned()), "{named:?}" ); // Divisions is a strip of a handful of values rather than a field, which is // what the shipped row of selectable buttons is. let equal = FakeForge::with(forging()); let named: Vec = deep_fields(&forge_screen(&equal)) .into_iter() .map(|field| field.name) .collect(); assert!( !named.contains(&Knob::Divisions.as_str().to_owned()), "{named:?}" ); } #[test] fn chopping_is_gated_on_a_preview_and_the_label_carries_the_count() { // AF-9: committing to an unknown slice count is what the preview exists to // stop, and the count on the label is the blast radius before the press. let unpreviewed = FakeForge::with(forging()); let screen = forge_screen(&unpreviewed); let chop = deep_acts(&screen) .into_iter() .find(|act| act.label.starts_with("Chop")) .expect("Chop is offered"); assert_eq!(chop.label, "Chop"); assert!(!chop.interactive()); // The fifth consumer of `quasi:vocabulary:disabled-reason`, degraded to a // line beside the control. assert!(deep_said(&screen).contains("Preview the slices first")); assert!(forged(&unpreviewed, Request::post("/forge/chop")).is_err()); let previewed = FakeForge::with(Forging { slices: 14, ..forging() }); let screen = forge_screen(&previewed); let chop = deep_acts(&screen) .into_iter() .find(|act| act.label.starts_with("Chop")) .expect("Chop is offered"); assert_eq!(chop.label, "Chop into 14 slices"); assert!(chop.interactive()); forged(&previewed, Request::post("/forge/chop")).unwrap(); assert_eq!(previewed.asked(), ["chop"]); } #[test] fn the_device_picker_says_what_to_do_in_its_own_ghost_text() { // The call the shipped screen already made: a select with nothing chosen // reads as an empty box, and the greyed button beside it is the wrong place // to explain that. let forge = FakeForge::with(forging()); let screen = forge_screen(&forge); let picker = deep_fields(&screen) .into_iter() .find(|field| field.name == "device") .expect("the device is asked for"); assert_eq!(picker.placeholder.as_deref(), Some("Select device...")); assert_eq!(picker.value.as_deref(), Some("")); // The summary is part of what the option reads as, and a device with none // is just its name. let labels: Vec = picker .options .iter() .map(|choice| choice.label.clone()) .collect(); assert_eq!(labels, ["SP-404 (WAV 44.1k/16)", "Digitakt"]); // Conform is dead until one is chosen, and refused at the address too. let conform = deep_acts(&screen) .into_iter() .find(|act| act.label == "Conform") .expect("Conform is offered"); assert!(!conform.interactive()); assert!(forged(&forge, Request::post("/forge/conform")).is_err()); // A device no profile carries is a refusal: the address is reachable by // typing. assert!( forged( &forge, posting("/forge/device", Params::new().with("device", "MPC-9000")) ) .is_err() ); // And an empty value is "nothing chosen" rather than a device named "". forged( &forge, posting("/forge/device", Params::new().with("device", "")), ) .unwrap(); assert_eq!(forge.asked(), ["device="]); } #[test] fn a_forge_with_no_device_profiles_says_so_instead_of_offering_an_empty_picker() { let forge = FakeForge::with(Forging { devices: Vec::new(), ..forging() }); let screen = forge_screen(&forge); assert!(deep_said(&screen).contains("No device profiles available.")); assert!( !deep_fields(&screen) .into_iter() .any(|field| field.name == "device") ); } #[test] fn the_batch_section_is_about_the_selection_rather_than_the_sample() { // Trimming a batch of one is the single-sample operation wearing the // batch's label, which is what the shipped section is hidden behind. let alone = FakeForge::with(forging()); assert!(deep_said(&forge_screen(&alone)).contains("Select 2+ samples")); assert!(forged(&alone, Request::post("/forge/trim")).is_err()); let several = FakeForge::with(Forging { chosen: 5, ..forging() }); let screen = forge_screen(&several); let trim = deep_acts(&screen) .into_iter() .find(|act| act.label.starts_with("Trim")) .expect("Trim is offered"); assert_eq!(trim.label, "Trim silence on 5 samples"); forged(&several, Request::post("/forge/trim")).unwrap(); assert_eq!(several.asked(), ["trim"]); } #[test] fn a_measured_control_names_its_unit_rather_than_hiding_it_in_the_label() { // makeover-layout 0.33.0, decided by Max 2026-08-21. The label is the // question's name and the unit is a fact about the value, so a reader of // this description gets `-96` and `dBFS` as two answers rather than one // string it would have to parse the second out of. // The batch trim appears once more than one sample is picked, which is what // carries the threshold. let forge = FakeForge::with(Forging { chosen: 3, ..forging() }); let response = forged(&forge, Request::get("/forge")).unwrap(); let threshold = deep_fields(screen_of(&response)) .into_iter() .find(|field| field.name == "threshold") .expect("the threshold is described"); assert_eq!(threshold.label, "Threshold"); assert_eq!(threshold.unit.as_deref(), Some("dBFS")); assert!(threshold.kind.measurable()); } #[test] fn one_write_route_serves_five_controls_across_two_sections() { let forge = FakeForge::with(Forging { how: Chop::Bpm, chosen: 3, ..forging() }); for (address, name, value, expected) in [ ("/forge/set/bpm", "bpm", "174", "set:bpm=174"), ( "/forge/set/subdivisions", "subdivisions", "4", "set:subdivisions=4", ), ( "/forge/set/threshold", "threshold", "-72", "set:threshold=-72", ), ] { let one = FakeForge::with(Forging { how: Chop::Bpm, chosen: 3, ..forging() }); forged(&one, posting(address, Params::new().with(name, value))).unwrap(); assert_eq!(one.asked(), [expected]); } // A name the description does not know is a refusal rather than a no-op. assert!(forged(&forge, Request::post("/forge/set/vibes")).is_err()); assert!(forged(&forge, Request::post("/forge/slice/sideways")).is_err()); } #[test] fn the_plugin_foreshadow_is_not_described_because_it_is_not_a_screen() { // "Plugin processing (CLAP/VST): coming soon" is copy for something that // does not exist. A description of a screen should not carry one. let forge = FakeForge::with(forging()); let said = deep_said(&forge_screen(&forge)); assert!(!said.contains("CLAP"), "{said}"); assert!(!said.contains("coming soon"), "{said}"); } // --- the migration strip --- #[test] fn the_strip_is_a_band_of_the_window_that_is_there_while_the_job_runs() { // No `/storage` address and no capability of its own: it is a band, which // is where the shipped strip puts itself and why. let quiet = FakeShell::default(); assert!( !shown(&quiet) .slots .iter() .any(|slot| slot.id == "shell-migration") ); let migrating = FakeShell { migrating: Some(Migrating { done: 40, total: 200, }), ..FakeShell::default() }; let screen = shown(&migrating); let strip = screen .slots .iter() .find(|slot| slot.id == "shell-migration") .expect("the strip is a region of the window"); assert_eq!(strip.kind, quasi_router::RegionKind::Band); let said = deep_said(&screen); assert!(said.contains("Optimising storage layout"), "{said}"); } #[test] fn pausing_the_migration_says_on_the_control_that_it_resumes() { // Cancelling is honest here in a way it usually is not, and that belongs on // the thing that does it rather than near it. let migrating = FakeShell { migrating: Some(Migrating { done: 40, total: 200, }), ..FakeShell::default() }; let screen = shown(&migrating); let pause = deep_acts(&screen) .into_iter() .find(|act| act.label == "Pause") .expect("Pause is offered"); assert!( pause .confirm .as_deref() .is_some_and(|asked| asked.contains("resumes the next time this vault opens")), "{:?}", pause.confirm ); showing(&migrating, Request::post("/storage/pause")).unwrap(); assert_eq!(*migrating.asked.borrow(), ["pause"]); // And pausing nothing is a refusal: the address is reachable by typing. let quiet = FakeShell::default(); assert!(showing(&quiet, Request::post("/storage/pause")).is_err()); } // --- the tag queue --- /// Nothing is queued, and nothing can be accepted. /// /// [`Idle`], [`NoImport`] and [`Unforged`]'s fourth peer. struct Unqueued; impl Queue for Unqueued { fn queued(&self) -> Option { None } fn read(&self, _at: usize) {} fn tick(&self, _at: usize) {} fn tick_shown(&self, _ticked: bool) {} fn accept(&self, _scope: Scope) {} fn accept_confident(&self) {} fn dismiss(&self) {} fn rescan(&self) {} fn close(&self) {} } /// Nothing is filtering, and every axis is open. /// /// [`Unqueued`]'s peer, and the last of them. struct Unfiltered; impl Filters for Unfiltered { fn axes(&self) -> Vec { open_axes() } fn keys(&self) -> Keys { Keys { wanted: Vec::new(), compatible: false, } } fn tags(&self) -> Vec { Vec::new() } fn typing(&self) -> String { String::new() } fn matched(&self) -> usize { 0 } fn active(&self) -> bool { false } fn describes(&self) -> String { "Filters".to_owned() } fn narrow(&self, _key: &'static str, _lower: Option, _upper: Option) {} fn set_key_mode(&self, _compatible: bool) {} fn toggle_key(&self, _key: &str) {} fn clear_keys(&self) {} fn typed(&self, _text: &str) {} fn require(&self, _tag: &str) {} fn unrequire(&self, _tag: &str) {} fn clear_tags(&self) {} fn clear_all(&self) {} fn save_collection(&self, _name: &str) {} } /// The six axes with neither end wanted. /// /// The shipped geometry table read rather than a second one written, which is /// what `FromFilters::table` does and is the point of the axes being `pub`. fn open_axes() -> Vec { use crate::quasi::filters as axes; [ ("bpm", &axes::BPM), ("duration", &axes::DURATION), ("loudness", &axes::LOUDNESS), ("brightness", &axes::BRIGHTNESS), ("noisiness", &axes::NOISINESS), ("attack", &axes::ATTACK), ] .into_iter() .map(|(key, axis)| Narrowing { key, axis, lower: None, upper: None, }) .collect() } /// Filters in memory, recording what was asked of them. struct FakeFilters { axes: RefCell>, keys: RefCell, tags: RefCell>, typing: RefCell, asked: RefCell>, } impl Default for FakeFilters { fn default() -> Self { Self { axes: RefCell::new(open_axes()), keys: RefCell::new(Keys { wanted: Vec::new(), compatible: false, }), tags: RefCell::new(Vec::new()), typing: RefCell::new(String::new()), asked: RefCell::new(Vec::new()), } } } impl FakeFilters { fn say(&self, said: impl Into) { self.asked.borrow_mut().push(said.into()); } fn asked(&self) -> Vec { self.asked.borrow().clone() } /// Narrow an axis up front, as a screen being re-read would find it. fn holding(self, key: &str, lower: Option, upper: Option) -> Self { for axis in self.axes.borrow_mut().iter_mut() { if axis.key == key { axis.lower = lower; axis.upper = upper; } } self } } impl Filters for FakeFilters { fn axes(&self) -> Vec { self.axes.borrow().clone() } fn keys(&self) -> Keys { self.keys.borrow().clone() } fn tags(&self) -> Vec { self.tags.borrow().clone() } fn typing(&self) -> String { self.typing.borrow().clone() } fn matched(&self) -> usize { 7 } fn active(&self) -> bool { self.axes .borrow() .iter() .any(|axis| axis.lower.is_some() || axis.upper.is_some()) || !self.tags.borrow().is_empty() || !self.keys.borrow().wanted.is_empty() } fn describes(&self) -> String { "BPM 90-130".to_owned() } fn narrow(&self, key: &'static str, lower: Option, upper: Option) { self.say(format!("narrow:{key}={lower:?}..{upper:?}")); } fn set_key_mode(&self, compatible: bool) { self.say(format!("mode:compatible={compatible}")); } fn toggle_key(&self, key: &str) { self.say(format!("key:{key}")); } fn clear_keys(&self) { self.say("keys:clear"); } fn typed(&self, text: &str) { self.say(format!("typing:{text}")); *self.typing.borrow_mut() = text.to_owned(); } fn require(&self, tag: &str) { self.say(format!("require:{tag}")); self.tags.borrow_mut().push(tag.to_owned()); } fn unrequire(&self, tag: &str) { self.say(format!("unrequire:{tag}")); } fn clear_tags(&self) { self.say("tags:clear"); } fn clear_all(&self) { self.say("clear"); } fn save_collection(&self, name: &str) { self.say(format!("save:{name}")); } } /// A router call against these filters. fn filtering( filters: &FakeFilters, request: Request, ) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, config: &store, sync: &sync, files: &files, export: &Idle, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue: &Unqueued, filters, themes: &themes, }; router().handle(&state, request) } /// The screen the filter panel answers. fn filter_screen(filters: &FakeFilters) -> Screen { screen_of(&filtering(filters, Request::get("/filters")).unwrap()).clone() } /// A queue in memory, recording what was asked of it. struct FakeQueue { queued: Option, asked: RefCell>, } impl FakeQueue { fn with(queued: Queued) -> Self { Self { queued: Some(queued), asked: RefCell::new(Vec::new()), } } fn empty() -> Self { Self { queued: None, asked: RefCell::new(Vec::new()), } } fn asked(&self) -> Vec { self.asked.borrow().clone() } fn say(&self, said: impl Into) { self.asked.borrow_mut().push(said.into()); } } impl Queue for FakeQueue { fn queued(&self) -> Option { self.queued.clone() } fn read(&self, at: usize) { self.say(format!("read:{at}")); } fn tick(&self, at: usize) { self.say(format!("tick:{at}")); } fn tick_shown(&self, ticked: bool) { self.say(format!("tick:shown={ticked}")); } fn accept(&self, scope: Scope) { self.say(format!("accept:{}", scope.as_str())); } fn accept_confident(&self) { self.say("accept:everywhere"); } fn dismiss(&self) { self.say("dismiss"); } fn rescan(&self) { self.say("rescan"); } fn close(&self) { self.say("close"); } } /// One tag with something waiting under it. fn group(tag: &str, candidates: usize, confident: usize, checked: usize) -> Group { Group { tag: tag.to_owned(), candidates, confident, checked, } } /// One candidate for the open tag. fn candidate(name: &str, score: f64, confident: bool, accepted: bool) -> Candidate { Candidate { name: name.to_owned(), score, confident, accepted, } } /// A queue with two tags, the first one open. fn queued() -> Queued { Queued { groups: vec![group("drums/kick", 340, 120, 0), group("texture", 12, 0, 0)], at: 0, considered: 4_000, suggested: 352, confident: 120, rescanning: false, said: None, shown: vec![ candidate("kick_01.wav", 0.94, true, false), candidate("kick_02.wav", 0.61, false, false), ], } } /// A router call against this queue. fn queueing(queue: &FakeQueue, request: Request) -> Result { let store = Store::default(); let sync = Offline; let files = FakeFiles::default(); let themes = themes(); let state = Panels { config: &store, sync: &sync, files: &files, export: &Idle, detail: &Unfocused, bulk: &Unchosen, shell: &Quiet, library: &Empty, bar: &Still, naming: &Unnamed, importing: &NoImport, integrity: &Sound, editor: &Unedited, forge: &Unforged, queue, filters: &Unfiltered, themes: &themes, }; router().handle(&state, request) } /// The review screen, with whatever is queued. fn queue_screen(queue: &FakeQueue) -> Screen { screen_of(&queueing(queue, Request::get("/review")).unwrap()).clone() } #[test] fn an_empty_queue_is_a_refusal_because_a_route_cannot_leave() { // The shipped screen closes itself rather than drawing an empty shell, so // "I finished" and "there was never anything" do not look the same. A route // answers what is at an address, and this address stopped being a place. let queue = FakeQueue::empty(); assert!(queueing(&queue, Request::get("/review")).is_err()); for address in [ "/review/accept/all", "/review/accept-confident", "/review/dismiss", "/review/rescan", "/review/close", ] { assert!( queueing(&queue, Request::post(address)).is_err(), "{address}" ); } } #[test] fn the_promise_the_screen_rests_on_is_stated_on_the_screen() { let queue = FakeQueue::with(queued()); let said = deep_said(&queue_screen(&queue)); assert!( said.contains("Nothing is applied until you accept it."), "{said}" ); // And what the pass actually found, so the queue-wide button is a decision // made with the count in view. assert!(said.contains("352 suggestions across 2 tags"), "{said}"); assert!(said.contains("352 of 4000 samples"), "{said}"); } #[test] fn the_tag_is_the_unit_of_navigation_and_the_open_one_says_so() { // The design the shipped header argues for: 340 rows one at a time is 340 // questions nobody finishes. let queue = FakeQueue::with(queued()); let screen = queue_screen(&queue); let tags = screen .slots .iter() .find(|slot| slot.id == "review-split") .expect("the screen is a split"); assert_eq!(tags.kind, quasi_router::RegionKind::Split); let rows = deep_rows(&screen); let open = rows .iter() .find(|row| row.primary() == "drums/kick") .expect("the open tag is listed"); assert!(open.current, "the open tag reads as open"); assert!( rows.iter() .find(|row| row.primary() == "texture") .is_some_and(|row| !row.current) ); queueing(&queue, Request::post("/review/1/read")).unwrap(); assert_eq!(queue.asked(), ["read:1"]); assert!(queueing(&queue, Request::post("/review/9/read")).is_err()); } #[test] fn the_three_accept_scopes_are_offered_only_where_they_mean_something() { // "Accept confident" beside "Accept all" when every candidate is confident // is a second button that does what the first one does, and "Accept 0 // checked" is a control that reports having done nothing. let queue = FakeQueue::with(queued()); let labels = deep_labels(&queue_screen(&queue)); assert!(labels.contains(&"Accept all 340".to_owned()), "{labels:?}"); assert!( labels.contains(&"Accept 120 confident".to_owned()), "{labels:?}" ); assert!( !labels.iter().any(|label| label.ends_with("checked")), "{labels:?}" ); let all_confident = FakeQueue::with(Queued { groups: vec![group("drums/kick", 340, 340, 3)], ..queued() }); let labels = deep_labels(&queue_screen(&all_confident)); assert!( !labels.contains(&"Accept 340 confident".to_owned()), "a subset of everything is not a subset: {labels:?}" ); assert!( labels.contains(&"Accept 3 checked".to_owned()), "{labels:?}" ); // And each address refuses an empty scope, so a control the reader could // still reach by typing is refused where the button was hidden. assert!(queueing(&queue, Request::post("/review/accept/checked")).is_err()); queueing(&queue, Request::post("/review/accept/confident")).unwrap(); assert_eq!(queue.asked(), ["accept:confident"]); assert!(queueing(&queue, Request::post("/review/accept/most")).is_err()); } #[test] fn the_window_over_a_group_is_a_fact_about_the_data() { // `files` refused to describe its own windowing because it holds every row. // This one does not: a name is a backend call each, so the rows past the // window were never resolved, and `Rest` is what says so. let queue = FakeQueue::with(queued()); let more = deep_nodes(&queue_screen(&queue)) .into_iter() .find_map(|node| match node { Node::List { more, .. } => more, _ => None, }) .expect("the candidate list says what it is not showing"); assert_eq!(more.paging.total(), Some(340)); // No way to widen it: the buttons act on the whole group, which is the // point of the cap. assert!(more.forward.is_none()); assert!(more.back.is_none()); // A group that fits says nothing, because there is nothing to say. let small = FakeQueue::with(Queued { groups: vec![group("texture", 2, 0, 0)], at: 0, ..queued() }); assert!( deep_nodes(&queue_screen(&small)) .into_iter() .all(|node| !matches!(node, Node::List { more: Some(_), .. })) ); } #[test] fn nothing_arrives_ticked_and_ticking_is_bounded_to_what_is_drawn() { // A screen opening with 340 boxes checked is auto-apply wearing a checkbox, // and ticking 44,000 invisible ones would make "Accept checked" silently // mean "accept everything". let queue = FakeQueue::with(queued()); let screen = queue_screen(&queue); let candidates: Vec = deep_rows(&screen) .into_iter() .filter(|row| row.selected.is_some()) .collect(); assert_eq!(candidates.len(), 2); assert!(candidates.iter().all(|row| row.selected == Some(false))); let labels = deep_labels(&screen); assert!(labels.contains(&"Check all shown".to_owned()), "{labels:?}"); // Uncheck appears only once something is ticked. assert!( !labels.contains(&"Uncheck all shown".to_owned()), "{labels:?}" ); queueing(&queue, Request::post("/review/rows/check")).unwrap(); assert_eq!(queue.asked(), ["tick:shown=true"]); assert!(queueing(&queue, Request::post("/review/rows/7/tick")).is_err()); } #[test] fn the_two_destructive_gestures_ask_on_the_controls_that_do_them() { let queue = FakeQueue::with(queued()); let screen = queue_screen(&queue); let dismiss = deep_acts(&screen) .into_iter() .find(|act| act.label == "Dismiss tag") .expect("Dismiss is offered"); assert_eq!(dismiss.tone, quasi_router::layout::Tone::Danger); assert!( dismiss .confirm .as_deref() .is_some_and(|asked| asked.contains("340 suggestions")), "{:?}", dismiss.confirm ); let everywhere = deep_acts(&screen) .into_iter() .find(|act| { act.label == "Accept 120 confident" && act.action.destination.as_str().contains("accept-confident") }) .expect("the queue-wide accept is offered"); assert!( everywhere .confirm .as_deref() .is_some_and(|asked| asked.contains("across every tag")), "{:?}", everywhere.confirm ); } #[test] fn rescanning_is_refused_while_a_pass_is_running() { let running = FakeQueue::with(Queued { rescanning: true, ..queued() }); let screen = queue_screen(&running); let again = deep_acts(&screen) .into_iter() .find(|act| act.label == "Rescan library") .expect("Rescan is offered"); assert!(!again.interactive()); assert!(deep_said(&screen).contains("A pass is running.")); assert!(queueing(&running, Request::post("/review/rescan")).is_err()); let idle = FakeQueue::with(queued()); queueing(&idle, Request::post("/review/rescan")).unwrap(); assert_eq!(idle.asked(), ["rescan"]); } #[test] fn what_the_last_accept_did_is_reported_rather_than_described() { let queue = FakeQueue::with(Queued { said: Some("Applied 120 tags.".to_owned()), ..queued() }); let notices: Vec = deep_nodes(&queue_screen(&queue)) .into_iter() .filter_map(|node| match node { Node::Notice { text, .. } => Some(text), _ => None, }) .collect(); assert_eq!(notices, ["Applied 120 tags."]); } // --------------------------------------------------------------------------- // The filter panel. The sixteenth port and the first consumer of // `FieldKind::Interval`, so what these cover is mostly the pair: that the six // axes are six questions rather than twelve, that both ends travel together, // and that the sentinel edges survive the round trip in both directions. // --------------------------------------------------------------------------- /// The intervals on the screen, by the name their lower end submits under. fn intervals( screen: &Screen, ) -> BTreeMap, Option, Option)> { let mut found = BTreeMap::new(); for slot in &screen.slots { for placed in &slot.body { let regions = match &placed.node { Node::Region(region) => std::slice::from_ref(region), _ => &[][..], }; for region in regions { for inner in ®ion.body { if let Node::Form { fields, .. } = &inner.node { for field in fields { if field.kind == quasi_router::layout::FieldKind::Interval { found.insert( field.name.clone(), ( field.upper_name.clone(), field.value.clone(), field.upper_value.clone(), ), ); } } } } } } } found } #[test] fn the_six_axes_are_six_questions_and_not_twelve() { // The whole reason this port waited for makeover-layout 0.34.0. Described // as `Number` pairs these are twelve fields with no relationship, and the // shipped panel's `range_filter_section` is the 55 lines that stood in for // the missing member. let filters = FakeFilters::default(); let screen = filter_screen(&filters); let axes = intervals(&screen); assert_eq!(axes.len(), 6, "{axes:?}"); for (lower, upper) in [ ("bpm_min", "bpm_max"), ("duration_min", "duration_max"), ("loudness_min", "loudness_max"), ("brightness_min", "brightness_max"), ("noisiness_min", "noisiness_max"), ("attack_min", "attack_max"), ] { let (named, _, _) = axes.get(lower).unwrap_or_else(|| panic!("{lower}")); assert_eq!(named.as_deref(), Some(upper), "{lower}"); } } #[test] fn an_axis_carries_the_shipped_geometry_rather_than_a_second_table() { // The six axes are a constant the shipped panel already reduced them to, // and a table here would drift the way the class filter's list and colour // table drifted before that reduction. let filters = FakeFilters::default(); let screen = filter_screen(&filters); let bpm = axis_field(&screen, "bpm_min"); assert_eq!(bpm.min.as_deref(), Some("0")); assert_eq!(bpm.max.as_deref(), Some("300")); assert_eq!(bpm.label, crate::quasi::filters::BPM.title); // Two of the six have no unit, and an empty suffix is not one. assert_eq!(bpm.unit, None); let loudness = axis_field(&screen, "loudness_min"); assert_eq!(loudness.min.as_deref(), Some("-96")); // The suffix carried a leading space because it was going into a DragValue. // `Field::unit` is the symbol alone. assert_eq!(loudness.unit.as_deref(), Some("dB")); let attack = axis_field(&screen, "attack_min"); assert_eq!(attack.unit.as_deref(), Some("s")); // Written at the axis's own precision, which is what the shipped control // was given as `decimals`. assert_eq!(attack.max.as_deref(), Some("1.000")); } /// One axis's field, by the name its lower end submits under. fn axis_field(screen: &Screen, name: &str) -> quasi_router::Field { for slot in &screen.slots { for placed in &slot.body { if let Node::Region(region) = &placed.node { for inner in ®ion.body { if let Node::Form { fields, .. } = &inner.node { for field in fields { if field.name == name { return field.clone(); } } } } } } } panic!("no axis named {name}"); } #[test] fn a_narrowed_axis_comes_back_with_both_ends_in_it() { let filters = FakeFilters::default().holding("bpm", Some(90.0), Some(130.0)); let screen = filter_screen(&filters); let axes = intervals(&screen); let (_, lower, upper) = axes.get("bpm_min").expect("the axis"); assert_eq!(lower.as_deref(), Some("90")); assert_eq!(upper.as_deref(), Some("130")); } #[test] fn an_open_end_is_an_empty_box_rather_than_the_edge() { // The sentinel mapping, read the other way. A stored `None` is no bound, // and a box holding the axis floor would read as a filter for "at least 0". let filters = FakeFilters::default().holding("bpm", Some(120.0), None); let screen = filter_screen(&filters); let axes = intervals(&screen); let (_, lower, upper) = axes.get("bpm_min").expect("the axis"); assert_eq!(lower.as_deref(), Some("120")); assert_eq!(*upper, None); } #[test] fn narrowing_sends_both_ends_and_maps_the_edges_back_to_no_bound() { // An interval is one answer: a handler taking only the end that moved would // drop the other bound every time either box was touched. And an end // sitting on its sentinel edge stores nothing, which is `range_bounds`'s // rule said once here. let filters = FakeFilters::default(); filtering( &filters, Request::post("/filters/axis/bpm").sending( Params::new() .with("bpm_min".to_owned(), "90".to_owned()) .with("bpm_max".to_owned(), "300".to_owned()), ), ) .expect("the route answered"); assert_eq!(filters.asked(), ["narrow:bpm=Some(90.0)..None"]); } #[test] fn a_crossed_interval_is_snapped_rather_than_refused() { // The shipped helper's sibling snap, which stays a write: the description // carries the extent and never checks it, and a description that could snap // could rewrite an answer without being asked. let filters = FakeFilters::default(); filtering( &filters, Request::post("/filters/axis/bpm").sending( Params::new() .with("bpm_min".to_owned(), "140".to_owned()) .with("bpm_max".to_owned(), "90".to_owned()), ), ) .expect("the route answered"); assert_eq!(filters.asked(), ["narrow:bpm=Some(140.0)..Some(140.0)"]); } #[test] fn an_axis_nobody_has_touched_offers_no_clear() { // The shipped section's own gate: a clear on an untouched axis is a control // that does nothing and says the axis is doing something. let open = FakeFilters::default(); let narrowed = FakeFilters::default().holding("bpm", Some(90.0), None); assert!(!filter_acts(&filter_screen(&open)).contains(&"/filters/axis/bpm/clear".to_owned())); assert!(filter_acts(&filter_screen(&narrowed)).contains(&"/filters/axis/bpm/clear".to_owned())); } /// Every address the screen's acts call, regions included. fn filter_acts(screen: &Screen) -> Vec { let mut found = Vec::new(); let mut visit = |node: &Node| { if let Node::Act(act) = node && let quasi_router::Destination::Route(path) = &act.action.destination { found.push(path.clone()); } }; for slot in &screen.slots { for placed in &slot.body { visit(&placed.node); if let Node::Region(region) = &placed.node { for inner in ®ion.body { visit(&inner.node); } } } } found } #[test] fn a_tag_that_is_not_one_is_refused_onto_the_field_it_was_typed_into() { // The shipped panel writes the complaint to the status line, which is the // affordance this vocabulary keeps moving messages off. let filters = FakeFilters::default(); let refused = filtering( &filters, Request::post("/filters/tags/add") .sending(Params::new().with("tag".to_owned(), "not a tag!".to_owned())), ); assert!(refused.is_err(), "{refused:?}"); assert!(filters.asked().is_empty(), "{:?}", filters.asked()); } #[test] fn adding_a_tag_empties_the_box_on_the_way_through() { // The half a bare `require` would lose, and it is the shipped path's own // behaviour rather than a nicety added here. let filters = FakeFilters::default(); filtering( &filters, Request::post("/filters/tags/add") .sending(Params::new().with("tag".to_owned(), " kick ".to_owned())), ) .expect("the route answered"); assert_eq!(filters.asked(), ["require:kick", "typing:"]); } #[test] fn saving_with_no_name_takes_the_one_the_ghost_text_offered() { // An empty name is not a refusal: the box's ghost text *is* the name it // would take, which is the shipped control's own arrangement. let filters = FakeFilters::default().holding("bpm", Some(90.0), Some(130.0)); filtering( &filters, Request::post("/filters/save") .sending(Params::new().with("name".to_owned(), " ".to_owned())), ) .expect("the route answered"); assert_eq!(filters.asked(), ["save:BPM 90-130"]); } #[test] fn nothing_is_saved_or_cleared_while_nothing_is_filtering() { let filters = FakeFilters::default(); assert!(filtering(&filters, Request::post("/filters/save")).is_err()); assert!(filtering(&filters, Request::post("/filters/clear")).is_err()); assert!(filters.asked().is_empty(), "{:?}", filters.asked()); } #[test] fn a_key_the_library_does_not_spell_is_refused() { let filters = FakeFilters::default(); assert!(filtering(&filters, Request::post("/filters/keys/H%20major/toggle")).is_err()); filtering(&filters, Request::post("/filters/keys/C# minor/toggle")) .expect("the route answered"); assert_eq!(filters.asked(), ["key:C# minor"]); }