//! Does the described screen offer what the shipped one offers? //! //! Every flip in the audiofiles set replaces a hand-written egui panel with a //! described screen. Without this file the only evidence that the replacement //! matches is that both were written from the same intent, which is not //! evidence. mnw-server built the equivalent (`tests/harness/parity.rs`) and it //! is what made that flip set startable: a flip with a parity harness behind it //! is a mechanical change, and one without it is a rewrite nobody can check. //! //! # What equivalence means here, and why it is not pixels //! //! The described half renders through `quasi-immediate` and the shipped half //! through hand-written egui. They do not look identical and are not supposed //! to: choosing the layout is the renderer's job and the whole reason the //! description stops short of one. //! //! What has to agree is what the screen **offers** -- the same controls, saying //! the same words, dead in the same states. That is a set of [`Offer`]s, and //! both sides are reduced to one: //! //! - The described side by walking the [`Screen`] the router answered. //! - The shipped side by drawing the panel into a headless [`egui::Context`] //! with AccessKit on, and reading the tree egui built for a screen reader. //! egui fills that tree from the same [`egui::WidgetInfo`] every widget //! already reports, so this asks the panel what it drew rather than parsing //! pixels or duplicating its logic. //! //! # What an offer is, and what it deliberately drops //! //! A [`Role`] and a label, plus whether the control is dead. Position is not //! compared: the two renderers order a screen differently by design, so offers //! are compared as a sorted multiset. //! //! Prose is dropped. A described screen says what it says through //! `Node::Text`, and the shipped panel scatters the same sentences through //! `ui.label` calls that AccessKit reports as `Role::Label` -- comparing them //! would fail on every line break either side chose. What a screen *says* is //! already asserted by `tests::said`; what it *offers* is this file's question. //! //! Addresses are the described side's alone, because egui has none: a shipped //! control calls a closure, and the whole point of the flip is that a described //! one names a route instead. So they are not compared across the two sides. //! They are checked *within* the described side by //! [`Offering::addresses_resolve`], which is the other half of the same claim: //! every act the screen offers reaches a route the router actually has. //! //! # Two jobs, and the second outlives the first //! //! Before a flip, a test here compares the described screen against the shipped //! panel it is about to replace. That test dies with the module it compared //! against, which is correct: there is nothing left to compare. //! //! After a flip, a test here compares the described screen against **what the //! host actually drew for it**, which is the same reader pointed at //! `panel::draw_*` instead of at `ui::*`. That one is permanent, and it is the //! guard the first flip needed and did not have: `panel::window` accepted a //! home address answering `Outcome::Screen` and not `Outcome::Over`, so the //! flipped loose-files warning drew the outcome's `Debug` rendering. Everything //! compiled, every other test passed, and the screen was a wall of Rust. //! //! # The allowances //! //! A flip is allowed to change what a screen offers, and where it does, the //! call site names the change rather than the harness ignoring a class of //! difference blanket. That is [`Parity::dropping`] and [`Parity::gaining`]: each one at a //! call site is a claim somebody wrote down. use std::collections::BTreeMap; use std::fmt::Write as _; use quasi_router::{Node, Screen, layout}; /// What kind of control an offer is. /// /// Deliberately coarser than either side's own vocabulary. egui reports a /// `SelectableLabel` and a `Button` as the same AccessKit role, and the /// description says `Act` for both, so a finer split would be a difference /// neither side chose. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum Role { /// Something you press. Button, /// Something you type into. Text, /// Something you tick. Check, /// Something you pick one of. Choice, /// Something you drag to a number. Number, } impl Role { const fn show(self) -> &'static str { match self { Self::Button => "button", Self::Text => "text", Self::Check => "check", Self::Choice => "choice", Self::Number => "number", } } } /// One thing a screen offers. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub(super) struct Offer { /// What kind of control it is. pub(super) role: Role, /// What it says. pub(super) label: String, /// Whether it is present and not answering. pub(super) dead: bool, } impl Offer { fn show(&self) -> String { let dead = if self.dead { " (dead)" } else { "" }; format!("{} {:?}{dead}", self.role.show(), self.label) } } /// Everything a screen offers, as a multiset. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(super) struct Offering { offers: Vec, /// The routes the described side's controls name. Empty for a shipped /// screen, which has no addresses to name. addresses: Vec, } impl Offering { fn push(&mut self, role: Role, label: impl Into, dead: bool) { let label = label.into(); // A control with nothing to say is not an offer anyone can act on, and // both sides produce them: egui reports a spacer, and the description // has icon-only acts whose label is empty by design. if label.trim().is_empty() { return; } self.offers.push(Offer { role, label, dead }); } /// The offers, sorted, so position stops being a difference. fn sorted(&self) -> Vec { let mut offers = self.offers.clone(); offers.sort(); offers } /// Assert every address this screen names is a route the router has. /// /// The other half of "they agree on their addresses". An address is the /// described side's alone -- a shipped control calls a closure and has /// none -- so it cannot be compared across the two. What can be checked is /// that it is real: a described act naming a route nobody registered is a /// dead control that a rendering test would never notice, because the /// screen draws perfectly and does nothing when pressed. /// /// Matched segment-wise against the router's own patterns rather than by /// asking the router, because `Path::match_path` is crate-private and the /// public alternative is `handle`, which would run the handler. Pressing /// things is not what a parity read does. fn addresses_resolve(&self) { let router = super::router(); let patterns: Vec = router.routes().map(|(_, path)| path.to_owned()).collect(); for address in &self.addresses { assert!( patterns.iter().any(|pattern| matches(pattern, address)), "the screen offers a control addressed {address:?}, \ and the router has no route that answers it" ); } } /// A one-per-line rendering, for a failure message. fn show(&self) -> String { let mut out = String::new(); for offer in self.sorted() { let _ = writeln!(out, " {}", offer.show()); } out } } /// Reduce a described screen to what it offers. /// /// Walks every node, descending into regions, forms, tables, lists and cells, /// because a control inside a row is as much of an offer as one in the header. pub(super) fn described(screen: &Screen) -> Offering { let mut out = Offering::default(); for slot in &screen.slots { for placed in &slot.body { walk(&placed.node, &mut out); } } out } /// What one field offers, which is not always one control. /// /// A `Radio` is drawn as one control per option by every renderer, so it offers /// as many things as it has options and each is named by its own label. Every /// other kind is a single control named by the field's question -- including /// `Select`, which is a box you open rather than a set of controls, so its /// options are not on screen until you do. fn field_offers(field: &quasi_router::Field, out: &mut Offering) { if field.kind == layout::FieldKind::Radio { for choice in &field.options { out.push(Role::Choice, choice.label.clone(), false); } return; } // An interval is one question with two ends, and both ends are controls. // makeover-immediate names each of them by the question, which is the // right answer -- "BPM Range" twice reads correctly to a screen reader // stepping through them -- so the offering has two. let ends = if field.kind == layout::FieldKind::Interval { 2 } else { 1 }; for _ in 0..ends { out.push(field_role(field.kind), field.label.clone(), false); } } /// The role a field of this kind is drawn as. fn field_role(kind: layout::FieldKind) -> Role { use layout::FieldKind as K; match kind { K::Checkbox => Role::Check, K::Select | K::Radio => Role::Choice, // A slider and an interval's two ends. A bare `Number` is not here: // `makeover_immediate::control_shape` sends everything it cannot draw // natively to `Control::Typed`, so a number is a well you type into and // reaches the tree as a text input. That is the renderer's answer and a // true report of the value, so the reader follows it rather than // insisting on the kind. K::Range | K::Interval => Role::Number, // Everything else is a box you type into. `File` is the one stretch and // it is the honest answer: a host draws it as a control that opens a // picker, which is a button on some hosts and a path box on others, and // guessing which would be this file deciding a renderer's question. _ => Role::Text, } } fn walk(node: &Node, out: &mut Offering) { match node { Node::Act(act) => { out.push( Role::Button, act.label.clone(), act.state == Some(layout::State::Disabled), ); out.addresses .push(act.action.destination.as_str().to_owned()); // An act that asks for something before it fires carries its own // fields, and those are as much of what the screen offers as a // field standing on its own. for field in &act.asks { field_offers(field, out); } } Node::Link { text, action } => { out.push(Role::Button, text.clone(), false); out.addresses.push(action.destination.as_str().to_owned()); } Node::Field(field) => field_offers(field, out), Node::Form { submit, action, fields, } => { out.push(Role::Button, submit.clone(), false); out.addresses.push(action.destination.as_str().to_owned()); for field in fields { field_offers(field, out); } } Node::Select { options, action, .. } => { // A segmented control is one choice with several labels on the // shipped side too, so each option is an offer rather than the // strip being one. // // A `Button` and not a `Choice`, which reads backwards until you // ask what a renderer draws: every host draws a `Node::Select` as a // strip of pressable segments, and egui reports a selectable label // as `Role::Button` with nothing to distinguish it from an ordinary // one. `Choice` stays for `FieldKind::Select` and `Radio`, which are // a box you open and a set of radios -- genuinely different things // to operate. Corrected 2026-08-22, when the forge's eight slice // options came back as buttons from the renderer and choices from // here. for (choice, own) in options { out.push(Role::Button, choice.label.clone(), false); if let Some(action) = own.as_ref().or(action.as_ref()) { out.addresses.push(action.destination.as_str().to_owned()); } } } Node::Table { columns, rows, .. } => { for column in columns { // A heading with no address is a heading. Only a sortable one // is something you can press. if let Some(reorder) = &column.reorder { out.push(Role::Button, column.name.clone(), false); out.addresses.push(reorder.destination.as_str().to_owned()); } } for cells in rows { if let Some(activate) = &cells.activate { out.push(Role::Button, first_words(&cells.values), false); out.addresses.push(activate.destination.as_str().to_owned()); } for cell in &cells.values { for part in &cell.parts { walk(part, out); } } } } Node::List { rows, .. } => { for row in rows { // A row is claimed when it opens OR when it offers a menu, // which is `quasi_immediate::node::row`'s own condition: a row // that only offers a menu still needs somewhere to right-click, // and it is announced by its first text either way. The menu's // own acts are not offers here -- they are not on screen until // the gesture -- but the row that carries them is. if row.activate.is_some() || !row.menu.is_empty() { let named: Vec<_> = row.parts.iter().map(|part| part.node.clone()).collect(); out.push(Role::Button, first_text(&named), false); } if let Some(activate) = &row.activate { out.addresses.push(activate.destination.as_str().to_owned()); } for part in &row.parts { walk(&part.node, out); } } } Node::Region(slot) => { for placed in &slot.body { walk(&placed.node, out); } } Node::Stats { figures } => { for (figure, action) in figures { if let Some(action) = action { out.push(Role::Button, figure.caption.clone(), false); out.addresses.push(action.destination.as_str().to_owned()); } } } // A stand-in's way out. Two of goingson's twenty-seven have one, which // is why `act` is optional, and audiofiles' idle import screen is // another: "Nothing is being imported" with an Import... beside it. The // sentence is prose and the act is a control, so only the second is an // offer. Node::StandIn { act: Some(act), .. } => walk(&Node::Act(act.clone()), out), // A token that calls a route is a control drawn as a chip -- the filter // panel's twenty-four key pills are the site -- and one that calls // nothing is a badge. `Tag::action` is the whole of the difference, so // it is what decides here rather than the kind. Node::Token(tag) => { if let Some(action) = &tag.action { out.push(Role::Button, tag.label.clone(), false); out.addresses.push(action.destination.as_str().to_owned()); } } // Prose, figures, images, meters, timelines and stand-ins are things a // screen says rather than things it offers. See the header. _ => {} } } /// Whether a concrete address is what this route pattern describes. /// /// Segment counts must agree and each segment must match, with a `{name}` /// segment matching anything. A trailing query is dropped first: it carries /// parameters, not a route. fn matches(pattern: &str, address: &str) -> bool { let address = address.split('?').next().unwrap_or(address); let pattern: Vec<&str> = pattern.trim_matches('/').split('/').collect(); let address: Vec<&str> = address.trim_matches('/').split('/').collect(); pattern.len() == address.len() && pattern .iter() .zip(&address) .all(|(want, got)| want.starts_with('{') || want == got) } /// What a row is called: the first words in it. /// /// A row's press has no label of its own on either side. The shipped panel /// announces the row by its first column, because that is the cell it senses /// the click on, and a described row names the same text in the same place, so /// this reads it from there rather than inventing a name for the press. fn first_words(cells: &[quasi_router::Cell]) -> String { cells .iter() .find_map(|cell| { let said = first_text(&cell.parts); (!said.is_empty()).then_some(said) }) .unwrap_or_default() } /// The first thing a run of leaves says. fn first_text(parts: &[Node]) -> String { parts .iter() .find_map(|part| match part { Node::Text { text, .. } | Node::Heading { text, .. } | Node::Link { text, .. } => { Some(text.clone()) } _ => None, }) .unwrap_or_default() } /// Reduce a shipped egui panel to what it offers. /// /// Draws `paint` into a headless context with AccessKit on and reads the tree /// egui built. The closure is handed the root [`egui::Ui`], which is what the /// panels take; a screen that opens a window instead reaches the context /// through `ui.ctx()`, the same way the app does. /// /// A control egui reports with no label is dropped by [`Offering::push`]: a /// separator, a spacer, the panel background. What survives is what a screen /// reader would announce, which is the same set a user can act on. pub(super) fn shipped(mut paint: impl FnMut(&mut egui::Ui)) -> Offering { let ctx = egui::Context::default(); ctx.enable_accesskit(); // Selectable labels off, and it is load-bearing rather than cosmetic. With // them on -- egui's default -- every `ui.label` senses a click so its text // can be dragged over, and the tree says a paragraph of prose answers a // press exactly as a sortable heading does. Selecting text is not something // a screen offers, and turning it off is what leaves the click sense // meaning what `role_of` reads it as meaning. for theme in [egui::Theme::Light, egui::Theme::Dark] { ctx.style_mut_of(theme, |style| { style.interaction.selectable_labels = false; // No animation, so a section that has been opened is open on the // next pass rather than a fraction of the way there. Openness is // animated, the harness runs its passes at one instant, and a // section caught mid-open draws none of its contents -- which reads // as a screen offering nothing. style.animation_time = 0.0; }); } // A real size, because a panel that lays out into a zero-width viewport // drops columns and would look like a screen offering less than it does. let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(1440.0, 900.0), )), ..Default::default() }; // Two passes, and the second is the one that is read. egui lays out against // the previous frame, so a first pass sees widgets at the wrong rect and // misses anything whose existence depends on a measurement taken last // frame. A window is the sharp case: it has no size until it has been // drawn once. let _ = ctx.run_ui(input(), &mut paint); let output = ctx.run_ui(input(), &mut paint); let mut out = Offering::default(); let Some(update) = output.platform_output.accesskit_update else { panic!("accesskit produced no tree: the panel drew nothing at all"); }; let by_id: std::collections::HashMap<_, _> = update.nodes.iter().cloned().collect(); for (_, node) in &update.nodes { let Some(role) = role_of(node) else { continue; }; let said = named(node, &by_id); out.push(role, undecorated(&said), node.is_disabled()); } out } /// What a control is called, the way a client works it out. /// /// egui puts a label's own text in `value` and every other widget's in `label`, /// because a `Role::Label` IS its text. A control named by a *separate* label /// has neither: it carries a `labelled_by` relation naming the node that says /// it, which is what `Response::labelled_by` sets and what /// `makeover_immediate::field` uses so a box is announced by its question /// rather than by its own contents. Following the relation is not a /// convenience here -- a reader that stopped at `label()` would report every /// properly-labelled field as nameless, which is the opposite of the truth. fn named( node: &egui::accesskit::Node, by_id: &std::collections::HashMap, ) -> String { if let Some(label) = node.label() { return label.to_owned(); } if let Some(said) = node .labelled_by() .iter() .find_map(|id| by_id.get(id)) .and_then(|by| by.label().or_else(|| by.value())) { return said.to_owned(); } node.value().unwrap_or_default().to_owned() } /// A rendered label with the renderer's own decoration taken back off. /// /// A sorted column heading is drawn as its name plus a caret, because a glyph /// beside the word is how a table says which column is in force. The /// description says the same thing structurally, as `Column::sorted`, and /// carries no caret in the name. Stripping it is therefore normalization /// rather than an allowance: the fact is on both sides, said two ways. /// /// The glyphs come from `layout::Sort::glyph`, which is where their spelling /// lives, so a renderer that changes its caret does not quietly break this. fn undecorated(said: &str) -> String { let mut said = said.trim(); for direction in [layout::Sort::Ascending, layout::Sort::Descending] { if let Some(stripped) = said.strip_suffix(direction.glyph()) { said = stripped.trim_end(); } } // A required field is drawn with a marker after its label. `Field::required` // is what the description says and `FieldStyle::required_marker` is how this // renderer shows it, so the asterisk is the same fact in the renderer's // spelling -- the caret's case again. if let Some(stripped) = said.strip_suffix('*') { said = stripped.trim_end(); } // An act carrying a key is drawn with it, as `label (key)`. The // description says the key on the act instead, so this is the same // normalization the caret gets: one fact, said two ways. // // Matched on the renderer's exact separator, two spaces, rather than on any // trailing parenthetical. A label can legitimately end in one -- the theme // picker shows "System (audiofiles)" -- and a looser rule silently ate it. if said.ends_with(')') && let Some((label, _)) = said.rsplit_once(" (") { said = label.trim_end(); } said.to_owned() } /// The role an AccessKit node maps to, or `None` if it is not a control. /// /// The role alone is not enough, and a sortable column heading is why. egui /// draws one as an `egui::Label` that senses a click, so the role that reaches /// the tree is `Label` -- a screen reader announces static text where a user can /// press to reorder the table. What decides here is therefore whether the node /// answers a click, which egui records faithfully from the widget's own /// `Sense`. The description says the same thing by giving the column a /// `reorder` address, so the two agree. /// /// That the announcement is wrong is a real finding about the renderer rather /// than about either screen, and it is filed rather than worked around here: /// this reads the sense because the sense is the honest signal, not to paper /// over the role. fn role_of(node: &egui::accesskit::Node) -> Option { use egui::accesskit::{Action, Role as R}; match node.role() { R::Button | R::Link => Some(Role::Button), R::TextInput | R::MultilineTextInput => Some(Role::Text), R::CheckBox | R::Switch => Some(Role::Check), R::RadioButton | R::ComboBox | R::ListBox => Some(Role::Choice), R::Slider | R::SpinButton => Some(Role::Number), // Anything else that answers a press is a control whatever it is // announced as. Anything else that does not is prose, an image, a // scrollbar, a pane: things a screen has rather than things it offers. _ if node.supports_action(Action::Click) => Some(Role::Button), _ => None, } } /// How a described screen is allowed to differ from the one it replaces. /// /// Every allowance is named at the call site, so the list on a test is the /// record of what that flip changed. There is deliberately no "ignore whatever /// differs" option: an unexplained difference is the thing this file exists to /// find. /// /// One allowance recurs and is worth knowing before it surprises you: a shipped /// modal drawn in an `egui::Window` announces a button carrying the window's own /// name, because that is its title bar's collapsing control. It is chrome, in /// the same class as a `CollapsingHeader`, and a modal test drops it by name. #[derive(Debug, Clone, Default)] pub(super) struct Parity { dropped: Vec, gained: Vec, } impl Parity { /// Strict: every difference fails. pub(super) fn strict() -> Self { Self::default() } /// A control the shipped screen had and the described one does not. /// /// For chrome the description deliberately refuses -- the ten-variant /// confirm dialog, a panel's own close button -- where the flip's claim is /// that the thing is the host's rather than the screen's. #[must_use] pub(super) fn dropping(mut self, label: &str) -> Self { self.dropped.push(label.to_owned()); self } /// A control the described screen has and the shipped one did not. /// /// For what a port fixed on the way through: a dead-end the shipped panel /// left the user in, an act that was only reachable by a keyboard shortcut. #[must_use] pub(super) fn gaining(mut self, label: &str) -> Self { self.gained.push(label.to_owned()); self } /// The `egui::Window` a described screen is drawn in, which is chrome. /// /// Three controls that belong to the frame rather than to the screen: the /// title bar's collapsing control, which carries the window's own name; /// egui's "Hide" for the same collapse; and "Close window" for the X. The /// description names the screen in `Screen::title` and leaves the frame to /// the host, which is the arrangement, so none of the three has a /// counterpart to compare against. #[must_use] pub(super) fn in_a_window(self, title: &str) -> Self { self.dropping(title) .dropping("Hide") .dropping("Close window") } /// The numeric readout egui draws inside a slider. /// /// A `Slider` is two widgets: the track, which `makeover_immediate::field` /// names from the question, and a `DragValue` showing the number, which /// egui builds inside and never hands back. So a described `Range` reaches /// the tree as one named control and one unnamed number. /// /// Not a defect to chase: the readout is a second view of a value the /// question already names, and the alternative is `show_value(false)`, /// which takes the number off the screen. Named here so it is a claim /// rather than a silence. #[must_use] pub(super) fn slider_readouts(mut self, shown: &[&str]) -> Self { for value in shown { self = self.dropping(value); } self } /// Assert the two sides offer the same thing, panicking with a diff if not. pub(super) fn assert(&self, described: &Offering, shipped: &Offering) { let mut want: BTreeMap = BTreeMap::new(); for offer in shipped.sorted() { if self.dropped.contains(&offer.label) { continue; } *want.entry(offer).or_default() += 1; } for offer in described.sorted() { if self.gained.contains(&offer.label) { continue; } *want.entry(offer).or_default() -= 1; } let mut missing = Vec::new(); let mut extra = Vec::new(); for (offer, count) in want { for _ in 0..count.max(0) { missing.push(offer.show()); } for _ in 0..(-count).max(0) { extra.push(offer.show()); } } assert!( missing.is_empty() && extra.is_empty(), "the described screen does not offer what the shipped one offers.\n\ \nthe shipped screen offers and the described one does not:\n{}\ \nthe described screen offers and the shipped one does not:\n{}\ \nall of the shipped screen's offers:\n{}\ \nall of the described screen's offers:\n{}", show_all(&missing), show_all(&extra), shipped.show(), described.show(), ); } } fn show_all(lines: &[String]) -> String { if lines.is_empty() { return " (none)\n".to_owned(); } let mut out = String::new(); for line in lines { let _ = writeln!(out, " {line}"); } out } /// A real app, with a few samples in it. /// /// A `BrowserState` on a temporary directory, which is what `state::tests` /// already uses: the shipped panels read one and the described screens read the /// app's own adapters over the same one, so the two sides genuinely share a /// fixture rather than agreeing about two. fn fixture() -> (crate::state::BrowserState, tempfile::TempDir) { use std::sync::Arc; let dir = tempfile::TempDir::new().unwrap(); let shared = Arc::new(crate::state::SharedState::new()); let mut state = crate::state::BrowserState::new(dir.path(), shared, 44_100.0, "Vault").unwrap(); let vfs = state.current_vfs_id().unwrap(); let parent = state.nav.current_dir; let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap(); for (hash, name) in [("aaa111", "kick.wav"), ("bbb222", "snare.wav")] { db.conn() .execute( "INSERT OR IGNORE INTO samples \ (hash, original_name, file_extension, file_size, import_date, last_modified) \ VALUES (?1, ?2, 'wav', 100, 0, 0)", rusqlite::params![hash, format!("{hash}.wav")], ) .unwrap(); state .backend .create_sample_link(vfs, parent, name, hash) .unwrap(); } state.refresh_contents(); (state, dir) } #[test] fn the_file_list_offers_what_the_shipped_one_offers() { let (mut state, _dir) = fixture(); let described = described(&super::panel::described_screen(&state, "/files")); let shipped = shipped(|ui| { crate::ui::file_list::draw_file_list(ui, &mut state, None); }); described.addresses_resolve(); // The one difference the flip introduces, and it is not settled: the // shipped heading is "Dur" because the column is fixed-width and narrow, // and the description says "Duration" because `Column::name` is both the // heading and the key a cell is addressed by, so the abbreviation and the // sort key cannot come apart. Filed against audiofiles rather than decided // here. Whichever way it goes, one of these two lines goes with it. Parity::strict() .dropping("Dur") .gaining("Duration") .assert(&described, &shipped); } #[test] fn the_detail_panel_serves_what_it_describes() { let (mut state, _dir) = fixture(); // A selection, because the detail panel's subject is what is chosen and an // empty one is a different screen. state.nav.selection.set_single(0); let described = described(&super::panel::described_screen(&state, "/detail")); let drawn = shipped(|ui| { super::panel::draw_detail(ui, &mut state); }); described.addresses_resolve(); // A right pane rather than a window, which is what the shipped panel was. Parity::strict().assert(&described, &drawn); } #[test] fn settings_offers_what_the_four_described_sections_offer() { let (mut state, _dir) = fixture(); let described = described(&super::panel::described_screen(&state, "/settings")); // The four sections the description covers, drawn as bodies rather than // through `draw_settings_panel`. Two reasons, and both would make a // whole-window comparison meaningless rather than merely noisy. // // The description covers four of the panel's nine sections on purpose -- // storage, trash, license and the classifier are about this host's // filesystem and a licence server, and `settings.rs`'s header is where that // is argued. Comparing against the whole window would need five sections' // worth of allowances saying so a second time. // // And the panel's sections are collapsing, so a whole-window read sees nine // headings and the contents of whichever one is open. What is on screen // would then depend on fold state that neither side describes, and which // cannot be set from outside: a `CollapsingHeader` derives its id from the // `ui.vertical` it makes for itself. That is why each body is its own // function now. let shipped = shipped(|ui| { crate::ui::settings_panel::appearance_body(ui, &mut state); crate::ui::settings_panel::preview_body(ui, &mut state); crate::ui::settings_panel::forge_body(ui, &mut state); crate::ui::settings_panel::display_body(ui, &mut state); }); // What the shipped theme picker is announced as: its current value. The // combo carries no label of its own, so this reads it the way // `appearance_body` builds it rather than naming a theme here, which would // make the test depend on which theme a machine resolved. let themes = crate::ui::theme::list_themes(); let active = crate::ui::theme::active_id(); let active_name = themes .iter() .find(|theme| theme.id == active) .map_or(active.as_str(), |theme| theme.name.as_str()); let announced = match &state.theme_selection { crate::ui::theme::ThemeSelection::Follow => format!("System ({active_name})"), crate::ui::theme::ThemeSelection::Fixed(_) => active_name.to_owned(), }; described.addresses_resolve(); Parity::strict() // Two controls the shipped panel leaves unnamed, and the port names. // The theme combo is announced as whatever theme is picked, because the // word "Theme" is a separate label beside it; the row-height slider // draws no text at all, so a screen reader announces an unnamed slider. // Both are the description attaching a question to its control, which // is the same fix the detail panel's tag box gets. .dropping(&announced) .gaining("Theme") .gaining("Row height") .assert(&described, &shipped); } #[test] fn the_flipped_warning_serves_what_it_describes() { let (mut state, _dir) = fixture(); state.loose_files.loose_files_missing_count = 3; state.loose_files.show_loose_files_warning = true; let described = described(&super::panel::described_screen( &state, "/library/loose-files", )); let drawn = shipped(|ui| { super::panel::draw_integrity(ui.ctx(), &mut state); }); Parity::strict() .in_a_window("Loose-files mode warning") .assert(&described, &drawn); } /// The four name modals: what each is called, and where it is served from. /// /// A table rather than four tests, because they are one screen four times and /// the flip's claim is exactly that. #[test] fn the_four_name_modals_serve_what_they_describe() { type Show = fn(&mut crate::state::BrowserState); let modals: [(&str, &str, Show); 4] = [ ("New Vault", "/vaults/new", |state| { state.vfs_modal.show_vfs_create = true; }), ("Rename Vault", "/vaults/{id}/rename", |state| { let vault = state.nav.vfs_list[0].clone(); state.vfs_modal.vfs_rename_target = Some((vault.id, vault.name)); }), ("New Folder", "/folders/new", |state| { state.vfs_modal.show_dir_create = true; }), ("Rename", "/folders/{id}/rename", |state| { let folder = state.nav.contents[0].node.clone(); state.vfs_modal.dir_rename_target = Some((folder.id, folder.name)); }), ]; for (title, address, show) in modals { let (mut state, _dir) = fixture(); // A folder to rename, which the sample-only fixture does not have. let vault = state.current_vfs_id().unwrap(); let parent = state.nav.current_dir; state .backend .create_directory(vault, parent, "drums") .unwrap(); state.refresh_contents(); show(&mut state); let address = address.replace("{id}", &real_id(&state, address).to_string()); let described = described(&super::panel::described_screen(&state, &address)); let drawn = shipped(|ui| { super::panel::draw_naming(ui.ctx(), &mut state, title, &address); }); described.addresses_resolve(); Parity::strict() .in_a_window(title) .assert(&described, &drawn); } } /// The id the rename addresses need, read off the fixture. fn real_id(state: &crate::state::BrowserState, address: &str) -> i64 { if address.starts_with("/vaults/") { state.nav.vfs_list[0].id.as_i64() } else { state .nav .contents .iter() .map(|node| &node.node) .find(|node| node.sample_hash.is_none()) .expect("the fixture makes a folder") .id .as_i64() } } /// A review queue with one tag in it, for the screen that shows one. fn with_a_review_queue(state: &mut crate::state::BrowserState) { use crate::state::{ReviewCandidate, ReviewGroup, ReviewQueue}; state.classifier.review = Some(ReviewQueue { groups: vec![ReviewGroup { tag: "instrument.drum.kick".to_owned(), candidates: vec![ ReviewCandidate { hash: "aaa111".to_owned(), name: Some("kick.wav".to_owned()), score: 0.95, confident: true, accepted: false, }, ReviewCandidate { hash: "bbb222".to_owned(), name: Some("snare.wav".to_owned()), score: 0.42, confident: false, accepted: false, }, ], names_loaded: true, }], samples_considered: 2, samples_with_suggestions: 2, }); state.open_review_screen(); } #[test] fn the_tag_queue_serves_what_it_describes() { let (mut state, _dir) = fixture(); with_a_review_queue(&mut state); let described = described(&super::panel::described_screen(&state, "/review")); let drawn = shipped(|ui| { super::panel::draw_queue(ui, &mut state); }); described.addresses_resolve(); // No `in_a_window`: the queue is a full-screen mode drawn into the app's own // pane, which is what the shipped screen was, so there is no frame around it // to discount. Parity::strict().assert(&described, &drawn); } /// A sample open in the forge, which is what that screen is about. fn with_the_forge_open(state: &mut crate::state::BrowserState) { state.nav.selection.set_single(0); state.open_forge_window("aaa111"); } #[test] fn the_forge_serves_what_it_describes() { let (mut state, _dir) = fixture(); with_the_forge_open(&mut state); let described = described(&super::panel::described_screen(&state, "/forge")); let drawn = shipped(|ui| { super::panel::draw_forge(ui.ctx(), &mut state); }); described.addresses_resolve(); Parity::strict() .in_a_window("Sample Forge") .assert(&described, &drawn); } /// A sample open in the editor, which is what that screen is about. fn with_the_editor_open(state: &mut crate::state::BrowserState) { state.nav.selection.set_single(0); state.open_edit_window("aaa111"); } #[test] fn the_editor_serves_what_it_describes() { let (mut state, _dir) = fixture(); with_the_editor_open(&mut state); let described = described(&super::panel::described_screen(&state, "/edit")); let drawn = shipped(|ui| { super::panel::draw_edit(ui.ctx(), &mut state); }); described.addresses_resolve(); Parity::strict() .in_a_window("Sample Editor") .slider_readouts(&["-1.0", "0.0", "0.000", "1.000", "100"]) .assert(&described, &drawn); } /// Every filter narrowed, which is how the shipped pane opens its sections. /// /// `widgets::filter_section` is `default_open(active)`, so a pane read with /// nothing filtered offers eight headings and no controls. Setting a bound on /// each axis is the panel's own rule for showing them rather than a way round /// it, and it is also the state worth comparing: an empty filter panel is the /// one arrangement where neither side has much to say. fn with_every_filter_narrowed(state: &mut crate::state::BrowserState) { let f = &mut state.search.search_filter; f.bpm_min = Some(90.0); f.duration_min = Some(1.0); f.peak_db_min = Some(-12.0); f.centroid_min = Some(500.0); f.flatness_min = Some(0.2); f.attack_min = Some(5.0); f.keys.push("Am".to_owned()); f.required_tags.push("drums".to_owned()); state.search.filter_panel_open = true; } #[test] fn the_filter_panel_serves_what_it_describes() { let (mut state, _dir) = fixture(); with_every_filter_narrowed(&mut state); let described = described(&super::panel::described_screen(&state, "/filters")); let drawn = shipped(|ui| { super::panel::draw_filters(ui, &mut state); }); described.addresses_resolve(); // A left pane rather than a window, which is what the shipped panel was, so // there is no frame to discount. Parity::strict().assert(&described, &drawn); } /// Two samples chosen, which is what every bulk modal needs. fn with_two_chosen(state: &mut crate::state::BrowserState) { state.nav.selection.select_all(state.nav.contents.len()); } #[test] fn the_three_bulk_modals_serve_what_they_describe() { type Open = fn(&mut crate::state::BrowserState); let modals: [(&str, &str, Open); 3] = [ ("Bulk Tag", "/bulk/tag", |state| state.open_bulk_tag_modal()), ("Bulk Move", "/bulk/move", |state| { state.open_bulk_move_modal(); }), ("Bulk Rename", "/bulk/rename", |state| { state.open_bulk_rename_modal(); }), ]; for (title, address, open) in modals { let (mut state, _dir) = fixture(); with_two_chosen(&mut state); open(&mut state); let described = described(&super::panel::described_screen(&state, address)); let drawn = shipped(|ui| { super::panel::draw_bulk(ui.ctx(), &mut state, title, address); }); described.addresses_resolve(); Parity::strict() .in_a_window(title) .assert(&described, &drawn); } } #[test] fn the_unconfigured_sync_screen_serves_what_it_describes() { // With no manager, which is the one sync state a test can stand up without // a server. `Unconfigured` says syncing is unavailable and offers nothing, // where the shipped side had a whole second window for it. let (mut state, _dir) = fixture(); state.sync.show_panel = true; let described = described(&super::panel::described_screen(&state, "/sync")); let drawn = shipped(|ui| { super::panel::draw_sync(ui.ctx(), &mut state, None); }); described.addresses_resolve(); Parity::strict() .in_a_window("Cloud Sync") .assert(&described, &drawn); } #[test] fn the_import_preflight_serves_what_it_describes() { let (mut state, _dir) = fixture(); state.import_wf.pending_import_preflight = Some(crate::state::import_workflow::ImportPreflight { source: std::path::PathBuf::from("/music/samples"), file_count: 4_200, total_bytes: 9_000_000_000, }); let described = described(&super::panel::described_screen(&state, "/import/preflight")); let drawn = shipped(|ui| { super::panel::draw_preflight(ui.ctx(), &mut state); }); described.addresses_resolve(); Parity::strict() .in_a_window("Import folder") .assert(&described, &drawn); } /// The import flow's stages, in the states a test can stand one up in. /// /// The preflight has a test of its own because it is a modal rather than a /// stage. This is the flow: one address whose answer depends on where the /// import has got to, so a fidelity test that only visited one stage would say /// almost nothing about it. #[test] fn the_import_flow_serves_what_it_describes_at_every_stage() { type Reach = fn(&mut crate::state::BrowserState); let stages: [(&str, Reach); 4] = [ ("idle", |_state| {}), ("configuring", |state| { state.import_wf.import_mode = crate::state::ImportMode::ConfigureImport { source: std::path::PathBuf::from("/music/kits"), source_name: "kits".to_owned(), strategy: crate::import::ImportStrategy::NewVfs { vfs_name: "kits".to_owned(), }, available_vfs: state.nav.vfs_list.to_vec(), selected_merge_vfs_idx: 0, new_vfs_name: "kits".to_owned(), audio_file_count: 42, }; }), ("copying", |state| { state.import_wf.import_mode = crate::state::ImportMode::Importing { total: 42, completed: 7, current_name: "kick.wav".to_owned(), walking: false, walking_count: 0, total_bytes: 9_000_000, loose_files: false, }; }), ("stopped", |state| { state.import_wf.import_mode = crate::state::ImportMode::OperationCancelled { kind: crate::state::CancelKind::Import, completed: 7, total: 42, destination: None, }; }), ]; for (stage, reach) in stages { let (mut state, _dir) = fixture(); reach(&mut state); let described = described(&super::panel::described_screen(&state, "/import")); let drawn = shipped(|ui| { super::panel::draw_import(ui, &mut state); }); described.addresses_resolve(); // A full-screen mode drawn into the app's own pane, so no frame to // discount -- the same terms as the tag queue and the filter panel. Parity::strict().assert(&described, &drawn); println!(" {stage}: ok"); } } #[test] fn the_sweep_serves_what_it_describes() { let (mut state, _dir) = fixture(); state.import_wf.import_mode = crate::state::ImportMode::Cleaning { completed: 3, total: 9, current_name: "kick.wav".to_owned(), }; let described = described(&super::panel::described_screen(&state, "/cleanup")); let drawn = shipped(|ui| { super::panel::draw_sweep(ui, &mut state); }); described.addresses_resolve(); // Into the pane, like every other full-screen mode, so no frame to discount. Parity::strict().assert(&described, &drawn); }