Skip to main content

max / audiofiles

48.1 KB · 1219 lines History Blame Raw
1 //! Does the described screen offer what the shipped one offers?
2 //!
3 //! Every flip in the audiofiles set replaces a hand-written egui panel with a
4 //! described screen. Without this file the only evidence that the replacement
5 //! matches is that both were written from the same intent, which is not
6 //! evidence. mnw-server built the equivalent (`tests/harness/parity.rs`) and it
7 //! is what made that flip set startable: a flip with a parity harness behind it
8 //! is a mechanical change, and one without it is a rewrite nobody can check.
9 //!
10 //! # What equivalence means here, and why it is not pixels
11 //!
12 //! The described half renders through `quasi-immediate` and the shipped half
13 //! through hand-written egui. They do not look identical and are not supposed
14 //! to: choosing the layout is the renderer's job and the whole reason the
15 //! description stops short of one.
16 //!
17 //! What has to agree is what the screen **offers** -- the same controls, saying
18 //! the same words, dead in the same states. That is a set of [`Offer`]s, and
19 //! both sides are reduced to one:
20 //!
21 //! - The described side by walking the [`Screen`] the router answered.
22 //! - The shipped side by drawing the panel into a headless [`egui::Context`]
23 //! with AccessKit on, and reading the tree egui built for a screen reader.
24 //! egui fills that tree from the same [`egui::WidgetInfo`] every widget
25 //! already reports, so this asks the panel what it drew rather than parsing
26 //! pixels or duplicating its logic.
27 //!
28 //! # What an offer is, and what it deliberately drops
29 //!
30 //! A [`Role`] and a label, plus whether the control is dead. Position is not
31 //! compared: the two renderers order a screen differently by design, so offers
32 //! are compared as a sorted multiset.
33 //!
34 //! Prose is dropped. A described screen says what it says through
35 //! `Node::Text`, and the shipped panel scatters the same sentences through
36 //! `ui.label` calls that AccessKit reports as `Role::Label` -- comparing them
37 //! would fail on every line break either side chose. What a screen *says* is
38 //! already asserted by `tests::said`; what it *offers* is this file's question.
39 //!
40 //! Addresses are the described side's alone, because egui has none: a shipped
41 //! control calls a closure, and the whole point of the flip is that a described
42 //! one names a route instead. So they are not compared across the two sides.
43 //! They are checked *within* the described side by
44 //! [`Offering::addresses_resolve`], which is the other half of the same claim:
45 //! every act the screen offers reaches a route the router actually has.
46 //!
47 //! # Two jobs, and the second outlives the first
48 //!
49 //! Before a flip, a test here compares the described screen against the shipped
50 //! panel it is about to replace. That test dies with the module it compared
51 //! against, which is correct: there is nothing left to compare.
52 //!
53 //! After a flip, a test here compares the described screen against **what the
54 //! host actually drew for it**, which is the same reader pointed at
55 //! `panel::draw_*` instead of at `ui::*`. That one is permanent, and it is the
56 //! guard the first flip needed and did not have: `panel::window` accepted a
57 //! home address answering `Outcome::Screen` and not `Outcome::Over`, so the
58 //! flipped loose-files warning drew the outcome's `Debug` rendering. Everything
59 //! compiled, every other test passed, and the screen was a wall of Rust.
60 //!
61 //! # The allowances
62 //!
63 //! A flip is allowed to change what a screen offers, and where it does, the
64 //! call site names the change rather than the harness ignoring a class of
65 //! difference blanket. That is [`Parity::dropping`] and [`Parity::gaining`]: each one at a
66 //! call site is a claim somebody wrote down.
67
68 use std::collections::BTreeMap;
69 use std::fmt::Write as _;
70
71 use quasi_router::{Node, Screen, layout};
72
73 /// What kind of control an offer is.
74 ///
75 /// Deliberately coarser than either side's own vocabulary. egui reports a
76 /// `SelectableLabel` and a `Button` as the same AccessKit role, and the
77 /// description says `Act` for both, so a finer split would be a difference
78 /// neither side chose.
79 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
80 pub(super) enum Role {
81 /// Something you press.
82 Button,
83 /// Something you type into.
84 Text,
85 /// Something you tick.
86 Check,
87 /// Something you pick one of.
88 Choice,
89 /// Something you drag to a number.
90 Number,
91 }
92
93 impl Role {
94 const fn show(self) -> &'static str {
95 match self {
96 Self::Button => "button",
97 Self::Text => "text",
98 Self::Check => "check",
99 Self::Choice => "choice",
100 Self::Number => "number",
101 }
102 }
103 }
104
105 /// One thing a screen offers.
106 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
107 pub(super) struct Offer {
108 /// What kind of control it is.
109 pub(super) role: Role,
110 /// What it says.
111 pub(super) label: String,
112 /// Whether it is present and not answering.
113 pub(super) dead: bool,
114 }
115
116 impl Offer {
117 fn show(&self) -> String {
118 let dead = if self.dead { " (dead)" } else { "" };
119 format!("{} {:?}{dead}", self.role.show(), self.label)
120 }
121 }
122
123 /// Everything a screen offers, as a multiset.
124 #[derive(Debug, Clone, Default, PartialEq, Eq)]
125 pub(super) struct Offering {
126 offers: Vec<Offer>,
127 /// The routes the described side's controls name. Empty for a shipped
128 /// screen, which has no addresses to name.
129 addresses: Vec<String>,
130 }
131
132 impl Offering {
133 fn push(&mut self, role: Role, label: impl Into<String>, dead: bool) {
134 let label = label.into();
135 // A control with nothing to say is not an offer anyone can act on, and
136 // both sides produce them: egui reports a spacer, and the description
137 // has icon-only acts whose label is empty by design.
138 if label.trim().is_empty() {
139 return;
140 }
141 self.offers.push(Offer { role, label, dead });
142 }
143
144 /// The offers, sorted, so position stops being a difference.
145 fn sorted(&self) -> Vec<Offer> {
146 let mut offers = self.offers.clone();
147 offers.sort();
148 offers
149 }
150
151 /// Assert every address this screen names is a route the router has.
152 ///
153 /// The other half of "they agree on their addresses". An address is the
154 /// described side's alone -- a shipped control calls a closure and has
155 /// none -- so it cannot be compared across the two. What can be checked is
156 /// that it is real: a described act naming a route nobody registered is a
157 /// dead control that a rendering test would never notice, because the
158 /// screen draws perfectly and does nothing when pressed.
159 ///
160 /// Matched segment-wise against the router's own patterns rather than by
161 /// asking the router, because `Path::match_path` is crate-private and the
162 /// public alternative is `handle`, which would run the handler. Pressing
163 /// things is not what a parity read does.
164 fn addresses_resolve(&self) {
165 let router = super::router();
166 let patterns: Vec<String> = router.routes().map(|(_, path)| path.to_owned()).collect();
167 for address in &self.addresses {
168 assert!(
169 patterns.iter().any(|pattern| matches(pattern, address)),
170 "the screen offers a control addressed {address:?}, \
171 and the router has no route that answers it"
172 );
173 }
174 }
175
176 /// A one-per-line rendering, for a failure message.
177 fn show(&self) -> String {
178 let mut out = String::new();
179 for offer in self.sorted() {
180 let _ = writeln!(out, " {}", offer.show());
181 }
182 out
183 }
184 }
185
186 /// Reduce a described screen to what it offers.
187 ///
188 /// Walks every node, descending into regions, forms, tables, lists and cells,
189 /// because a control inside a row is as much of an offer as one in the header.
190 pub(super) fn described(screen: &Screen) -> Offering {
191 let mut out = Offering::default();
192 for slot in &screen.slots {
193 for placed in &slot.body {
194 walk(&placed.node, &mut out);
195 }
196 }
197 out
198 }
199
200 /// What one field offers, which is not always one control.
201 ///
202 /// A `Radio` is drawn as one control per option by every renderer, so it offers
203 /// as many things as it has options and each is named by its own label. Every
204 /// other kind is a single control named by the field's question -- including
205 /// `Select`, which is a box you open rather than a set of controls, so its
206 /// options are not on screen until you do.
207 fn field_offers(field: &quasi_router::Field, out: &mut Offering) {
208 if field.kind == layout::FieldKind::Radio {
209 for choice in &field.options {
210 out.push(Role::Choice, choice.label.clone(), false);
211 }
212 return;
213 }
214 // An interval is one question with two ends, and both ends are controls.
215 // makeover-immediate names each of them by the question, which is the
216 // right answer -- "BPM Range" twice reads correctly to a screen reader
217 // stepping through them -- so the offering has two.
218 let ends = if field.kind == layout::FieldKind::Interval {
219 2
220 } else {
221 1
222 };
223 for _ in 0..ends {
224 out.push(field_role(field.kind), field.label.clone(), false);
225 }
226 }
227
228 /// The role a field of this kind is drawn as.
229 fn field_role(kind: layout::FieldKind) -> Role {
230 use layout::FieldKind as K;
231 match kind {
232 K::Checkbox => Role::Check,
233 K::Select | K::Radio => Role::Choice,
234 // A slider and an interval's two ends. A bare `Number` is not here:
235 // `makeover_immediate::control_shape` sends everything it cannot draw
236 // natively to `Control::Typed`, so a number is a well you type into and
237 // reaches the tree as a text input. That is the renderer's answer and a
238 // true report of the value, so the reader follows it rather than
239 // insisting on the kind.
240 K::Range | K::Interval => Role::Number,
241 // Everything else is a box you type into. `File` is the one stretch and
242 // it is the honest answer: a host draws it as a control that opens a
243 // picker, which is a button on some hosts and a path box on others, and
244 // guessing which would be this file deciding a renderer's question.
245 _ => Role::Text,
246 }
247 }
248
249 fn walk(node: &Node, out: &mut Offering) {
250 match node {
251 Node::Act(act) => {
252 out.push(
253 Role::Button,
254 act.label.clone(),
255 act.state == Some(layout::State::Disabled),
256 );
257 out.addresses
258 .push(act.action.destination.as_str().to_owned());
259 // An act that asks for something before it fires carries its own
260 // fields, and those are as much of what the screen offers as a
261 // field standing on its own.
262 for field in &act.asks {
263 field_offers(field, out);
264 }
265 }
266 Node::Link { text, action } => {
267 out.push(Role::Button, text.clone(), false);
268 out.addresses.push(action.destination.as_str().to_owned());
269 }
270 Node::Field(field) => field_offers(field, out),
271 Node::Form {
272 submit,
273 action,
274 fields,
275 } => {
276 out.push(Role::Button, submit.clone(), false);
277 out.addresses.push(action.destination.as_str().to_owned());
278 for field in fields {
279 field_offers(field, out);
280 }
281 }
282 Node::Select {
283 options, action, ..
284 } => {
285 // A segmented control is one choice with several labels on the
286 // shipped side too, so each option is an offer rather than the
287 // strip being one.
288 //
289 // A `Button` and not a `Choice`, which reads backwards until you
290 // ask what a renderer draws: every host draws a `Node::Select` as a
291 // strip of pressable segments, and egui reports a selectable label
292 // as `Role::Button` with nothing to distinguish it from an ordinary
293 // one. `Choice` stays for `FieldKind::Select` and `Radio`, which are
294 // a box you open and a set of radios -- genuinely different things
295 // to operate. Corrected 2026-08-22, when the forge's eight slice
296 // options came back as buttons from the renderer and choices from
297 // here.
298 for (choice, own) in options {
299 out.push(Role::Button, choice.label.clone(), false);
300 if let Some(action) = own.as_ref().or(action.as_ref()) {
301 out.addresses.push(action.destination.as_str().to_owned());
302 }
303 }
304 }
305 Node::Table { columns, rows, .. } => {
306 for column in columns {
307 // A heading with no address is a heading. Only a sortable one
308 // is something you can press.
309 if let Some(reorder) = &column.reorder {
310 out.push(Role::Button, column.name.clone(), false);
311 out.addresses.push(reorder.destination.as_str().to_owned());
312 }
313 }
314 for cells in rows {
315 if let Some(activate) = &cells.activate {
316 out.push(Role::Button, first_words(&cells.values), false);
317 out.addresses.push(activate.destination.as_str().to_owned());
318 }
319 for cell in &cells.values {
320 for part in &cell.parts {
321 walk(part, out);
322 }
323 }
324 }
325 }
326 Node::List { rows, .. } => {
327 for row in rows {
328 // A row is claimed when it opens OR when it offers a menu,
329 // which is `quasi_immediate::node::row`'s own condition: a row
330 // that only offers a menu still needs somewhere to right-click,
331 // and it is announced by its first text either way. The menu's
332 // own acts are not offers here -- they are not on screen until
333 // the gesture -- but the row that carries them is.
334 if row.activate.is_some() || !row.menu.is_empty() {
335 let named: Vec<_> = row.parts.iter().map(|part| part.node.clone()).collect();
336 out.push(Role::Button, first_text(&named), false);
337 }
338 if let Some(activate) = &row.activate {
339 out.addresses.push(activate.destination.as_str().to_owned());
340 }
341 for part in &row.parts {
342 walk(&part.node, out);
343 }
344 }
345 }
346 Node::Region(slot) => {
347 for placed in &slot.body {
348 walk(&placed.node, out);
349 }
350 }
351 Node::Stats { figures } => {
352 for (figure, action) in figures {
353 if let Some(action) = action {
354 out.push(Role::Button, figure.caption.clone(), false);
355 out.addresses.push(action.destination.as_str().to_owned());
356 }
357 }
358 }
359 // A stand-in's way out. Two of goingson's twenty-seven have one, which
360 // is why `act` is optional, and audiofiles' idle import screen is
361 // another: "Nothing is being imported" with an Import... beside it. The
362 // sentence is prose and the act is a control, so only the second is an
363 // offer.
364 Node::StandIn { act: Some(act), .. } => walk(&Node::Act(act.clone()), out),
365 // A token that calls a route is a control drawn as a chip -- the filter
366 // panel's twenty-four key pills are the site -- and one that calls
367 // nothing is a badge. `Tag::action` is the whole of the difference, so
368 // it is what decides here rather than the kind.
369 Node::Token(tag) => {
370 if let Some(action) = &tag.action {
371 out.push(Role::Button, tag.label.clone(), false);
372 out.addresses.push(action.destination.as_str().to_owned());
373 }
374 }
375 // Prose, figures, images, meters, timelines and stand-ins are things a
376 // screen says rather than things it offers. See the header.
377 _ => {}
378 }
379 }
380
381 /// Whether a concrete address is what this route pattern describes.
382 ///
383 /// Segment counts must agree and each segment must match, with a `{name}`
384 /// segment matching anything. A trailing query is dropped first: it carries
385 /// parameters, not a route.
386 fn matches(pattern: &str, address: &str) -> bool {
387 let address = address.split('?').next().unwrap_or(address);
388 let pattern: Vec<&str> = pattern.trim_matches('/').split('/').collect();
389 let address: Vec<&str> = address.trim_matches('/').split('/').collect();
390 pattern.len() == address.len()
391 && pattern
392 .iter()
393 .zip(&address)
394 .all(|(want, got)| want.starts_with('{') || want == got)
395 }
396
397 /// What a row is called: the first words in it.
398 ///
399 /// A row's press has no label of its own on either side. The shipped panel
400 /// announces the row by its first column, because that is the cell it senses
401 /// the click on, and a described row names the same text in the same place, so
402 /// this reads it from there rather than inventing a name for the press.
403 fn first_words(cells: &[quasi_router::Cell]) -> String {
404 cells
405 .iter()
406 .find_map(|cell| {
407 let said = first_text(&cell.parts);
408 (!said.is_empty()).then_some(said)
409 })
410 .unwrap_or_default()
411 }
412
413 /// The first thing a run of leaves says.
414 fn first_text(parts: &[Node]) -> String {
415 parts
416 .iter()
417 .find_map(|part| match part {
418 Node::Text { text, .. } | Node::Heading { text, .. } | Node::Link { text, .. } => {
419 Some(text.clone())
420 }
421 _ => None,
422 })
423 .unwrap_or_default()
424 }
425
426 /// Reduce a shipped egui panel to what it offers.
427 ///
428 /// Draws `paint` into a headless context with AccessKit on and reads the tree
429 /// egui built. The closure is handed the root [`egui::Ui`], which is what the
430 /// panels take; a screen that opens a window instead reaches the context
431 /// through `ui.ctx()`, the same way the app does.
432 ///
433 /// A control egui reports with no label is dropped by [`Offering::push`]: a
434 /// separator, a spacer, the panel background. What survives is what a screen
435 /// reader would announce, which is the same set a user can act on.
436 pub(super) fn shipped(mut paint: impl FnMut(&mut egui::Ui)) -> Offering {
437 let ctx = egui::Context::default();
438 ctx.enable_accesskit();
439 // Selectable labels off, and it is load-bearing rather than cosmetic. With
440 // them on -- egui's default -- every `ui.label` senses a click so its text
441 // can be dragged over, and the tree says a paragraph of prose answers a
442 // press exactly as a sortable heading does. Selecting text is not something
443 // a screen offers, and turning it off is what leaves the click sense
444 // meaning what `role_of` reads it as meaning.
445 for theme in [egui::Theme::Light, egui::Theme::Dark] {
446 ctx.style_mut_of(theme, |style| {
447 style.interaction.selectable_labels = false;
448 // No animation, so a section that has been opened is open on the
449 // next pass rather than a fraction of the way there. Openness is
450 // animated, the harness runs its passes at one instant, and a
451 // section caught mid-open draws none of its contents -- which reads
452 // as a screen offering nothing.
453 style.animation_time = 0.0;
454 });
455 }
456 // A real size, because a panel that lays out into a zero-width viewport
457 // drops columns and would look like a screen offering less than it does.
458 let input = || egui::RawInput {
459 screen_rect: Some(egui::Rect::from_min_size(
460 egui::Pos2::ZERO,
461 egui::vec2(1440.0, 900.0),
462 )),
463 ..Default::default()
464 };
465
466 // Two passes, and the second is the one that is read. egui lays out against
467 // the previous frame, so a first pass sees widgets at the wrong rect and
468 // misses anything whose existence depends on a measurement taken last
469 // frame. A window is the sharp case: it has no size until it has been
470 // drawn once.
471 let _ = ctx.run_ui(input(), &mut paint);
472 let output = ctx.run_ui(input(), &mut paint);
473
474 let mut out = Offering::default();
475 let Some(update) = output.platform_output.accesskit_update else {
476 panic!("accesskit produced no tree: the panel drew nothing at all");
477 };
478 let by_id: std::collections::HashMap<_, _> = update.nodes.iter().cloned().collect();
479 for (_, node) in &update.nodes {
480 let Some(role) = role_of(node) else {
481 continue;
482 };
483 let said = named(node, &by_id);
484 out.push(role, undecorated(&said), node.is_disabled());
485 }
486 out
487 }
488
489 /// What a control is called, the way a client works it out.
490 ///
491 /// egui puts a label's own text in `value` and every other widget's in `label`,
492 /// because a `Role::Label` IS its text. A control named by a *separate* label
493 /// has neither: it carries a `labelled_by` relation naming the node that says
494 /// it, which is what `Response::labelled_by` sets and what
495 /// `makeover_immediate::field` uses so a box is announced by its question
496 /// rather than by its own contents. Following the relation is not a
497 /// convenience here -- a reader that stopped at `label()` would report every
498 /// properly-labelled field as nameless, which is the opposite of the truth.
499 fn named(
500 node: &egui::accesskit::Node,
501 by_id: &std::collections::HashMap<egui::accesskit::NodeId, egui::accesskit::Node>,
502 ) -> String {
503 if let Some(label) = node.label() {
504 return label.to_owned();
505 }
506 if let Some(said) = node
507 .labelled_by()
508 .iter()
509 .find_map(|id| by_id.get(id))
510 .and_then(|by| by.label().or_else(|| by.value()))
511 {
512 return said.to_owned();
513 }
514 node.value().unwrap_or_default().to_owned()
515 }
516
517 /// A rendered label with the renderer's own decoration taken back off.
518 ///
519 /// A sorted column heading is drawn as its name plus a caret, because a glyph
520 /// beside the word is how a table says which column is in force. The
521 /// description says the same thing structurally, as `Column::sorted`, and
522 /// carries no caret in the name. Stripping it is therefore normalization
523 /// rather than an allowance: the fact is on both sides, said two ways.
524 ///
525 /// The glyphs come from `layout::Sort::glyph`, which is where their spelling
526 /// lives, so a renderer that changes its caret does not quietly break this.
527 fn undecorated(said: &str) -> String {
528 let mut said = said.trim();
529 for direction in [layout::Sort::Ascending, layout::Sort::Descending] {
530 if let Some(stripped) = said.strip_suffix(direction.glyph()) {
531 said = stripped.trim_end();
532 }
533 }
534 // A required field is drawn with a marker after its label. `Field::required`
535 // is what the description says and `FieldStyle::required_marker` is how this
536 // renderer shows it, so the asterisk is the same fact in the renderer's
537 // spelling -- the caret's case again.
538 if let Some(stripped) = said.strip_suffix('*') {
539 said = stripped.trim_end();
540 }
541 // An act carrying a key is drawn with it, as `label (key)`. The
542 // description says the key on the act instead, so this is the same
543 // normalization the caret gets: one fact, said two ways.
544 //
545 // Matched on the renderer's exact separator, two spaces, rather than on any
546 // trailing parenthetical. A label can legitimately end in one -- the theme
547 // picker shows "System (audiofiles)" -- and a looser rule silently ate it.
548 if said.ends_with(')')
549 && let Some((label, _)) = said.rsplit_once(" (")
550 {
551 said = label.trim_end();
552 }
553 said.to_owned()
554 }
555
556 /// The role an AccessKit node maps to, or `None` if it is not a control.
557 ///
558 /// The role alone is not enough, and a sortable column heading is why. egui
559 /// draws one as an `egui::Label` that senses a click, so the role that reaches
560 /// the tree is `Label` -- a screen reader announces static text where a user can
561 /// press to reorder the table. What decides here is therefore whether the node
562 /// answers a click, which egui records faithfully from the widget's own
563 /// `Sense`. The description says the same thing by giving the column a
564 /// `reorder` address, so the two agree.
565 ///
566 /// That the announcement is wrong is a real finding about the renderer rather
567 /// than about either screen, and it is filed rather than worked around here:
568 /// this reads the sense because the sense is the honest signal, not to paper
569 /// over the role.
570 fn role_of(node: &egui::accesskit::Node) -> Option<Role> {
571 use egui::accesskit::{Action, Role as R};
572 match node.role() {
573 R::Button | R::Link => Some(Role::Button),
574 R::TextInput | R::MultilineTextInput => Some(Role::Text),
575 R::CheckBox | R::Switch => Some(Role::Check),
576 R::RadioButton | R::ComboBox | R::ListBox => Some(Role::Choice),
577 R::Slider | R::SpinButton => Some(Role::Number),
578 // Anything else that answers a press is a control whatever it is
579 // announced as. Anything else that does not is prose, an image, a
580 // scrollbar, a pane: things a screen has rather than things it offers.
581 _ if node.supports_action(Action::Click) => Some(Role::Button),
582 _ => None,
583 }
584 }
585
586 /// How a described screen is allowed to differ from the one it replaces.
587 ///
588 /// Every allowance is named at the call site, so the list on a test is the
589 /// record of what that flip changed. There is deliberately no "ignore whatever
590 /// differs" option: an unexplained difference is the thing this file exists to
591 /// find.
592 ///
593 /// One allowance recurs and is worth knowing before it surprises you: a shipped
594 /// modal drawn in an `egui::Window` announces a button carrying the window's own
595 /// name, because that is its title bar's collapsing control. It is chrome, in
596 /// the same class as a `CollapsingHeader`, and a modal test drops it by name.
597 #[derive(Debug, Clone, Default)]
598 pub(super) struct Parity {
599 dropped: Vec<String>,
600 gained: Vec<String>,
601 }
602
603 impl Parity {
604 /// Strict: every difference fails.
605 pub(super) fn strict() -> Self {
606 Self::default()
607 }
608
609 /// A control the shipped screen had and the described one does not.
610 ///
611 /// For chrome the description deliberately refuses -- the ten-variant
612 /// confirm dialog, a panel's own close button -- where the flip's claim is
613 /// that the thing is the host's rather than the screen's.
614 #[must_use]
615 pub(super) fn dropping(mut self, label: &str) -> Self {
616 self.dropped.push(label.to_owned());
617 self
618 }
619
620 /// A control the described screen has and the shipped one did not.
621 ///
622 /// For what a port fixed on the way through: a dead-end the shipped panel
623 /// left the user in, an act that was only reachable by a keyboard shortcut.
624 #[must_use]
625 pub(super) fn gaining(mut self, label: &str) -> Self {
626 self.gained.push(label.to_owned());
627 self
628 }
629
630 /// The `egui::Window` a described screen is drawn in, which is chrome.
631 ///
632 /// Three controls that belong to the frame rather than to the screen: the
633 /// title bar's collapsing control, which carries the window's own name;
634 /// egui's "Hide" for the same collapse; and "Close window" for the X. The
635 /// description names the screen in `Screen::title` and leaves the frame to
636 /// the host, which is the arrangement, so none of the three has a
637 /// counterpart to compare against.
638 #[must_use]
639 pub(super) fn in_a_window(self, title: &str) -> Self {
640 self.dropping(title)
641 .dropping("Hide")
642 .dropping("Close window")
643 }
644
645 /// The numeric readout egui draws inside a slider.
646 ///
647 /// A `Slider` is two widgets: the track, which `makeover_immediate::field`
648 /// names from the question, and a `DragValue` showing the number, which
649 /// egui builds inside and never hands back. So a described `Range` reaches
650 /// the tree as one named control and one unnamed number.
651 ///
652 /// Not a defect to chase: the readout is a second view of a value the
653 /// question already names, and the alternative is `show_value(false)`,
654 /// which takes the number off the screen. Named here so it is a claim
655 /// rather than a silence.
656 #[must_use]
657 pub(super) fn slider_readouts(mut self, shown: &[&str]) -> Self {
658 for value in shown {
659 self = self.dropping(value);
660 }
661 self
662 }
663
664 /// Assert the two sides offer the same thing, panicking with a diff if not.
665 pub(super) fn assert(&self, described: &Offering, shipped: &Offering) {
666 let mut want: BTreeMap<Offer, isize> = BTreeMap::new();
667 for offer in shipped.sorted() {
668 if self.dropped.contains(&offer.label) {
669 continue;
670 }
671 *want.entry(offer).or_default() += 1;
672 }
673 for offer in described.sorted() {
674 if self.gained.contains(&offer.label) {
675 continue;
676 }
677 *want.entry(offer).or_default() -= 1;
678 }
679
680 let mut missing = Vec::new();
681 let mut extra = Vec::new();
682 for (offer, count) in want {
683 for _ in 0..count.max(0) {
684 missing.push(offer.show());
685 }
686 for _ in 0..(-count).max(0) {
687 extra.push(offer.show());
688 }
689 }
690
691 assert!(
692 missing.is_empty() && extra.is_empty(),
693 "the described screen does not offer what the shipped one offers.\n\
694 \nthe shipped screen offers and the described one does not:\n{}\
695 \nthe described screen offers and the shipped one does not:\n{}\
696 \nall of the shipped screen's offers:\n{}\
697 \nall of the described screen's offers:\n{}",
698 show_all(&missing),
699 show_all(&extra),
700 shipped.show(),
701 described.show(),
702 );
703 }
704 }
705
706 fn show_all(lines: &[String]) -> String {
707 if lines.is_empty() {
708 return " (none)\n".to_owned();
709 }
710 let mut out = String::new();
711 for line in lines {
712 let _ = writeln!(out, " {line}");
713 }
714 out
715 }
716
717 /// A real app, with a few samples in it.
718 ///
719 /// A `BrowserState` on a temporary directory, which is what `state::tests`
720 /// already uses: the shipped panels read one and the described screens read the
721 /// app's own adapters over the same one, so the two sides genuinely share a
722 /// fixture rather than agreeing about two.
723 fn fixture() -> (crate::state::BrowserState, tempfile::TempDir) {
724 use std::sync::Arc;
725
726 let dir = tempfile::TempDir::new().unwrap();
727 let shared = Arc::new(crate::state::SharedState::new());
728 let mut state = crate::state::BrowserState::new(dir.path(), shared, 44_100.0, "Vault").unwrap();
729
730 let vfs = state.current_vfs_id().unwrap();
731 let parent = state.nav.current_dir;
732 let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap();
733 for (hash, name) in [("aaa111", "kick.wav"), ("bbb222", "snare.wav")] {
734 db.conn()
735 .execute(
736 "INSERT OR IGNORE INTO samples \
737 (hash, original_name, file_extension, file_size, import_date, last_modified) \
738 VALUES (?1, ?2, 'wav', 100, 0, 0)",
739 rusqlite::params![hash, format!("{hash}.wav")],
740 )
741 .unwrap();
742 state
743 .backend
744 .create_sample_link(vfs, parent, name, hash)
745 .unwrap();
746 }
747 state.refresh_contents();
748 (state, dir)
749 }
750
751 #[test]
752 fn the_file_list_offers_what_the_shipped_one_offers() {
753 let (mut state, _dir) = fixture();
754
755 let described = described(&super::panel::described_screen(&state, "/files"));
756 let shipped = shipped(|ui| {
757 crate::ui::file_list::draw_file_list(ui, &mut state, None);
758 });
759
760 described.addresses_resolve();
761 // The one difference the flip introduces, and it is not settled: the
762 // shipped heading is "Dur" because the column is fixed-width and narrow,
763 // and the description says "Duration" because `Column::name` is both the
764 // heading and the key a cell is addressed by, so the abbreviation and the
765 // sort key cannot come apart. Filed against audiofiles rather than decided
766 // here. Whichever way it goes, one of these two lines goes with it.
767 Parity::strict()
768 .dropping("Dur")
769 .gaining("Duration")
770 .assert(&described, &shipped);
771 }
772
773 #[test]
774 fn the_detail_panel_serves_what_it_describes() {
775 let (mut state, _dir) = fixture();
776 // A selection, because the detail panel's subject is what is chosen and an
777 // empty one is a different screen.
778 state.nav.selection.set_single(0);
779
780 let described = described(&super::panel::described_screen(&state, "/detail"));
781 let drawn = shipped(|ui| {
782 super::panel::draw_detail(ui, &mut state);
783 });
784
785 described.addresses_resolve();
786 // A right pane rather than a window, which is what the shipped panel was.
787 Parity::strict().assert(&described, &drawn);
788 }
789
790 #[test]
791 fn settings_offers_what_the_four_described_sections_offer() {
792 let (mut state, _dir) = fixture();
793
794 let described = described(&super::panel::described_screen(&state, "/settings"));
795 // The four sections the description covers, drawn as bodies rather than
796 // through `draw_settings_panel`. Two reasons, and both would make a
797 // whole-window comparison meaningless rather than merely noisy.
798 //
799 // The description covers four of the panel's nine sections on purpose --
800 // storage, trash, license and the classifier are about this host's
801 // filesystem and a licence server, and `settings.rs`'s header is where that
802 // is argued. Comparing against the whole window would need five sections'
803 // worth of allowances saying so a second time.
804 //
805 // And the panel's sections are collapsing, so a whole-window read sees nine
806 // headings and the contents of whichever one is open. What is on screen
807 // would then depend on fold state that neither side describes, and which
808 // cannot be set from outside: a `CollapsingHeader` derives its id from the
809 // `ui.vertical` it makes for itself. That is why each body is its own
810 // function now.
811 let shipped = shipped(|ui| {
812 crate::ui::settings_panel::appearance_body(ui, &mut state);
813 crate::ui::settings_panel::preview_body(ui, &mut state);
814 crate::ui::settings_panel::forge_body(ui, &mut state);
815 crate::ui::settings_panel::display_body(ui, &mut state);
816 });
817
818 // What the shipped theme picker is announced as: its current value. The
819 // combo carries no label of its own, so this reads it the way
820 // `appearance_body` builds it rather than naming a theme here, which would
821 // make the test depend on which theme a machine resolved.
822 let themes = crate::ui::theme::list_themes();
823 let active = crate::ui::theme::active_id();
824 let active_name = themes
825 .iter()
826 .find(|theme| theme.id == active)
827 .map_or(active.as_str(), |theme| theme.name.as_str());
828 let announced = match &state.theme_selection {
829 crate::ui::theme::ThemeSelection::Follow => format!("System ({active_name})"),
830 crate::ui::theme::ThemeSelection::Fixed(_) => active_name.to_owned(),
831 };
832
833 described.addresses_resolve();
834 Parity::strict()
835 // Two controls the shipped panel leaves unnamed, and the port names.
836 // The theme combo is announced as whatever theme is picked, because the
837 // word "Theme" is a separate label beside it; the row-height slider
838 // draws no text at all, so a screen reader announces an unnamed slider.
839 // Both are the description attaching a question to its control, which
840 // is the same fix the detail panel's tag box gets.
841 .dropping(&announced)
842 .gaining("Theme")
843 .gaining("Row height")
844 .assert(&described, &shipped);
845 }
846
847 #[test]
848 fn the_flipped_warning_serves_what_it_describes() {
849 let (mut state, _dir) = fixture();
850 state.loose_files.loose_files_missing_count = 3;
851 state.loose_files.show_loose_files_warning = true;
852
853 let described = described(&super::panel::described_screen(
854 &state,
855 "/library/loose-files",
856 ));
857 let drawn = shipped(|ui| {
858 super::panel::draw_integrity(ui.ctx(), &mut state);
859 });
860
861 Parity::strict()
862 .in_a_window("Loose-files mode warning")
863 .assert(&described, &drawn);
864 }
865
866 /// The four name modals: what each is called, and where it is served from.
867 ///
868 /// A table rather than four tests, because they are one screen four times and
869 /// the flip's claim is exactly that.
870 #[test]
871 fn the_four_name_modals_serve_what_they_describe() {
872 type Show = fn(&mut crate::state::BrowserState);
873
874 let modals: [(&str, &str, Show); 4] = [
875 ("New Vault", "/vaults/new", |state| {
876 state.vfs_modal.show_vfs_create = true;
877 }),
878 ("Rename Vault", "/vaults/{id}/rename", |state| {
879 let vault = state.nav.vfs_list[0].clone();
880 state.vfs_modal.vfs_rename_target = Some((vault.id, vault.name));
881 }),
882 ("New Folder", "/folders/new", |state| {
883 state.vfs_modal.show_dir_create = true;
884 }),
885 ("Rename", "/folders/{id}/rename", |state| {
886 let folder = state.nav.contents[0].node.clone();
887 state.vfs_modal.dir_rename_target = Some((folder.id, folder.name));
888 }),
889 ];
890
891 for (title, address, show) in modals {
892 let (mut state, _dir) = fixture();
893 // A folder to rename, which the sample-only fixture does not have.
894 let vault = state.current_vfs_id().unwrap();
895 let parent = state.nav.current_dir;
896 state
897 .backend
898 .create_directory(vault, parent, "drums")
899 .unwrap();
900 state.refresh_contents();
901 show(&mut state);
902
903 let address = address.replace("{id}", &real_id(&state, address).to_string());
904 let described = described(&super::panel::described_screen(&state, &address));
905 let drawn = shipped(|ui| {
906 super::panel::draw_naming(ui.ctx(), &mut state, title, &address);
907 });
908
909 described.addresses_resolve();
910 Parity::strict()
911 .in_a_window(title)
912 .assert(&described, &drawn);
913 }
914 }
915
916 /// The id the rename addresses need, read off the fixture.
917 fn real_id(state: &crate::state::BrowserState, address: &str) -> i64 {
918 if address.starts_with("/vaults/") {
919 state.nav.vfs_list[0].id.as_i64()
920 } else {
921 state
922 .nav
923 .contents
924 .iter()
925 .map(|node| &node.node)
926 .find(|node| node.sample_hash.is_none())
927 .expect("the fixture makes a folder")
928 .id
929 .as_i64()
930 }
931 }
932
933 /// A review queue with one tag in it, for the screen that shows one.
934 fn with_a_review_queue(state: &mut crate::state::BrowserState) {
935 use crate::state::{ReviewCandidate, ReviewGroup, ReviewQueue};
936
937 state.classifier.review = Some(ReviewQueue {
938 groups: vec![ReviewGroup {
939 tag: "instrument.drum.kick".to_owned(),
940 candidates: vec![
941 ReviewCandidate {
942 hash: "aaa111".to_owned(),
943 name: Some("kick.wav".to_owned()),
944 score: 0.95,
945 confident: true,
946 accepted: false,
947 },
948 ReviewCandidate {
949 hash: "bbb222".to_owned(),
950 name: Some("snare.wav".to_owned()),
951 score: 0.42,
952 confident: false,
953 accepted: false,
954 },
955 ],
956 names_loaded: true,
957 }],
958 samples_considered: 2,
959 samples_with_suggestions: 2,
960 });
961 state.open_review_screen();
962 }
963
964 #[test]
965 fn the_tag_queue_serves_what_it_describes() {
966 let (mut state, _dir) = fixture();
967 with_a_review_queue(&mut state);
968
969 let described = described(&super::panel::described_screen(&state, "/review"));
970 let drawn = shipped(|ui| {
971 super::panel::draw_queue(ui, &mut state);
972 });
973
974 described.addresses_resolve();
975 // No `in_a_window`: the queue is a full-screen mode drawn into the app's own
976 // pane, which is what the shipped screen was, so there is no frame around it
977 // to discount.
978 Parity::strict().assert(&described, &drawn);
979 }
980
981 /// A sample open in the forge, which is what that screen is about.
982 fn with_the_forge_open(state: &mut crate::state::BrowserState) {
983 state.nav.selection.set_single(0);
984 state.open_forge_window("aaa111");
985 }
986
987 #[test]
988 fn the_forge_serves_what_it_describes() {
989 let (mut state, _dir) = fixture();
990 with_the_forge_open(&mut state);
991
992 let described = described(&super::panel::described_screen(&state, "/forge"));
993 let drawn = shipped(|ui| {
994 super::panel::draw_forge(ui.ctx(), &mut state);
995 });
996
997 described.addresses_resolve();
998 Parity::strict()
999 .in_a_window("Sample Forge")
1000 .assert(&described, &drawn);
1001 }
1002
1003 /// A sample open in the editor, which is what that screen is about.
1004 fn with_the_editor_open(state: &mut crate::state::BrowserState) {
1005 state.nav.selection.set_single(0);
1006 state.open_edit_window("aaa111");
1007 }
1008
1009 #[test]
1010 fn the_editor_serves_what_it_describes() {
1011 let (mut state, _dir) = fixture();
1012 with_the_editor_open(&mut state);
1013
1014 let described = described(&super::panel::described_screen(&state, "/edit"));
1015 let drawn = shipped(|ui| {
1016 super::panel::draw_edit(ui.ctx(), &mut state);
1017 });
1018
1019 described.addresses_resolve();
1020 Parity::strict()
1021 .in_a_window("Sample Editor")
1022 .slider_readouts(&["-1.0", "0.0", "0.000", "1.000", "100"])
1023 .assert(&described, &drawn);
1024 }
1025
1026 /// Every filter narrowed, which is how the shipped pane opens its sections.
1027 ///
1028 /// `widgets::filter_section` is `default_open(active)`, so a pane read with
1029 /// nothing filtered offers eight headings and no controls. Setting a bound on
1030 /// each axis is the panel's own rule for showing them rather than a way round
1031 /// it, and it is also the state worth comparing: an empty filter panel is the
1032 /// one arrangement where neither side has much to say.
1033 fn with_every_filter_narrowed(state: &mut crate::state::BrowserState) {
1034 let f = &mut state.search.search_filter;
1035 f.bpm_min = Some(90.0);
1036 f.duration_min = Some(1.0);
1037 f.peak_db_min = Some(-12.0);
1038 f.centroid_min = Some(500.0);
1039 f.flatness_min = Some(0.2);
1040 f.attack_min = Some(5.0);
1041 f.keys.push("Am".to_owned());
1042 f.required_tags.push("drums".to_owned());
1043 state.search.filter_panel_open = true;
1044 }
1045
1046 #[test]
1047 fn the_filter_panel_serves_what_it_describes() {
1048 let (mut state, _dir) = fixture();
1049 with_every_filter_narrowed(&mut state);
1050
1051 let described = described(&super::panel::described_screen(&state, "/filters"));
1052 let drawn = shipped(|ui| {
1053 super::panel::draw_filters(ui, &mut state);
1054 });
1055
1056 described.addresses_resolve();
1057 // A left pane rather than a window, which is what the shipped panel was, so
1058 // there is no frame to discount.
1059 Parity::strict().assert(&described, &drawn);
1060 }
1061
1062 /// Two samples chosen, which is what every bulk modal needs.
1063 fn with_two_chosen(state: &mut crate::state::BrowserState) {
1064 state.nav.selection.select_all(state.nav.contents.len());
1065 }
1066
1067 #[test]
1068 fn the_three_bulk_modals_serve_what_they_describe() {
1069 type Open = fn(&mut crate::state::BrowserState);
1070
1071 let modals: [(&str, &str, Open); 3] = [
1072 ("Bulk Tag", "/bulk/tag", |state| state.open_bulk_tag_modal()),
1073 ("Bulk Move", "/bulk/move", |state| {
1074 state.open_bulk_move_modal();
1075 }),
1076 ("Bulk Rename", "/bulk/rename", |state| {
1077 state.open_bulk_rename_modal();
1078 }),
1079 ];
1080
1081 for (title, address, open) in modals {
1082 let (mut state, _dir) = fixture();
1083 with_two_chosen(&mut state);
1084 open(&mut state);
1085
1086 let described = described(&super::panel::described_screen(&state, address));
1087 let drawn = shipped(|ui| {
1088 super::panel::draw_bulk(ui.ctx(), &mut state, title, address);
1089 });
1090
1091 described.addresses_resolve();
1092 Parity::strict()
1093 .in_a_window(title)
1094 .assert(&described, &drawn);
1095 }
1096 }
1097
1098 #[test]
1099 fn the_unconfigured_sync_screen_serves_what_it_describes() {
1100 // With no manager, which is the one sync state a test can stand up without
1101 // a server. `Unconfigured` says syncing is unavailable and offers nothing,
1102 // where the shipped side had a whole second window for it.
1103 let (mut state, _dir) = fixture();
1104 state.sync.show_panel = true;
1105
1106 let described = described(&super::panel::described_screen(&state, "/sync"));
1107 let drawn = shipped(|ui| {
1108 super::panel::draw_sync(ui.ctx(), &mut state, None);
1109 });
1110
1111 described.addresses_resolve();
1112 Parity::strict()
1113 .in_a_window("Cloud Sync")
1114 .assert(&described, &drawn);
1115 }
1116
1117 #[test]
1118 fn the_import_preflight_serves_what_it_describes() {
1119 let (mut state, _dir) = fixture();
1120 state.import_wf.pending_import_preflight =
1121 Some(crate::state::import_workflow::ImportPreflight {
1122 source: std::path::PathBuf::from("/music/samples"),
1123 file_count: 4_200,
1124 total_bytes: 9_000_000_000,
1125 });
1126
1127 let described = described(&super::panel::described_screen(&state, "/import/preflight"));
1128 let drawn = shipped(|ui| {
1129 super::panel::draw_preflight(ui.ctx(), &mut state);
1130 });
1131
1132 described.addresses_resolve();
1133 Parity::strict()
1134 .in_a_window("Import folder")
1135 .assert(&described, &drawn);
1136 }
1137
1138 /// The import flow's stages, in the states a test can stand one up in.
1139 ///
1140 /// The preflight has a test of its own because it is a modal rather than a
1141 /// stage. This is the flow: one address whose answer depends on where the
1142 /// import has got to, so a fidelity test that only visited one stage would say
1143 /// almost nothing about it.
1144 #[test]
1145 fn the_import_flow_serves_what_it_describes_at_every_stage() {
1146 type Reach = fn(&mut crate::state::BrowserState);
1147
1148 let stages: [(&str, Reach); 4] = [
1149 ("idle", |_state| {}),
1150 ("configuring", |state| {
1151 state.import_wf.import_mode = crate::state::ImportMode::ConfigureImport {
1152 source: std::path::PathBuf::from("/music/kits"),
1153 source_name: "kits".to_owned(),
1154 strategy: crate::import::ImportStrategy::NewVfs {
1155 vfs_name: "kits".to_owned(),
1156 },
1157 available_vfs: state.nav.vfs_list.to_vec(),
1158 selected_merge_vfs_idx: 0,
1159 new_vfs_name: "kits".to_owned(),
1160 audio_file_count: 42,
1161 };
1162 }),
1163 ("copying", |state| {
1164 state.import_wf.import_mode = crate::state::ImportMode::Importing {
1165 total: 42,
1166 completed: 7,
1167 current_name: "kick.wav".to_owned(),
1168 walking: false,
1169 walking_count: 0,
1170 total_bytes: 9_000_000,
1171 loose_files: false,
1172 };
1173 }),
1174 ("stopped", |state| {
1175 state.import_wf.import_mode = crate::state::ImportMode::OperationCancelled {
1176 kind: crate::state::CancelKind::Import,
1177 completed: 7,
1178 total: 42,
1179 destination: None,
1180 };
1181 }),
1182 ];
1183
1184 for (stage, reach) in stages {
1185 let (mut state, _dir) = fixture();
1186 reach(&mut state);
1187
1188 let described = described(&super::panel::described_screen(&state, "/import"));
1189 let drawn = shipped(|ui| {
1190 super::panel::draw_import(ui, &mut state);
1191 });
1192
1193 described.addresses_resolve();
1194 // A full-screen mode drawn into the app's own pane, so no frame to
1195 // discount -- the same terms as the tag queue and the filter panel.
1196 Parity::strict().assert(&described, &drawn);
1197 println!(" {stage}: ok");
1198 }
1199 }
1200
1201 #[test]
1202 fn the_sweep_serves_what_it_describes() {
1203 let (mut state, _dir) = fixture();
1204 state.import_wf.import_mode = crate::state::ImportMode::Cleaning {
1205 completed: 3,
1206 total: 9,
1207 current_name: "kick.wav".to_owned(),
1208 };
1209
1210 let described = described(&super::panel::described_screen(&state, "/cleanup"));
1211 let drawn = shipped(|ui| {
1212 super::panel::draw_sweep(ui, &mut state);
1213 });
1214
1215 described.addresses_resolve();
1216 // Into the pane, like every other full-screen mode, so no frame to discount.
1217 Parity::strict().assert(&described, &drawn);
1218 }
1219