Skip to main content

max / audiofiles

276.1 KB · 8873 lines History Blame Raw
1 //! The described screens, called with no host in sight.
2 //!
3 //! Every test here builds a `Settings`, calls the router, and reads the `Screen`
4 //! that came back. No egui, no window, no `BrowserState`: that is the property
5 //! the description layer exists to give, and it is why these run in
6 //! milliseconds where the panel they replace cannot be tested at all.
7
8 use std::cell::RefCell;
9 use std::collections::BTreeMap;
10
11 use audiofiles_core::config_key::ConfigKey;
12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13
14 use super::{
15 Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown,
16 Config, Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Editing, Export, Failure,
17 Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format, Group, Halted,
18 Holding, Importing, Integrity, Keys, Knob, Library, Measure, Measures, Migrating, Naming,
19 Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue,
20 Queued, Reviewed, Sample, Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source,
21 Spread, Stage, State, Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep,
22 Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router,
23 };
24
25 /// A config store in memory.
26 ///
27 /// Two methods, which is the whole of what a described screen needs. Standing
28 /// this up against `Backend` itself would have meant implementing vfs, tags and
29 /// search to test a checkbox, and that cost is what `Config` exists to refuse.
30 #[derive(Default)]
31 struct Store {
32 values: RefCell<BTreeMap<String, String>>,
33 }
34
35 impl Config for Store {
36 fn get(&self, key: ConfigKey) -> Result<Option<String>, String> {
37 Ok(self.values.borrow().get(key.as_str()).cloned())
38 }
39
40 fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> {
41 self.values
42 .borrow_mut()
43 .insert(key.as_str().to_owned(), value.to_owned());
44 Ok(())
45 }
46 }
47
48 impl Store {
49 fn with(pairs: &[(ConfigKey, &str)]) -> Self {
50 let store = Self::default();
51 for (key, value) in pairs {
52 store
53 .values
54 .borrow_mut()
55 .insert(key.as_str().to_owned(), (*value).to_owned());
56 }
57 store
58 }
59
60 fn get(&self, key: ConfigKey) -> Option<String> {
61 self.values.borrow().get(key.as_str()).cloned()
62 }
63 }
64
65 /// A file list in memory, recording what was asked of it.
66 ///
67 /// The reads are plain data and the writes are recorded, which is the shape the
68 /// real adapter has for a reason the fixture makes visible: selecting a row is
69 /// `&mut BrowserState`, so a route can only ask.
70 #[derive(Default)]
71 struct FakeFiles {
72 samples: Vec<Sample>,
73 shown: ColumnsShown,
74 by: String,
75 ascending: bool,
76 current: Option<i64>,
77 asked: RefCell<Vec<String>>,
78 }
79
80 impl FakeFiles {
81 fn with(samples: Vec<Sample>) -> Self {
82 Self {
83 samples,
84 shown: ColumnsShown {
85 duration: true,
86 bpm: true,
87 key: true,
88 peak_db: false,
89 tags: true,
90 },
91 by: "Name".to_owned(),
92 ascending: true,
93 ..Self::default()
94 }
95 }
96
97 fn asked(&self) -> Vec<String> {
98 self.asked.borrow().clone()
99 }
100 }
101
102 impl Files for FakeFiles {
103 fn samples(&self) -> Vec<Sample> {
104 self.samples.clone()
105 }
106 fn columns(&self) -> ColumnsShown {
107 self.shown
108 }
109 fn sort(&self) -> (String, bool) {
110 (self.by.clone(), self.ascending)
111 }
112 fn current(&self) -> Option<i64> {
113 self.current
114 }
115 fn open(&self, id: i64) {
116 self.asked.borrow_mut().push(format!("open:{id}"));
117 }
118 fn play(&self, id: i64) {
119 self.asked.borrow_mut().push(format!("play:{id}"));
120 }
121 fn sort_by(&self, column: &str) {
122 self.asked.borrow_mut().push(format!("sort:{column}"));
123 }
124 // The row menu's seven. Recorded rather than performed, same as the three
125 // above: what a test of a described screen asserts is that pressing a
126 // described act reaches the capability, and the app is what does it.
127 fn enter(&self, id: i64) {
128 self.asked.borrow_mut().push(format!("enter:{id}"));
129 }
130 fn reveal(&self, id: i64) {
131 self.asked.borrow_mut().push(format!("reveal:{id}"));
132 }
133 fn as_instrument(&self, id: i64) {
134 self.asked.borrow_mut().push(format!("instrument:{id}"));
135 }
136 fn reanalyze(&self, id: i64) {
137 self.asked.borrow_mut().push(format!("reanalyze:{id}"));
138 }
139 fn delete(&self, id: i64) {
140 self.asked.borrow_mut().push(format!("delete:{id}"));
141 }
142 fn download(&self, id: i64) {
143 self.asked.borrow_mut().push(format!("download:{id}"));
144 }
145 fn remove_from_collection(&self, id: i64) {
146 self.asked.borrow_mut().push(format!("uncollect:{id}"));
147 }
148 fn add_to_collection(&self, id: i64, collection: i64) {
149 self.asked
150 .borrow_mut()
151 .push(format!("collect:{id}->{collection}"));
152 }
153 }
154
155 /// An export flow that is not running, for the screens that are not about one.
156 ///
157 /// Its own type rather than a `FakeExport` in the idle phase, because every
158 /// method on it is a refusal: the other screens' tests should not be able to
159 /// start an export by accident, and a fake that recorded the call would let one.
160 struct Idle;
161
162 impl Export for Idle {
163 fn phase(&self) -> Phase {
164 Phase::Idle
165 }
166 fn open(&self) {}
167 fn configure(&self, _setting: Setting, _value: &str) {}
168 fn start(&self) {}
169 fn cancel(&self) {}
170 fn dismiss(&self) {}
171 }
172
173 /// An export flow in memory, recording what was asked of it.
174 ///
175 /// The phase is fixed per test rather than advancing, which is the honest shape:
176 /// what moves the phase is the app applying an intent, and these tests are of
177 /// the description rather than of the host. What is recorded is the asking.
178 struct FakeExport {
179 phase: Phase,
180 asked: RefCell<Vec<String>>,
181 }
182
183 impl FakeExport {
184 fn at(phase: Phase) -> Self {
185 Self {
186 phase,
187 asked: RefCell::new(Vec::new()),
188 }
189 }
190 }
191
192 impl Export for FakeExport {
193 fn phase(&self) -> Phase {
194 self.phase.clone()
195 }
196 fn open(&self) {
197 self.asked.borrow_mut().push("open".to_owned());
198 }
199 fn configure(&self, setting: Setting, value: &str) {
200 self.asked
201 .borrow_mut()
202 .push(format!("set:{}={value}", setting.as_str()));
203 }
204 fn start(&self) {
205 self.asked.borrow_mut().push("start".to_owned());
206 }
207 fn cancel(&self) {
208 self.asked.borrow_mut().push("cancel".to_owned());
209 }
210 fn dismiss(&self) {
211 self.asked.borrow_mut().push("dismiss".to_owned());
212 }
213 }
214
215 /// Settings as the app defaults them: copy as-is, into a tree.
216 fn defaults() -> Settings {
217 Settings {
218 format: Format::Original,
219 sample_rate: None,
220 bit_depth: None,
221 channels: Channels::Original,
222 flatten: false,
223 sidecar: false,
224 naming_pattern: None,
225 destination: "/tmp/export".to_owned(),
226 device_profile: None,
227 }
228 }
229
230 /// One sample about to be exported.
231 fn subject(name: &str, seconds: f64) -> Subject {
232 Subject {
233 name: name.to_owned(),
234 ext: "wav".to_owned(),
235 duration: Some(seconds),
236 bpm: Some(120.0),
237 musical_key: Some("Am".to_owned()),
238 }
239 }
240
241 /// A router call against this export flow.
242 fn exporting(export: &FakeExport, request: Request) -> Result<Response, quasi_router::RouteError> {
243 let store = Store::default();
244 let sync = Offline;
245 let files = FakeFiles::default();
246 let themes = themes();
247 let state = Panels {
248 detail: &Unfocused,
249 bulk: &Unchosen,
250 shell: &Quiet,
251 library: &Empty,
252 bar: &Still,
253 config: &store,
254 sync: &sync,
255 files: &files,
256 export,
257 naming: &Unnamed,
258 importing: &NoImport,
259 integrity: &Sound,
260 editor: &Unedited,
261 forge: &Unforged,
262 queue: &Unqueued,
263 filters: &Unfiltered,
264 themes: &themes,
265 };
266 router().handle(&state, request)
267 }
268
269 /// The screen the export flow answers, at whatever phase it is in.
270 fn exported(export: &FakeExport) -> Screen {
271 screen_of(&exporting(export, Request::get("/export")).unwrap()).clone()
272 }
273
274 /// Every node on a screen, in order.
275 fn nodes(screen: &Screen) -> Vec<&Node> {
276 screen
277 .slots
278 .iter()
279 .flat_map(|slot| &slot.body)
280 .map(|placed| &placed.node)
281 .collect()
282 }
283
284 /// Every node on a screen, descending into regions.
285 ///
286 /// [`nodes`] stops at the top level, which is enough for a flat screen. The sync
287 /// screen nests: a body region holds a subscription region holds the forms, so a
288 /// test that asks what the screen says has to walk down.
289 fn nodes_deep(screen: &Screen) -> Vec<Node> {
290 fn walk(node: &Node, out: &mut Vec<Node>) {
291 out.push(node.clone());
292 if let Node::Region(slot) = node {
293 for placed in &slot.body {
294 walk(&placed.node, out);
295 }
296 }
297 }
298 let mut out = Vec::new();
299 for slot in &screen.slots {
300 for placed in &slot.body {
301 walk(&placed.node, &mut out);
302 }
303 }
304 out
305 }
306
307 /// What the screen says, regions included. See [`said`].
308 fn said_deep(screen: &Screen) -> String {
309 nodes_deep(screen)
310 .iter()
311 .filter_map(|node| match node {
312 Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => {
313 Some(text.clone())
314 }
315 Node::StandIn { message, .. } => Some(message.clone()),
316 _ => None,
317 })
318 .collect::<Vec<_>>()
319 .join(" | ")
320 }
321
322 /// The text of every prose and notice node on a screen, joined.
323 ///
324 /// Assertions read against this rather than against node positions: what a
325 /// screen *says* is the described fact, and where the renderer puts it is not.
326 fn said(screen: &Screen) -> String {
327 nodes(screen)
328 .iter()
329 .filter_map(|node| match node {
330 Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => {
331 Some(text.clone())
332 }
333 Node::StandIn { message, .. } => Some(message.clone()),
334 _ => None,
335 })
336 .collect::<Vec<_>>()
337 .join(" | ")
338 }
339
340 /// A sample with the fields a row names.
341 fn sample(id: i64, name: &str) -> Sample {
342 Sample {
343 id,
344 name: name.to_owned(),
345 duration: Some(1.5),
346 bpm: Some(120.0),
347 key: Some("Am".to_owned()),
348 peak_db: Some(-3.2),
349 tags: vec!["drums".to_owned(), "loop".to_owned()],
350 directory: false,
351 cloud_only: false,
352 }
353 }
354
355 /// A folder row, which offers a different menu.
356 fn folder(id: i64, name: &str) -> Sample {
357 Sample {
358 duration: None,
359 bpm: None,
360 key: None,
361 peak_db: None,
362 tags: Vec::new(),
363 directory: true,
364 ..sample(id, name)
365 }
366 }
367
368 /// A sample whose bytes are only in the cloud.
369 fn cloud_only(id: i64, name: &str) -> Sample {
370 Sample {
371 cloud_only: true,
372 ..sample(id, name)
373 }
374 }
375
376 /// A router call against this file list.
377 fn listing(files: &FakeFiles, request: Request) -> Result<Response, quasi_router::RouteError> {
378 let store = Store::default();
379 let sync = Offline;
380 let themes = themes();
381 let state = Panels {
382 detail: &Unfocused,
383 bulk: &Unchosen,
384 shell: &Quiet,
385 library: &Empty,
386 bar: &Still,
387 config: &store,
388 sync: &sync,
389 files,
390 export: &Idle,
391 naming: &Unnamed,
392 importing: &NoImport,
393 integrity: &Sound,
394 editor: &Unedited,
395 forge: &Unforged,
396 queue: &Unqueued,
397 filters: &Unfiltered,
398 themes: &themes,
399 };
400 router().handle(&state, request)
401 }
402
403 /// The same call, with a collection open.
404 ///
405 /// One menu entry turns on it -- Remove from Collection means nothing outside one
406 /// -- so the file list asks the library whether any collection is active, and
407 /// this is the fixture that says yes. `Empty` answers no and is what every other
408 /// file-list test wants.
409 fn listing_in_collection(
410 files: &FakeFiles,
411 request: Request,
412 ) -> Result<Response, quasi_router::RouteError> {
413 struct Showing;
414
415 impl Library for Showing {
416 fn vaults(&self) -> Vec<Vault> {
417 Vec::new()
418 }
419 fn collections(&self) -> Vec<Collection> {
420 vec![Collection {
421 id: 3,
422 name: "Kicks".to_owned(),
423 holding: Holding::Fixed(4),
424 active: true,
425 }]
426 }
427 fn tags(&self) -> Vec<Filter> {
428 Vec::new()
429 }
430 fn open_vault(&self, _id: i64) {}
431 fn delete_vault(&self, _id: i64) {}
432 fn toggle_tag(&self, _path: &str) {}
433 fn remove_tag(&self, _path: &str) {}
434 fn open_collection(&self, _id: i64) {}
435 fn close_collection(&self) {}
436 fn delete_collection(&self, _id: i64) {}
437 }
438
439 let store = Store::default();
440 let sync = Offline;
441 let themes = themes();
442 let state = Panels {
443 detail: &Unfocused,
444 bulk: &Unchosen,
445 shell: &Quiet,
446 library: &Showing,
447 bar: &Still,
448 config: &store,
449 sync: &sync,
450 files,
451 export: &Idle,
452 naming: &Unnamed,
453 importing: &NoImport,
454 integrity: &Sound,
455 editor: &Unedited,
456 forge: &Unforged,
457 queue: &Unqueued,
458 filters: &Unfiltered,
459 themes: &themes,
460 };
461 router().handle(&state, request)
462 }
463
464 /// The table on a screen.
465 ///
466 /// Descends into a `Node::Region`, because a region is a slot inside a node and
467 /// `nodes` only walks the screen's own slots. The rename preview lives in one so
468 /// that a fragment can replace it.
469 fn table_of(screen: &Screen) -> (Vec<quasi_router::Column>, Vec<quasi_router::Cells>) {
470 fn find(
471 body: &[quasi_router::Ranked],
472 ) -> Option<(Vec<quasi_router::Column>, Vec<quasi_router::Cells>)> {
473 for placed in body {
474 match &placed.node {
475 Node::Table { columns, rows, .. } => {
476 return Some((columns.clone(), rows.clone()));
477 }
478 Node::Region(slot) => {
479 if let Some(found) = find(&slot.body) {
480 return Some(found);
481 }
482 }
483 _ => {}
484 }
485 }
486 None
487 }
488
489 screen
490 .slots
491 .iter()
492 .find_map(|slot| find(&slot.body))
493 .expect("the screen draws a table")
494 }
495
496 /// Sync that reports nothing and does nothing.
497 ///
498 /// The settings tests do not touch it, and it is here because `Panels` is one
499 /// state for every screen: a router is one table, so a settings test still has
500 /// to name a sync. That is the cost of sharing, and it is a fixture rather than
501 /// a design problem.
502 struct Offline;
503
504 impl Sync for Offline {
505 fn status(&self) -> Status {
506 Status {
507 state: State::Disconnected,
508 last_sync_at: None,
509 pending_changes: 0,
510 last_error: None,
511 auto_sync_enabled: false,
512 sync_interval_minutes: 15,
513 }
514 }
515
516 fn connect(&self) -> Result<String, String> {
517 Err("offline".to_owned())
518 }
519
520 fn cancel(&self) {}
521 fn set_password(&self, _password: &str, _is_new: bool) {}
522 fn sync_now(&self) {}
523 fn set_auto(&self, _enabled: bool) {}
524 fn set_interval(&self, _minutes: u32) {}
525 fn clear_error(&self) {}
526 fn disconnect(&self) {}
527 fn subscription(&self) -> Option<Subscription> {
528 None
529 }
530 fn pricing(&self) -> Option<Pricing> {
531 None
532 }
533 fn synced_library_bytes(&self) -> Option<i64> {
534 None
535 }
536 fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 {
537 0
538 }
539 fn refresh_subscription(&self) {}
540 fn subscribe(&self, _cap_bytes: i64, _annual: bool) {}
541 fn queue_cap_change(&self, _cap_bytes: i64) {}
542 }
543
544 fn themes() -> Vec<ThemeChoice> {
545 vec![
546 ThemeChoice {
547 id: "audiofiles".into(),
548 name: "audiofiles".into(),
549 variant: "light".into(),
550 source: Some("[color]\nink = \"#111111\"\n".into()),
551 },
552 // No source, which is the built-in-with-nothing-to-read case: Export
553 // must offer nothing rather than offer an empty file.
554 ThemeChoice {
555 id: "nord".into(),
556 name: "Nord".into(),
557 variant: "dark".into(),
558 source: None,
559 },
560 ]
561 }
562
563 /// The screen out of a response, or a failure naming what came instead.
564 ///
565 /// Either kind of screen, because an overlay is a screen drawn over something
566 /// rather than a different thing: what a test asks of `/vaults/1/rename` is
567 /// what it says, and whether it is over the main window is
568 /// [`overlaid`](overlaid)'s question.
569 fn screen_of(response: &Response) -> &Screen {
570 match &response.outcome {
571 Outcome::Screen(screen) | Outcome::Over(screen) => screen,
572 other => panic!("expected a screen, got {other:?}"),
573 }
574 }
575
576 /// Every field on the screen, by name.
577 ///
578 /// Standing alone or inside a form: the two are the same fact about what the
579 /// screen asks for, and only the submit differs.
580 fn fields(screen: &Screen) -> BTreeMap<String, Option<String>> {
581 let mut found = BTreeMap::new();
582 for slot in &screen.slots {
583 for placed in &slot.body {
584 match &placed.node {
585 Node::Field(field) => {
586 found.insert(field.name.clone(), field.value.clone());
587 }
588 Node::Form { fields, .. } => {
589 for field in fields {
590 found.insert(field.name.clone(), field.value.clone());
591 }
592 }
593 _ => {}
594 }
595 }
596 }
597 found
598 }
599
600 #[test]
601 fn the_screen_answers_with_every_control_it_describes() {
602 let store = Store::default();
603 let themes = themes();
604 let sync = Offline;
605 let files = FakeFiles::default();
606 let state = Panels {
607 detail: &Unfocused,
608 bulk: &Unchosen,
609 shell: &Quiet,
610 library: &Empty,
611 bar: &Still,
612 config: &store,
613 sync: &sync,
614 files: &files,
615 export: &Idle,
616 naming: &Unnamed,
617 importing: &NoImport,
618 integrity: &Sound,
619 editor: &Unedited,
620 forge: &Unforged,
621 queue: &Unqueued,
622 filters: &Unfiltered,
623 themes: &themes,
624 };
625 let response = router()
626 .handle(&state, Request::get("/settings"))
627 .expect("the route answered");
628 let screen = screen_of(&response);
629 let named = fields(screen);
630
631 // The four describable sections, as the controls a user sees.
632 assert!(named.contains_key(ConfigKey::PreviewLoop.as_str()));
633 assert!(named.contains_key(ConfigKey::PreviewAutoplay.as_str()));
634 assert!(named.contains_key(ConfigKey::ForgeAutoTrimOvershoot.as_str()));
635 assert!(named.contains_key(ConfigKey::RowHeight.as_str()));
636 for column in ["column.bpm", "column.key", "column.tags"] {
637 assert!(named.contains_key(column), "{column} is not on the screen");
638 }
639 }
640
641 #[test]
642 fn every_control_writes_through_one_route() {
643 // The shape goingson's settings port settled: a screen that is a key/value
644 // editor reads as one, and carries no second list of what it may name.
645 let table: Vec<(Method, String)> = router().routes().map(|(m, p)| (m, p.to_owned())).collect();
646 assert!(table.contains(&(Method::Get, "/settings".to_owned())));
647 assert!(table.contains(&(Method::Post, "/settings/config/{key}".to_owned())));
648 // Counted per screen rather than in total, so a second screen landing in the
649 // same table does not read as this one growing routes.
650 //
651 // Four, and the two that are not the key/value route are not exceptions to
652 // it: `columns/reset` is one write to one key, and `theme/export` is not a
653 // write at all -- it hands back a file. What this asserts is that no control
654 // grew an address of its own, which is the drift it exists to catch.
655 let settings = table
656 .iter()
657 .filter(|(_, path)| path.starts_with("/settings"))
658 .count();
659 assert_eq!(settings, 4, "{table:?}");
660 }
661
662 /// A router call against the settings screen, over a given config store.
663 fn settling(store: &Store, request: Request) -> Result<Response, quasi_router::RouteError> {
664 let themes = themes();
665 let sync = Offline;
666 let files = FakeFiles::default();
667 let state = Panels {
668 detail: &Unfocused,
669 bulk: &Unchosen,
670 shell: &Quiet,
671 library: &Empty,
672 bar: &Still,
673 config: store,
674 sync: &sync,
675 files: &files,
676 export: &Idle,
677 naming: &Unnamed,
678 importing: &NoImport,
679 integrity: &Sound,
680 editor: &Unedited,
681 forge: &Unforged,
682 queue: &Unqueued,
683 filters: &Unfiltered,
684 themes: &themes,
685 };
686 router().handle(&state, request)
687 }
688
689 #[test]
690 fn exporting_a_theme_hands_back_the_file_rather_than_naming_a_path() {
691 // First consumer of `Outcome::File` on this host (`67881a88`). The
692 // description never names a path: the route answers with the bytes and a
693 // suggested name, and where they land is the host's -- a save dialog here,
694 // the working directory on a terminal, a download in a browser.
695 let store = Store::with(&[(ConfigKey::Theme, "audiofiles")]);
696 let response = settling(&store, Request::post("/settings/theme/export"))
697 .expect("the active theme has a source, so it exports");
698
699 let Outcome::File { name, kind, bytes } = &response.outcome else {
700 panic!("expected a file, got {:?}", response.outcome);
701 };
702 assert_eq!(name, "audiofiles.toml");
703 assert_eq!(kind, &quasi_router::Accepted::suffix(".toml"));
704 assert!(String::from_utf8_lossy(bytes).contains("[color]"));
705 }
706
707 #[test]
708 fn a_theme_with_no_readable_source_is_not_offered_for_export() {
709 // `nord` in the fixture has `source: None`, which is a built-in with nothing
710 // to read. Offering Export on it would hand the user an empty file.
711 let store = Store::with(&[(ConfigKey::Theme, "nord")]);
712 assert!(
713 settling(&store, Request::post("/settings/theme/export")).is_err(),
714 "a theme with no source was exported anyway"
715 );
716
717 let screen = match settling(&store, Request::get("/settings"))
718 .expect("settings answers")
719 .outcome
720 {
721 Outcome::Screen(screen) => screen,
722 other => panic!("expected a screen, got {other:?}"),
723 };
724 assert!(
725 !acts(&screen)
726 .iter()
727 .any(|label| label == "Export current theme"),
728 "the act is on the screen for a theme that cannot answer it"
729 );
730 }
731
732 #[test]
733 fn a_toggle_reads_what_is_stored_and_writes_what_was_sent() {
734 let store = Store::with(&[(ConfigKey::PreviewLoop, "1")]);
735 let themes = themes();
736 let sync = Offline;
737 let files = FakeFiles::default();
738 let state = Panels {
739 detail: &Unfocused,
740 bulk: &Unchosen,
741 shell: &Quiet,
742 library: &Empty,
743 bar: &Still,
744 config: &store,
745 sync: &sync,
746 files: &files,
747 export: &Idle,
748 naming: &Unnamed,
749 importing: &NoImport,
750 integrity: &Sound,
751 editor: &Unedited,
752 forge: &Unforged,
753 queue: &Unqueued,
754 filters: &Unfiltered,
755 themes: &themes,
756 };
757
758 let response = router()
759 .handle(&state, Request::get("/settings"))
760 .expect("answered");
761 let on = fields(screen_of(&response));
762 assert_eq!(
763 on.get(ConfigKey::PreviewLoop.as_str()),
764 Some(&Some("on".to_owned())),
765 "a stored 1 draws as ticked"
766 );
767
768 router()
769 .handle(
770 &state,
771 Request::post(format!(
772 "/settings/config/{}",
773 ConfigKey::PreviewLoop.as_str()
774 ))
775 .sending(Params::new().with(ConfigKey::PreviewLoop.as_str().to_owned(), String::new())),
776 )
777 .expect("answered");
778 assert_eq!(store.get(ConfigKey::PreviewLoop).as_deref(), Some(""));
779 }
780
781 #[test]
782 fn a_setting_this_app_never_declared_is_a_not_found() {
783 // `ConfigKey::from_key` is the same refusal the rest of the app makes, and
784 // the address is reachable by typing, so it is a 404 rather than a 500.
785 let store = Store::default();
786 let themes = themes();
787 let sync = Offline;
788 let files = FakeFiles::default();
789 let state = Panels {
790 detail: &Unfocused,
791 bulk: &Unchosen,
792 shell: &Quiet,
793 library: &Empty,
794 bar: &Still,
795 config: &store,
796 sync: &sync,
797 files: &files,
798 export: &Idle,
799 naming: &Unnamed,
800 importing: &NoImport,
801 integrity: &Sound,
802 editor: &Unedited,
803 forge: &Unforged,
804 queue: &Unqueued,
805 filters: &Unfiltered,
806 themes: &themes,
807 };
808 let refused = router().handle(
809 &state,
810 Request::post("/settings/config/not_a_setting")
811 .sending(Params::new().with("not_a_setting".to_owned(), "x".to_owned())),
812 );
813 let error = refused.expect_err("an undeclared key is refused");
814 assert_eq!(error.class, quasi_router::Class::NotFound);
815 }
816
817 #[test]
818 fn a_column_is_five_described_names_against_one_stored_blob() {
819 // The reconciliation this port owns: the user sees five booleans and the
820 // store keeps one JSON value, and the route is where the two meet. A
821 // description that named the blob would be describing a storage format.
822 let store = Store::default();
823 let themes = themes();
824 let sync = Offline;
825 let files = FakeFiles::default();
826 let state = Panels {
827 detail: &Unfocused,
828 bulk: &Unchosen,
829 shell: &Quiet,
830 library: &Empty,
831 bar: &Still,
832 config: &store,
833 sync: &sync,
834 files: &files,
835 export: &Idle,
836 naming: &Unnamed,
837 importing: &NoImport,
838 integrity: &Sound,
839 editor: &Unedited,
840 forge: &Unforged,
841 queue: &Unqueued,
842 filters: &Unfiltered,
843 themes: &themes,
844 };
845
846 // Absent means shown, which is what a fresh install does.
847 let response = router()
848 .handle(&state, Request::get("/settings"))
849 .expect("answered");
850 assert_eq!(
851 fields(screen_of(&response)).get("column.bpm"),
852 Some(&Some("on".to_owned()))
853 );
854
855 // Turning one off leaves the others alone.
856 router()
857 .handle(
858 &state,
859 Request::post("/settings/config/column.bpm")
860 .sending(Params::new().with("column.bpm".to_owned(), String::new())),
861 )
862 .expect("answered");
863 let stored = store.get(ConfigKey::ColumnConfig).expect("written");
864 assert!(stored.contains("\"show_bpm\":false"), "{stored}");
865
866 let response = router()
867 .handle(&state, Request::get("/settings"))
868 .expect("answered");
869 let named = fields(screen_of(&response));
870 assert_eq!(named.get("column.bpm"), Some(&Some(String::new())));
871 assert_eq!(
872 named.get("column.key"),
873 Some(&Some("on".to_owned())),
874 "turning one column off turned another off too"
875 );
876 }
877
878 #[test]
879 fn the_theme_picker_offers_what_the_host_resolved() {
880 // The settled host-boundary rule, applied first time out: a host fact
881 // readable at startup goes in `S` rather than through a capability surface.
882 let store = Store::with(&[(ConfigKey::Theme, "nord")]);
883 let themes = themes();
884 let sync = Offline;
885 let files = FakeFiles::default();
886 let state = Panels {
887 detail: &Unfocused,
888 bulk: &Unchosen,
889 shell: &Quiet,
890 library: &Empty,
891 bar: &Still,
892 config: &store,
893 sync: &sync,
894 files: &files,
895 export: &Idle,
896 naming: &Unnamed,
897 importing: &NoImport,
898 integrity: &Sound,
899 editor: &Unedited,
900 forge: &Unforged,
901 queue: &Unqueued,
902 filters: &Unfiltered,
903 themes: &themes,
904 };
905 let response = router()
906 .handle(&state, Request::get("/settings"))
907 .expect("answered");
908
909 // A field and not a `Node::Select`: `Selector` is a strip of a handful of
910 // choices, and thirty-odd themes that fold away is a dropdown.
911 let picker = screen_of(&response)
912 .slots
913 .iter()
914 .flat_map(|slot| &slot.body)
915 .find_map(|placed| match &placed.node {
916 Node::Field(field) if field.name == ConfigKey::Theme.as_str() => Some(field),
917 _ => None,
918 })
919 .expect("the screen offers a theme picker");
920
921 assert_eq!(picker.kind, quasi_router::layout::FieldKind::Select);
922 assert_eq!(picker.value.as_deref(), Some("nord"));
923 assert_eq!(picker.options.len(), 2);
924 // The finding, asserted rather than described in prose: the variant is in
925 // the label because `Choice` has nowhere else to put it. When grouping
926 // arrives, this assertion is what should have to change.
927 assert!(
928 picker
929 .options
930 .iter()
931 .any(|choice| choice.label.contains("dark")),
932 "the variant survived only by riding in the label"
933 );
934 }
935
936 #[test]
937 fn resetting_the_columns_says_it_did() {
938 let store = Store::with(&[(ConfigKey::ColumnConfig, "{\"show_bpm\":false}")]);
939 let themes = themes();
940 let sync = Offline;
941 let files = FakeFiles::default();
942 let state = Panels {
943 detail: &Unfocused,
944 bulk: &Unchosen,
945 shell: &Quiet,
946 library: &Empty,
947 bar: &Still,
948 config: &store,
949 sync: &sync,
950 files: &files,
951 export: &Idle,
952 naming: &Unnamed,
953 importing: &NoImport,
954 integrity: &Sound,
955 editor: &Unedited,
956 forge: &Unforged,
957 queue: &Unqueued,
958 filters: &Unfiltered,
959 themes: &themes,
960 };
961 let response = router()
962 .handle(&state, Request::post("/settings/columns/reset"))
963 .expect("answered");
964 assert_eq!(store.get(ConfigKey::ColumnConfig).as_deref(), Some(""));
965 assert!(
966 response.notice.is_some(),
967 "a destructive-looking control that says nothing is one the user cannot tell worked"
968 );
969 }
970
971 /// Sync in whatever state a test wants, recording what was asked of it.
972 struct FakeSync {
973 status: Status,
974 calls: RefCell<Vec<String>>,
975 /// What the subscription fetch has answered, if it has.
976 subscription: Option<Subscription>,
977 /// Whether pricing has arrived.
978 priced: bool,
979 /// What the vault says would upload, if the screen can look.
980 library_bytes: Option<i64>,
981 }
982
983 impl FakeSync {
984 fn in_state(state: State) -> Self {
985 Self {
986 status: Status {
987 state,
988 last_sync_at: None,
989 pending_changes: 0,
990 last_error: None,
991 auto_sync_enabled: false,
992 sync_interval_minutes: 15,
993 },
994 calls: RefCell::new(Vec::new()),
995 subscription: None,
996 priced: true,
997 library_bytes: Some(0),
998 }
999 }
1000
1001 fn called(&self) -> Vec<String> {
1002 self.calls.borrow().clone()
1003 }
1004
1005 fn note(&self, what: &str) {
1006 self.calls.borrow_mut().push(what.to_owned());
1007 }
1008 }
1009
1010 impl Sync for FakeSync {
1011 fn status(&self) -> Status {
1012 self.status.clone()
1013 }
1014
1015 fn connect(&self) -> Result<String, String> {
1016 self.note("connect");
1017 Ok("https://makenot.work/auth?code=abc".to_owned())
1018 }
1019
1020 fn cancel(&self) {
1021 self.note("cancel");
1022 }
1023
1024 fn set_password(&self, _password: &str, is_new: bool) {
1025 self.note(if is_new {
1026 "set_password:new"
1027 } else {
1028 "set_password:unlock"
1029 });
1030 }
1031
1032 fn sync_now(&self) {
1033 self.note("sync_now");
1034 }
1035
1036 fn set_auto(&self, enabled: bool) {
1037 self.note(if enabled { "auto:on" } else { "auto:off" });
1038 }
1039
1040 fn set_interval(&self, minutes: u32) {
1041 self.note(&format!("interval:{minutes}"));
1042 }
1043
1044 fn clear_error(&self) {
1045 self.note("clear_error");
1046 }
1047
1048 fn disconnect(&self) {
1049 self.note("disconnect");
1050 }
1051
1052 fn subscription(&self) -> Option<Subscription> {
1053 self.subscription.clone()
1054 }
1055
1056 fn pricing(&self) -> Option<Pricing> {
1057 self.priced.then_some(Pricing {
1058 min_bytes: 10 * GIB,
1059 max_bytes: 2048 * GIB,
1060 })
1061 }
1062
1063 fn synced_library_bytes(&self) -> Option<i64> {
1064 self.library_bytes
1065 }
1066
1067 fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 {
1068 // A stand-in for the server's model: a dollar a gibibyte a month, and
1069 // two months free on the year. The screen never computes a price, so
1070 // what matters here is only that the number reaches the label.
1071 let monthly = (cap_bytes / GIB) * 100;
1072 if annual { monthly * 10 } else { monthly }
1073 }
1074
1075 fn refresh_subscription(&self) {
1076 self.note("refresh_subscription");
1077 }
1078
1079 fn subscribe(&self, cap_bytes: i64, annual: bool) {
1080 self.note(&format!(
1081 "subscribe:{}:{}",
1082 cap_bytes / GIB,
1083 if annual { "annual" } else { "monthly" }
1084 ));
1085 }
1086
1087 fn queue_cap_change(&self, cap_bytes: i64) {
1088 self.note(&format!("cap:{}", cap_bytes / GIB));
1089 }
1090 }
1091
1092 /// One gibibyte, as the routes count caps.
1093 const GIB: i64 = 1024 * 1024 * 1024;
1094
1095 /// A subscription that is running.
1096 fn active(limit_gib: i64, used_gib: i64) -> Subscription {
1097 Subscription {
1098 active: true,
1099 limit_bytes: limit_gib * GIB,
1100 used_bytes: used_gib * GIB,
1101 interval: "monthly".to_owned(),
1102 pending_limit_bytes: None,
1103 }
1104 }
1105
1106 /// A router call against a sync in this state.
1107 fn syncing(sync: &FakeSync, request: Request) -> Result<Response, quasi_router::RouteError> {
1108 let store = Store::default();
1109 let themes = themes();
1110 let files = FakeFiles::default();
1111 let state = Panels {
1112 detail: &Unfocused,
1113 bulk: &Unchosen,
1114 shell: &Quiet,
1115 library: &Empty,
1116 bar: &Still,
1117 config: &store,
1118 sync,
1119 files: &files,
1120 export: &Idle,
1121 naming: &Unnamed,
1122 importing: &NoImport,
1123 integrity: &Sound,
1124 editor: &Unedited,
1125 forge: &Unforged,
1126 queue: &Unqueued,
1127 filters: &Unfiltered,
1128 themes: &themes,
1129 };
1130 router().handle(&state, request)
1131 }
1132
1133 /// Every act on a screen, by label.
1134 fn acts(screen: &Screen) -> Vec<String> {
1135 screen
1136 .slots
1137 .iter()
1138 .flat_map(|slot| &slot.body)
1139 .filter_map(|placed| match &placed.node {
1140 Node::Act(act) => Some(act.label.clone()),
1141 _ => None,
1142 })
1143 .collect()
1144 }
1145
1146 #[test]
1147 fn one_route_answers_four_shapes_because_a_state_is_not_an_address() {
1148 // A user cannot navigate to Authenticating; they arrive there because
1149 // something happened. Four addresses would be four places you could bookmark
1150 // into a lie.
1151 for (state, expected) in [
1152 (State::Disconnected, "Connect"),
1153 (State::Authenticating, "Cancel"),
1154 (State::Ready, "Sync now"),
1155 ] {
1156 let sync = FakeSync::in_state(state);
1157 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1158 let labels = acts(screen_of(&response));
1159 assert!(
1160 labels.iter().any(|label| label == expected),
1161 "{state:?} offered {labels:?}, wanted {expected}"
1162 );
1163 }
1164 }
1165
1166 #[test]
1167 fn connecting_answers_with_somewhere_to_go_rather_than_a_screen() {
1168 // The case `Destination::External` exists for, and what replaces the three
1169 // `#[cfg(target_os)]` branches the shipped panel keeps inside a drawing
1170 // function.
1171 let sync = FakeSync::in_state(State::Disconnected);
1172 let response = syncing(&sync, Request::post("/sync/connect")).expect("answered");
1173 match &response.outcome {
1174 Outcome::Goto(action) => {
1175 assert!(action.destination.is_external(), "{action:?}");
1176 assert!(action.destination.as_str().starts_with("https://"));
1177 }
1178 other => panic!("expected somewhere to go, got {other:?}"),
1179 }
1180 assert_eq!(sync.called(), ["connect"]);
1181 }
1182
1183 #[test]
1184 fn the_password_screen_knows_whether_it_is_setting_or_unlocking() {
1185 // `has_server_key` changes what is said and not what shape it is, and the
1186 // manager needs it: choosing a password is not the same call as supplying
1187 // one.
1188 for (has_server_key, expected) in [(false, "set_password:new"), (true, "set_password:unlock")] {
1189 let sync = FakeSync::in_state(State::NeedsEncryption { has_server_key });
1190 syncing(
1191 &sync,
1192 Request::post("/sync/encryption")
1193 .sending(Params::new().with("password".to_owned(), "hunter2".to_owned())),
1194 )
1195 .expect("answered");
1196 assert_eq!(sync.called(), [expected]);
1197 }
1198 }
1199
1200 #[test]
1201 fn an_empty_password_is_refused_without_reaching_the_manager() {
1202 let sync = FakeSync::in_state(State::NeedsEncryption {
1203 has_server_key: false,
1204 });
1205 let response = syncing(
1206 &sync,
1207 Request::post("/sync/encryption")
1208 .sending(Params::new().with("password".to_owned(), String::new())),
1209 )
1210 .expect("answered");
1211 assert!(response.notice.is_some(), "the refusal said nothing");
1212 assert!(
1213 sync.called().is_empty(),
1214 "an empty password reached the manager"
1215 );
1216 }
1217
1218 #[test]
1219 fn the_password_is_never_carried_back_into_the_description() {
1220 // `39057019`: a Secret field refuses to hold a value, so the runtime's
1221 // buffer is the only place the typed password has ever lived.
1222 let sync = FakeSync::in_state(State::NeedsEncryption {
1223 has_server_key: false,
1224 });
1225 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1226 let carried: Vec<_> = screen_of(&response)
1227 .slots
1228 .iter()
1229 .flat_map(|slot| &slot.body)
1230 .filter_map(|placed| match &placed.node {
1231 Node::Form { fields, .. } => Some(fields.clone()),
1232 _ => None,
1233 })
1234 .flatten()
1235 .collect();
1236 let password = carried
1237 .iter()
1238 .find(|field| field.name == "password")
1239 .expect("the screen asks for a password");
1240 assert_eq!(password.kind, quasi_router::layout::FieldKind::Secret);
1241 assert_eq!(password.value, None, "the description carried a secret");
1242 }
1243
1244 #[test]
1245 fn a_running_sync_is_drawn_and_does_not_answer() {
1246 // Present, visible and not answering, which is what a control that is
1247 // already running should be.
1248 let sync = FakeSync::in_state(State::Syncing);
1249 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1250 let now = screen_of(&response)
1251 .slots
1252 .iter()
1253 .flat_map(|slot| &slot.body)
1254 .find_map(|placed| match &placed.node {
1255 Node::Act(act) if act.label == "Sync now" => Some(act),
1256 _ => None,
1257 })
1258 .expect("the screen offers Sync now");
1259 // Read off the member rather than through a predicate: `Act::disabled` is
1260 // the builder on this type, where `makeover_layout::Act::disabled` is the
1261 // question. One name, two crates, opposite parts of speech.
1262 assert_eq!(
1263 now.state,
1264 Some(quasi_router::layout::State::Disabled),
1265 "a sync already running still answered"
1266 );
1267 }
1268
1269 #[test]
1270 fn an_interval_the_panel_does_not_offer_is_refused() {
1271 let sync = FakeSync::in_state(State::Ready);
1272 let refused = syncing(
1273 &sync,
1274 Request::post("/sync/interval")
1275 .sending(Params::new().with(Node::SELECTED.to_owned(), "7".to_owned())),
1276 );
1277 assert!(refused.is_err(), "an unoffered cadence was accepted");
1278 assert!(sync.called().is_empty());
1279
1280 let accepted = syncing(
1281 &sync,
1282 Request::post("/sync/interval")
1283 .sending(Params::new().with(Node::SELECTED.to_owned(), "30".to_owned())),
1284 );
1285 assert!(accepted.is_ok());
1286 assert_eq!(sync.called(), ["interval:30"]);
1287 }
1288
1289 #[test]
1290 fn a_failure_is_reported_in_every_state_and_retry_only_where_it_means_something() {
1291 for (state, retryable) in [(State::Disconnected, false), (State::Ready, true)] {
1292 let mut sync = FakeSync::in_state(state);
1293 sync.status.last_error = Some("the server said no".to_owned());
1294 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1295 let screen = screen_of(&response);
1296
1297 let said = screen.slots.iter().flat_map(|slot| &slot.body).any(
1298 |placed| matches!(&placed.node, Node::Notice { text, .. } if text.contains("said no")),
1299 );
1300 assert!(said, "{state:?} swallowed the error");
1301
1302 let labels = acts(screen);
1303 assert!(labels.iter().any(|label| label == "Dismiss"));
1304 assert_eq!(
1305 labels.iter().any(|label| label == "Retry"),
1306 retryable,
1307 "{state:?} offered the wrong escape: {labels:?}"
1308 );
1309 }
1310 }
1311
1312 /// Every form on a screen, as (submit label, action path, field names).
1313 fn forms(screen: &Screen) -> Vec<(String, String, Vec<String>)> {
1314 screen
1315 .slots
1316 .iter()
1317 .flat_map(|slot| &slot.body)
1318 .flat_map(|placed| match &placed.node {
1319 Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(),
1320 other => vec![other.clone()],
1321 })
1322 .filter_map(|node| match node {
1323 Node::Form {
1324 submit,
1325 action,
1326 fields,
1327 } => Some((
1328 submit,
1329 action.destination.as_str().to_owned(),
1330 fields.iter().map(|f| f.name.clone()).collect(),
1331 )),
1332 _ => None,
1333 })
1334 .collect()
1335 }
1336
1337 #[test]
1338 fn a_subscription_that_has_not_arrived_is_pending_rather_than_absent() {
1339 // `None` is "not fetched yet", which is what Readiness says and what the
1340 // shipped panel spends two Instants and a thirty-second timeout on.
1341 let sync = FakeSync::in_state(State::Ready);
1342 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1343 let region = screen_of(&response)
1344 .slots
1345 .iter()
1346 .flat_map(|slot| &slot.body)
1347 .find_map(|placed| match &placed.node {
1348 Node::Region(slot) if slot.id == "subscription" => Some(slot),
1349 _ => None,
1350 })
1351 .expect("the screen has a subscription region");
1352 assert_eq!(region.readiness, quasi_router::layout::Readiness::Pending);
1353 }
1354
1355 #[test]
1356 fn one_form_carries_the_cap_and_the_cadence_because_a_form_has_one_submit() {
1357 // The redesign the vocabulary forced: the shipped panel has one cap and two
1358 // priced buttons, and `Node::Form` carries one action and one submit.
1359 let mut sync = FakeSync::in_state(State::Ready);
1360 sync.subscription = Some(Subscription {
1361 active: false,
1362 limit_bytes: 0,
1363 used_bytes: 0,
1364 interval: "monthly".to_owned(),
1365 pending_limit_bytes: None,
1366 });
1367 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1368 let found = forms(screen_of(&response));
1369 let offer = found
1370 .iter()
1371 .find(|(_, action, _)| action == "/sync/subscribe")
1372 .expect("the screen offers a subscription");
1373 assert_eq!(offer.0, "Subscribe");
1374 assert_eq!(offer.2, ["cap_gib", "cadence"], "{offer:?}");
1375 }
1376
1377 #[test]
1378 fn subscribing_sends_the_cap_and_the_cadence_it_was_given() {
1379 let mut sync = FakeSync::in_state(State::Ready);
1380 sync.subscription = Some(Subscription {
1381 active: false,
1382 limit_bytes: 0,
1383 used_bytes: 0,
1384 interval: "monthly".to_owned(),
1385 pending_limit_bytes: None,
1386 });
1387 syncing(
1388 &sync,
1389 Request::post("/sync/subscribe").sending(
1390 Params::new()
1391 .with("cap_gib".to_owned(), "100".to_owned())
1392 .with("cadence".to_owned(), "annual".to_owned()),
1393 ),
1394 )
1395 .expect("answered");
1396 assert_eq!(sync.called(), ["subscribe:100:annual"]);
1397 }
1398
1399 #[test]
1400 fn a_cap_outside_what_is_sold_never_reaches_the_manager() {
1401 // Money: the route checks the bounds again rather than trusting the field,
1402 // because the address is reachable by typing.
1403 let sync = FakeSync::in_state(State::Ready);
1404 for out_of_range in ["1", "9999"] {
1405 let refused = syncing(
1406 &sync,
1407 Request::post("/sync/subscribe").sending(
1408 Params::new()
1409 .with("cap_gib".to_owned(), out_of_range.to_owned())
1410 .with("cadence".to_owned(), "monthly".to_owned()),
1411 ),
1412 );
1413 assert!(refused.is_err(), "{out_of_range} GiB was accepted");
1414 }
1415 assert!(
1416 sync.called().is_empty(),
1417 "a cap outside the offer reached the manager"
1418 );
1419
1420 // And one inside it does.
1421 syncing(
1422 &sync,
1423 Request::post("/sync/cap")
1424 .sending(Params::new().with("cap_gib".to_owned(), "50".to_owned())),
1425 )
1426 .expect("answered");
1427 assert_eq!(sync.called(), ["cap:50"]);
1428 }
1429
1430 #[test]
1431 fn a_cap_asked_for_before_pricing_arrived_is_refused_rather_than_guessed() {
1432 // Without pricing there are no bounds, and a purchase route that cannot
1433 // check its bounds must not proceed.
1434 let mut sync = FakeSync::in_state(State::Ready);
1435 sync.priced = false;
1436 let refused = syncing(
1437 &sync,
1438 Request::post("/sync/subscribe").sending(
1439 Params::new()
1440 .with("cap_gib".to_owned(), "50".to_owned())
1441 .with("cadence".to_owned(), "monthly".to_owned()),
1442 ),
1443 );
1444 assert!(refused.is_err());
1445 assert!(sync.called().is_empty());
1446 }
1447
1448 #[test]
1449 fn a_running_subscription_shows_what_it_holds_and_how_full_it_is() {
1450 let mut sync = FakeSync::in_state(State::Ready);
1451 sync.subscription = Some(active(100, 90));
1452 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1453
1454 let meter = screen_of(&response)
1455 .slots
1456 .iter()
1457 .flat_map(|slot| &slot.body)
1458 .flat_map(|placed| match &placed.node {
1459 Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(),
1460 other => vec![other.clone()],
1461 })
1462 .find_map(|node| match node {
1463 Node::Meter(meter) => Some(meter),
1464 _ => None,
1465 })
1466 .expect("a subscription draws how full it is");
1467
1468 assert_eq!(meter.done, 90);
1469 assert_eq!(meter.total, 100);
1470 // The tone is carried because no renderer can work it out: 90% of a paid cap
1471 // is a warning and 90% of a subtask rollup is a success.
1472 assert_eq!(meter.tone, quasi_router::layout::Tone::Warning);
1473 }
1474
1475 #[test]
1476 fn every_offered_cap_carries_its_own_price() {
1477 // This replaces `the_price_shown_is_the_committed_cap_and_not_a_live_quote`,
1478 // which asserted the old shape: one hint, pricing the committed cap, because
1479 // nothing describes a display derived from a control's own uncommitted
1480 // value (quasicoherent `57c21152`).
1481 //
1482 // The redesign dissolves that finding for this control rather than working
1483 // around it. The cap is now chosen from named sizes and each option carries
1484 // its own price, so there is no uncommitted value to derive a display from -
1485 // the price is on the label the user is reading when they choose. Both
1486 // cadences are on every label too, so the cadence field cannot leave a price
1487 // stale underneath it.
1488 let mut sync = FakeSync::in_state(State::Ready);
1489 sync.subscription = Some(active(20, 1));
1490 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1491 let screen = screen_of(&response);
1492 assert!(
1493 forms(screen)
1494 .iter()
1495 .any(|(_, action, _)| action == "/sync/cap"),
1496 "a running subscription offers a cap change"
1497 );
1498
1499 let cap = cap_field_of(screen).expect("the cap is chosen from named sizes");
1500 assert!(
1501 !cap.options.is_empty(),
1502 "the cap is a choice, not a bare number"
1503 );
1504 for choice in &cap.options {
1505 let gib: i64 = choice.value.parse().expect("a choice is a cap in GiB");
1506 // The fixture prices a dollar a gibibyte a month, ten months a year.
1507 assert!(
1508 choice.label.contains(&format!("${gib} a month")),
1509 "every option prices itself monthly: {}",
1510 choice.label
1511 );
1512 assert!(
1513 choice.label.contains(&format!("${} a year", gib * 10)),
1514 "and annually, so the cadence field cannot stale it: {}",
1515 choice.label
1516 );
1517 }
1518
1519 // The committed cap is 20 GiB, which is not one of the named sizes. It is
1520 // still selected, rather than the group reading as unanswered.
1521 assert_eq!(cap.value.as_deref(), Some("20"));
1522 assert!(
1523 cap.options.iter().any(|c| c.value == "20"),
1524 "a cap off the named list is still one of the options"
1525 );
1526 }
1527
1528 /// The cap field, wherever in the screen's regions it landed.
1529 fn cap_field_of(screen: &Screen) -> Option<quasi_router::Field> {
1530 nodes_deep(screen).into_iter().find_map(|node| match node {
1531 Node::Form { fields, .. } => fields
1532 .iter()
1533 .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Radio)
1534 .cloned(),
1535 _ => None,
1536 })
1537 }
1538
1539 /// A subscription that has lapsed, which is what puts the subscribe screen up.
1540 fn lapsed() -> Subscription {
1541 Subscription {
1542 active: false,
1543 limit_bytes: 0,
1544 used_bytes: 0,
1545 interval: "monthly".to_owned(),
1546 pending_limit_bytes: None,
1547 }
1548 }
1549
1550 #[test]
1551 fn the_subscribe_screen_sizes_the_proposal_to_the_library() {
1552 // The measurement the redesign turns on: the app already knows how much
1553 // would upload, so it proposes a cap instead of soliciting one. 400 GiB of
1554 // samples wants half again as headroom - 600 - and the smallest named size
1555 // that covers 600 is 1024.
1556 let mut sync = FakeSync::in_state(State::Ready);
1557 sync.subscription = Some(lapsed());
1558 sync.library_bytes = Some(400 * GIB);
1559
1560 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1561 let screen = screen_of(&response);
1562
1563 assert!(
1564 said_deep(screen).contains("400 GiB"),
1565 "the screen states the need it sized against: {}",
1566 said_deep(screen)
1567 );
1568 let cap = cap_field_of(screen).expect("a cap is offered");
1569 assert_eq!(
1570 cap.value.as_deref(),
1571 Some("1024"),
1572 "the smallest named cap covering 400 GiB plus half again"
1573 );
1574 }
1575
1576 #[test]
1577 fn nothing_set_to_sync_proposes_the_floor_and_says_why() {
1578 // `Some(0)` is a real answer and a different one from "cannot look": no
1579 // vault has file sync on, so nothing would upload. Proposing the floor is
1580 // right, and so is saying why rather than showing a confident 250 GiB with
1581 // no reason attached.
1582 let mut sync = FakeSync::in_state(State::Ready);
1583 sync.subscription = Some(lapsed());
1584 sync.library_bytes = Some(0);
1585
1586 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1587 let screen = screen_of(&response);
1588
1589 assert!(
1590 said_deep(screen).contains("No vault is set to sync"),
1591 "{}",
1592 said_deep(screen)
1593 );
1594 let cap = cap_field_of(screen).expect("a cap is offered");
1595 assert_eq!(
1596 cap.value.as_deref(),
1597 Some("250"),
1598 "the cheapest thing on offer"
1599 );
1600 }
1601
1602 #[test]
1603 fn a_library_that_cannot_be_read_claims_no_size() {
1604 // `None` is "cannot look". The screen must not invent a need, and must not
1605 // print "0" as though it had measured one.
1606 let mut sync = FakeSync::in_state(State::Ready);
1607 sync.subscription = Some(lapsed());
1608 sync.library_bytes = None;
1609
1610 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1611 let screen = screen_of(&response);
1612
1613 let text = said_deep(screen);
1614 assert!(
1615 !text.contains("would upload"),
1616 "no measurement is claimed: {text}"
1617 );
1618 assert!(
1619 !text.contains("Proposed:"),
1620 "and nothing is proposed as sized: {text}"
1621 );
1622 let cap = cap_field_of(screen).expect("a cap is still offered");
1623 assert_eq!(cap.value.as_deref(), Some("250"));
1624 }
1625
1626 #[test]
1627 fn a_filling_cap_warns_before_the_upload_fails() {
1628 // Item 5 of the redesign. Today the first news of a full cap is a 402 from
1629 // the blob route, which the user meets as a sync that broke. The screen
1630 // knows the number, so it says the consequence first - and says that
1631 // metadata sync carries on, which is the half that makes it not an outage.
1632 let mut sync = FakeSync::in_state(State::Ready);
1633 sync.subscription = Some(active(1024, 1000));
1634 sync.library_bytes = Some(1000 * GIB);
1635
1636 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1637 let text = said_deep(screen_of(&response));
1638
1639 assert!(text.contains("close to your storage cap"), "{text}");
1640 assert!(
1641 text.contains("everything else keeps syncing"),
1642 "the consequence is bounded, not an outage: {text}"
1643 );
1644 // 1000 GiB plus half again is 1500, so 2048 is the smallest named cap that
1645 // holds it, and the warning carries what that costs.
1646 assert!(
1647 text.contains("2048 GiB") || text.contains("2.0 TiB"),
1648 "{text}"
1649 );
1650 assert!(
1651 text.contains("$2048 a month"),
1652 "priced, at the fixture rate: {text}"
1653 );
1654 }
1655
1656 #[test]
1657 fn a_full_cap_says_uploads_have_already_stopped() {
1658 // The other side of the same sentence, and it is a different one: "will
1659 // stop" and "have stopped" are not degrees of one message.
1660 let mut sync = FakeSync::in_state(State::Ready);
1661 sync.subscription = Some(active(1024, 1024));
1662 sync.library_bytes = Some(1024 * GIB);
1663
1664 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1665 let text = said_deep(screen_of(&response));
1666
1667 assert!(text.contains("cap is full"), "{text}");
1668 assert!(text.contains("not uploading"), "{text}");
1669 }
1670
1671 #[test]
1672 fn a_cap_with_room_says_nothing_about_filling() {
1673 // The warning is a warning. A subscription at 5% must not carry it, or it
1674 // stops being read.
1675 let mut sync = FakeSync::in_state(State::Ready);
1676 sync.subscription = Some(active(1024, 50));
1677 sync.library_bytes = Some(50 * GIB);
1678
1679 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1680 let text = said_deep(screen_of(&response));
1681
1682 assert!(!text.contains("storage cap"), "{text}");
1683 assert!(!text.contains("cap is full"), "{text}");
1684 }
1685
1686 #[test]
1687 fn the_exact_cap_form_carries_the_bounds_it_claims() {
1688 // The old `cap_field` was a bare `FieldKind::Number` with no min and no max,
1689 // despite a doc comment saying the bounds were what a renderer draws. The
1690 // only thing rejecting an out-of-range cap was `cap_from`, after submit.
1691 let mut sync = FakeSync::in_state(State::Ready);
1692 sync.subscription = Some(lapsed());
1693
1694 let response = syncing(&sync, Request::get("/sync")).expect("answered");
1695 let screen = screen_of(&response);
1696
1697 let exact = nodes_deep(screen)
1698 .into_iter()
1699 .find_map(|node| match node {
1700 Node::Form { fields, .. } => fields
1701 .iter()
1702 .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Number)
1703 .cloned(),
1704 _ => None,
1705 })
1706 .expect("an exact cap can be typed");
1707
1708 assert_eq!(exact.min.as_deref(), Some("10"), "the fixture's floor");
1709 assert_eq!(exact.max.as_deref(), Some("2048"), "the fixture's ceiling");
1710 }
1711
1712 #[test]
1713 fn the_file_list_describes_a_column_per_shown_flag() {
1714 // The columns were described before this port existed:
1715 // `ui::file_list::describe` already built `makeover_layout::Column`s. What
1716 // the port adds is the address a heading calls.
1717 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
1718 let response = listing(&files, Request::get("/files")).expect("answered");
1719 let (columns, rows) = table_of(screen_of(&response));
1720
1721 let names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
1722 // Peak is off in the fixture, so it is not described at all.
1723 assert_eq!(names, ["Name", "Duration", "BPM", "Key", "Tags", "Play"]);
1724 assert_eq!(rows.len(), 1);
1725 assert_eq!(rows[0].values.len(), columns.len(), "a cell per column");
1726 }
1727
1728 #[test]
1729 fn only_the_columns_with_a_sort_carry_an_address() {
1730 // Peak and Tags have no sort of their own and never had one, which is what
1731 // `Column::sortable` says when it is false: headings rather than controls.
1732 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
1733 let response = listing(&files, Request::get("/files")).expect("answered");
1734 let (columns, _) = table_of(screen_of(&response));
1735
1736 for column in &columns {
1737 let addressed = column.reorder.is_some();
1738 let expected = matches!(column.name.as_str(), "Name" | "Duration" | "BPM" | "Key");
1739 assert_eq!(
1740 addressed, expected,
1741 "{} carried the wrong address",
1742 column.name
1743 );
1744 }
1745 }
1746
1747 #[test]
1748 fn the_column_in_force_carries_its_caret_and_the_others_do_not() {
1749 let mut files = FakeFiles::with(vec![sample(1, "kick.wav")]);
1750 files.by = "BPM".to_owned();
1751 files.ascending = false;
1752 let response = listing(&files, Request::get("/files")).expect("answered");
1753 let (columns, _) = table_of(screen_of(&response));
1754
1755 for column in &columns {
1756 let sorted = column.sorted;
1757 if column.name == "BPM" {
1758 assert_eq!(sorted, Some(quasi_router::layout::Sort::Descending));
1759 } else {
1760 assert_eq!(sorted, None, "{} claimed a sort", column.name);
1761 }
1762 }
1763 }
1764
1765 #[test]
1766 fn a_row_is_addressed_by_its_own_id_and_not_by_where_it_sits() {
1767 // An index is a fact about the current filter and sort, which is exactly
1768 // what an address should not be.
1769 let files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]);
1770 let response = listing(&files, Request::get("/files")).expect("answered");
1771 let (_, rows) = table_of(screen_of(&response));
1772
1773 let opens: Vec<String> = rows
1774 .iter()
1775 .filter_map(|row| row.activate.as_ref())
1776 .map(|action| action.destination.as_str().to_owned())
1777 .collect();
1778 assert_eq!(opens, ["/files/7/open", "/files/9/open"]);
1779 }
1780
1781 #[test]
1782 fn pressing_a_row_and_a_heading_reaches_the_app() {
1783 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1784 listing(&files, Request::post("/files/7/open")).expect("answered");
1785 listing(&files, Request::post("/files/7/play")).expect("answered");
1786 listing(&files, Request::post("/files/sort/BPM")).expect("answered");
1787 assert_eq!(files.asked(), ["open:7", "play:7", "sort:BPM"]);
1788 }
1789
1790 #[test]
1791 fn a_sample_row_offers_the_menu_the_shipped_one_offers() {
1792 // `Cells::menu`, the member this screen asked quasi-router for. The
1793 // assertion is against `draw_context_menu`'s sample branch: same acts, same
1794 // order, same words.
1795 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1796 let response = listing(&files, Request::get("/files")).expect("answered");
1797 let (_, rows) = table_of(screen_of(&response));
1798
1799 let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1800 assert_eq!(
1801 labels,
1802 [
1803 "Preview",
1804 "Copy Path",
1805 crate::ui::file_list_menus::reveal_label(),
1806 "Find Similar",
1807 "Find Duplicates",
1808 "Edit...",
1809 "Play as Instrument",
1810 "Export...",
1811 "Re-analyze...",
1812 "Delete",
1813 ],
1814 "the described menu drifted from the shipped one"
1815 );
1816
1817 // Addressed by the row's own id, like `activate` and for the same reason.
1818 let delete = rows[0].menu.last().expect("the menu ends in Delete");
1819 assert_eq!(delete.action.destination.as_str(), "/files/7/delete");
1820 // The one destructive entry says so, and carries the question rather than
1821 // leaving each renderer to invent one.
1822 assert_eq!(delete.tone, quasi_router::layout::Tone::Danger);
1823 assert_eq!(delete.confirm.as_deref(), Some("Delete kick.wav?"));
1824
1825 // No collection is open in the fixture, so the entry that only makes sense
1826 // inside one is absent rather than drawn dead.
1827 assert!(!labels.contains(&"Remove from Collection"));
1828 }
1829
1830 #[test]
1831 fn a_folder_row_offers_the_folder_menu_and_nothing_about_samples() {
1832 // The branch. A folder has no analysis to redo and nothing to preview, and
1833 // the shipped menu offers it five entries instead of ten.
1834 let files = FakeFiles::with(vec![folder(4, "Drums")]);
1835 let response = listing(&files, Request::get("/files")).expect("answered");
1836 let (columns, rows) = table_of(screen_of(&response));
1837
1838 let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1839 assert_eq!(
1840 labels,
1841 ["Open", "New Folder", "Rename", "Export...", "Delete"]
1842 );
1843
1844 // The two that were already described point at the addresses that already
1845 // answer them rather than at new ones.
1846 assert_eq!(rows[0].menu[1].action.destination.as_str(), "/folders/new");
1847 assert_eq!(
1848 rows[0].menu[2].action.destination.as_str(),
1849 "/folders/4/rename"
1850 );
1851
1852 // And a folder still fills every column, because cells are positional: the
1853 // Play cell is empty rather than missing.
1854 assert_eq!(rows[0].values.len(), columns.len());
1855 assert!(
1856 !rows[0]
1857 .values
1858 .last()
1859 .expect("a Play cell")
1860 .carries_control(),
1861 "a folder offered something to play"
1862 );
1863 }
1864
1865 #[test]
1866 fn a_cloud_only_row_offers_the_fetch_and_withholds_what_needs_the_bytes() {
1867 // The third branch, and the one a description could get quietly wrong: the
1868 // acts are all still *sayable* for a sample nobody has fetched, and offering
1869 // them would be offering acts that fail on a file that is not there.
1870 let files = FakeFiles::with(vec![cloud_only(9, "snare.wav")]);
1871 let response = listing(&files, Request::get("/files")).expect("answered");
1872 let (_, rows) = table_of(screen_of(&response));
1873
1874 let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1875 assert_eq!(
1876 labels,
1877 [
1878 "Download",
1879 "Copy Path",
1880 "Find Similar",
1881 "Find Duplicates",
1882 "Delete",
1883 ]
1884 );
1885 // Named rather than left to the count above: these four are the ones that
1886 // need the file on disk.
1887 for withheld in ["Preview", "Edit...", "Play as Instrument", "Re-analyze..."] {
1888 assert!(!labels.contains(&withheld), "{withheld} was offered");
1889 }
1890 }
1891
1892 #[test]
1893 fn a_collection_being_shown_adds_the_entry_that_only_means_something_there() {
1894 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1895 let response = listing_in_collection(&files, Request::get("/files")).expect("answered");
1896 let (_, rows) = table_of(screen_of(&response));
1897
1898 let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1899 assert!(labels.contains(&"Remove from Collection"), "{labels:?}");
1900 }
1901
1902 #[test]
1903 fn add_to_collection_is_one_entry_that_asks_which_one_rather_than_a_submenu() {
1904 // The member this entry waited on was never a submenu. `Act::asking` is a
1905 // control that wants a value before it fires, so the menu holds one line
1906 // however many collections exist, and the list is on the act.
1907 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1908 let response = listing_in_collection(&files, Request::get("/files")).expect("answered");
1909 let (_, rows) = table_of(screen_of(&response));
1910
1911 let add = rows[0]
1912 .menu
1913 .iter()
1914 .find(|act| act.label == "Add to Collection")
1915 .expect("the entry is offered when there is a collection to offer");
1916
1917 assert_eq!(add.action.destination.as_str(), "/files/7/collection/add");
1918 let asked = add.asks.first().expect("it asks which collection");
1919 assert_eq!(asked.name, "collection");
1920 assert_eq!(asked.kind, quasi_router::layout::FieldKind::Select);
1921 // The value is the id and the label is the name, so the handler reads a
1922 // number and the user reads a collection.
1923 let offered: Vec<(&str, &str)> = asked
1924 .options
1925 .iter()
1926 .map(|choice| (choice.value.as_str(), choice.label.as_str()))
1927 .collect();
1928 assert_eq!(offered, [("3", "Kicks")]);
1929 }
1930
1931 #[test]
1932 fn nothing_offers_add_to_collection_when_there_is_no_collection() {
1933 // Not drawn dead: an act asking a question with no answers is a control the
1934 // user can press and cannot satisfy.
1935 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1936 let response = listing(&files, Request::get("/files")).expect("answered");
1937 let (_, rows) = table_of(screen_of(&response));
1938
1939 let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1940 assert!(!labels.contains(&"Add to Collection"), "{labels:?}");
1941 }
1942
1943 #[test]
1944 fn adding_to_a_collection_carries_both_ids_to_the_app() {
1945 // The row comes from the address and the collection from what the act
1946 // asked, which is the whole difference between this entry and every other
1947 // one in the menu.
1948 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1949 let request = Request::post("/files/7/collection/add")
1950 .sending(Params::new().with("collection".to_owned(), "3".to_owned()));
1951 listing_in_collection(&files, request).expect("answered");
1952
1953 assert_eq!(files.asked(), ["collect:7->3"]);
1954 }
1955
1956 #[test]
1957 fn a_collection_that_went_away_is_refused_rather_than_guessed_at() {
1958 // The list the act offered was built when the menu opened. A value outside
1959 // it means the collection was deleted since, and adding to a collection
1960 // that is not there is not something to do quietly.
1961 let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1962 let request = Request::post("/files/7/collection/add")
1963 .sending(Params::new().with("collection".to_owned(), "99".to_owned()));
1964 let refused = listing_in_collection(&files, request);
1965
1966 assert!(refused.is_err(), "an unknown collection is not an add");
1967 assert!(files.asked().is_empty(), "and nothing reached the app");
1968 }
1969
1970 #[test]
1971 fn every_menu_entry_reaches_the_app_at_the_row_it_was_opened_on() {
1972 // The whole point of the member: the acts are addresses, and pressing one
1973 // has to arrive with the row's id rather than with whatever is selected. Two
1974 // rows in the fixture so an id that came from the selection would show up.
1975 let files = FakeFiles::with(vec![sample(7, "kick.wav"), folder(4, "Drums")]);
1976 for verb in [
1977 "enter",
1978 "path/copy",
1979 "reveal",
1980 "similar",
1981 "duplicates",
1982 "edit",
1983 "instrument",
1984 "reanalyze",
1985 "delete",
1986 "download",
1987 "collection/remove",
1988 ] {
1989 listing(&files, Request::post(format!("/files/7/{verb}"))).expect("answered");
1990 }
1991
1992 assert_eq!(
1993 files.asked(),
1994 [
1995 "enter:7",
1996 // The five that borrow a capability select the row and then let the
1997 // detail handle act on it, so what this fake sees is the selection.
1998 "open:7",
1999 "reveal:7",
2000 "open:7",
2001 "open:7",
2002 "open:7",
2003 "instrument:7",
2004 "reanalyze:7",
2005 "delete:7",
2006 "download:7",
2007 "uncollect:7",
2008 ]
2009 );
2010 }
2011
2012 #[test]
2013 fn a_column_with_no_sort_is_refused_rather_than_ordered_by() {
2014 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
2015 let refused = listing(&files, Request::post("/files/sort/Tags"));
2016 assert!(refused.is_err(), "Tags was accepted as a sort");
2017 assert!(files.asked().is_empty());
2018 }
2019
2020 #[test]
2021 fn an_empty_list_says_so_and_offers_the_way_out() {
2022 // `703f4cd2`: the sentence and the way out are both on the node, because a
2023 // region with a heading and no rows still has content.
2024 let files = FakeFiles::with(Vec::new());
2025 let response = listing(&files, Request::get("/files")).expect("answered");
2026 let stand_in = screen_of(&response)
2027 .slots
2028 .iter()
2029 .flat_map(|slot| &slot.body)
2030 .find_map(|placed| match &placed.node {
2031 Node::StandIn { message, act, .. } => Some((message.clone(), act.clone())),
2032 _ => None,
2033 })
2034 .expect("an empty list says so");
2035 assert!(stand_in.0.contains("Nothing here"));
2036 assert!(stand_in.1.is_some(), "the empty list offered no way out");
2037 }
2038
2039 #[test]
2040 fn the_row_the_app_is_pointing_at_is_the_current_one() {
2041 let mut files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]);
2042 files.current = Some(9);
2043 let response = listing(&files, Request::get("/files")).expect("answered");
2044 let (_, rows) = table_of(screen_of(&response));
2045 assert_eq!(
2046 rows.iter().map(|row| row.current).collect::<Vec<_>>(),
2047 [false, true]
2048 );
2049 }
2050
2051 // --- the export flow ---
2052
2053 #[test]
2054 fn nothing_to_export_says_so_and_offers_no_way_to_start_one() {
2055 // The flow is entered from the file list, so a control here would be an
2056 // affordance the shipped app does not have.
2057 let export = FakeExport::at(Phase::Idle);
2058 let screen = exported(&export);
2059
2060 assert!(matches!(
2061 nodes(&screen).as_slice(),
2062 [Node::StandIn { act: None, .. }]
2063 ));
2064 }
2065
2066 #[test]
2067 fn the_configure_screen_names_what_is_going_and_what_it_will_be_written_as() {
2068 let export = FakeExport::at(Phase::Configuring {
2069 subjects: vec![subject("kick", 2.0), subject("snare", 1.0)],
2070 profiles: Vec::new(),
2071 settings: defaults(),
2072 });
2073 let screen = exported(&export);
2074 let said = said(&screen);
2075
2076 assert!(said.contains("2 samples to export"), "{said}");
2077 // No profiles, so the picker is not offered at all rather than offered
2078 // empty: an empty dropdown is a control that cannot be used.
2079 assert!(!said.contains("Device Profile"), "{said}");
2080 assert!(said.contains("Format"), "{said}");
2081 assert!(said.contains("Destination"), "{said}");
2082 }
2083
2084 #[test]
2085 fn copying_as_is_says_nothing_about_rates_and_re_encoding_says_everything() {
2086 // The rate and depth exist only when something is being re-encoded, which
2087 // is the shipped screen's own rule. A described screen that named them
2088 // under Original would be describing controls that do nothing.
2089 let original = FakeExport::at(Phase::Configuring {
2090 subjects: vec![subject("kick", 2.0)],
2091 profiles: Vec::new(),
2092 settings: defaults(),
2093 });
2094 let said_of_original = said(&exported(&original));
2095 assert!(
2096 !said_of_original.contains("Sample Rate"),
2097 "{said_of_original}"
2098 );
2099 assert!(
2100 !said_of_original.contains("strips embedded metadata"),
2101 "{said_of_original}"
2102 );
2103
2104 let wav = FakeExport::at(Phase::Configuring {
2105 subjects: vec![subject("kick", 2.0)],
2106 profiles: Vec::new(),
2107 settings: Settings {
2108 format: Format::Wav,
2109 ..defaults()
2110 },
2111 });
2112 let said_of_wav = said(&exported(&wav));
2113 assert!(said_of_wav.contains("Sample Rate"), "{said_of_wav}");
2114 assert!(said_of_wav.contains("Bit Depth"), "{said_of_wav}");
2115 assert!(
2116 said_of_wav.contains("strips embedded metadata"),
2117 "{said_of_wav}"
2118 );
2119 }
2120
2121 #[test]
2122 fn a_device_profile_takes_the_audio_settings_off_the_screen() {
2123 // The profile owns them, so a control for them would be one the export
2124 // pipeline overrides. The shipped screen hides the whole block; so does
2125 // this, and it says what the lock is hiding instead.
2126 let export = FakeExport::at(Phase::Configuring {
2127 subjects: vec![subject("kick", 2.0)],
2128 profiles: vec![ProfileChoice {
2129 name: "SP-404 MKII".to_owned(),
2130 manufacturer: "Roland".to_owned(),
2131 summary: Some("WAV, 44.1k, 16-bit, Mono".to_owned()),
2132 category: Some("Sampler".to_owned()),
2133 notes: None,
2134 max_file_size_bytes: None,
2135 }],
2136 settings: Settings {
2137 device_profile: Some("SP-404 MKII".to_owned()),
2138 ..defaults()
2139 },
2140 });
2141 let said = said(&exported(&export));
2142
2143 assert!(said.contains("Device Profile"), "{said}");
2144 assert!(said.contains("by Roland"), "{said}");
2145 assert!(said.contains("WAV, 44.1k, 16-bit, Mono"), "{said}");
2146 assert!(!said.contains("Sample Rate"), "{said}");
2147 assert!(!said.contains("Channels"), "{said}");
2148 }
2149
2150 #[test]
2151 fn a_sample_too_long_for_an_aiff_chunk_is_warned_about_before_anything_is_written() {
2152 // Four gigabytes at 48 kHz / 24-bit stereo is about 4 hours, so five hours
2153 // is over and one minute is not. The arithmetic is the shipped screen's.
2154 let over = FakeExport::at(Phase::Configuring {
2155 subjects: vec![subject("drone", 5.0 * 3600.0)],
2156 profiles: Vec::new(),
2157 settings: Settings {
2158 format: Format::Aiff,
2159 ..defaults()
2160 },
2161 });
2162 assert!(said(&exported(&over)).contains("AIFF chunks cap at 4 GB"),);
2163
2164 let under = FakeExport::at(Phase::Configuring {
2165 subjects: vec![subject("kick", 60.0)],
2166 profiles: Vec::new(),
2167 settings: Settings {
2168 format: Format::Aiff,
2169 ..defaults()
2170 },
2171 });
2172 assert!(!said(&exported(&under)).contains("AIFF chunks cap"),);
2173 }
2174
2175 #[test]
2176 fn a_sample_too_big_for_the_device_is_warned_about_by_name_when_it_is_the_only_one() {
2177 let profile = |cap: u64| ProfileChoice {
2178 name: "SP-404 MKII".to_owned(),
2179 manufacturer: "Roland".to_owned(),
2180 summary: None,
2181 category: None,
2182 notes: None,
2183 max_file_size_bytes: Some(cap),
2184 };
2185 let configuring = |subjects: Vec<Subject>, cap: u64| {
2186 FakeExport::at(Phase::Configuring {
2187 subjects,
2188 profiles: vec![profile(cap)],
2189 settings: Settings {
2190 device_profile: Some("SP-404 MKII".to_owned()),
2191 ..defaults()
2192 },
2193 })
2194 };
2195
2196 // One over the cap is named; two are counted. The difference is the shipped
2197 // screen's and it is worth keeping: a name is actionable and a count is not.
2198 let one = configuring(
2199 vec![subject("drone", 600.0), subject("kick", 0.5)],
2200 1_000_000,
2201 );
2202 let said_of_one = said(&exported(&one));
2203 assert!(
2204 said_of_one.contains("\"drone\" may exceed"),
2205 "{said_of_one}"
2206 );
2207
2208 let two = configuring(
2209 vec![subject("drone", 600.0), subject("pad", 700.0)],
2210 1_000_000,
2211 );
2212 let said_of_two = said(&exported(&two));
2213 assert!(
2214 said_of_two.contains("2 samples may exceed"),
2215 "{said_of_two}"
2216 );
2217 }
2218
2219 #[test]
2220 fn a_naming_pattern_is_previewed_against_the_first_sample_and_a_typo_is_reported() {
2221 let flattened = |pattern: &str| {
2222 FakeExport::at(Phase::Configuring {
2223 subjects: vec![subject("kick", 2.0)],
2224 profiles: Vec::new(),
2225 settings: Settings {
2226 flatten: true,
2227 naming_pattern: Some(pattern.to_owned()),
2228 ..defaults()
2229 },
2230 })
2231 };
2232
2233 let good = flattened("{name}-{bpm}");
2234 let said_of_good = said(&exported(&good));
2235 assert!(said_of_good.contains("Preview: kick-120"), "{said_of_good}");
2236
2237 // The point of the preview: a typo is caught before two hundred files are
2238 // written under it.
2239 let bad = flattened("{nmae}");
2240 let said_of_bad = said(&exported(&bad));
2241 assert!(said_of_bad.contains("Pattern:"), "{said_of_bad}");
2242 assert!(!said_of_bad.contains("Preview:"), "{said_of_bad}");
2243 }
2244
2245 #[test]
2246 fn a_naming_pattern_is_only_described_when_the_tree_is_being_flattened() {
2247 // It names files in one folder. With the tree preserved there is nothing
2248 // for it to do, and the shipped screen does not draw it either.
2249 let export = FakeExport::at(Phase::Configuring {
2250 subjects: vec![subject("kick", 2.0)],
2251 profiles: Vec::new(),
2252 settings: Settings {
2253 flatten: false,
2254 naming_pattern: Some("{name}".to_owned()),
2255 ..defaults()
2256 },
2257 });
2258 assert!(!said(&exported(&export)).contains("Naming Pattern"),);
2259 }
2260
2261 #[test]
2262 fn every_control_writes_through_one_route_and_an_undeclared_setting_is_refused() {
2263 let export = FakeExport::at(Phase::Configuring {
2264 subjects: vec![subject("kick", 2.0)],
2265 profiles: Vec::new(),
2266 settings: defaults(),
2267 });
2268
2269 exporting(
2270 &export,
2271 Request {
2272 method: Method::Post,
2273 path: "/export/set/format".to_owned(),
2274 captures: Params::new().with("setting", "format"),
2275 payload: Params::new().with("format", "wav"),
2276 carried: Params::new(),
2277 },
2278 )
2279 .unwrap();
2280 assert_eq!(export.asked.borrow().as_slice(), ["set:format=wav"]);
2281
2282 // An address is reachable by typing, so a name the description does not
2283 // carry is a refusal rather than a panic or a silent no-op.
2284 let refused = exporting(
2285 &export,
2286 Request {
2287 method: Method::Post,
2288 path: "/export/set/bitrate".to_owned(),
2289 captures: Params::new().with("setting", "bitrate"),
2290 payload: Params::new(),
2291 carried: Params::new(),
2292 },
2293 )
2294 .unwrap_err();
2295 assert_eq!(refused.class, quasi_router::Class::NotFound);
2296 assert_eq!(export.asked.borrow().len(), 1, "the refusal wrote nothing");
2297 }
2298
2299 #[test]
2300 fn a_worker_that_has_not_counted_the_files_yet_reads_as_pending_not_as_finished() {
2301 // A meter of 0 of 0 draws full, which would say the export is done before
2302 // it has started. Readiness is what says "working on it".
2303 let starting = FakeExport::at(Phase::Running {
2304 done: 0,
2305 total: 0,
2306 current: String::new(),
2307 });
2308 let screen = exported(&starting);
2309 assert!(nodes(&screen).iter().any(|node| matches!(
2310 node,
2311 Node::StandIn {
2312 state: quasi_router::layout::Readiness::Pending,
2313 ..
2314 }
2315 )));
2316 assert!(
2317 !nodes(&screen)
2318 .iter()
2319 .any(|node| matches!(node, Node::Meter(_)))
2320 );
2321
2322 let running = FakeExport::at(Phase::Running {
2323 done: 3,
2324 total: 10,
2325 current: "kick.wav".to_owned(),
2326 });
2327 let screen = exported(&running);
2328 let meter = nodes(&screen)
2329 .into_iter()
2330 .find_map(|node| match node {
2331 Node::Meter(meter) => Some(meter.clone()),
2332 _ => None,
2333 })
2334 .expect("a counted export describes its proportion");
2335 assert_eq!((meter.done, meter.total), (3, 10));
2336 assert!(said(&screen).contains("Exporting: kick.wav"));
2337 }
2338
2339 #[test]
2340 fn cancelling_a_running_export_asks_the_app_rather_than_deciding_itself() {
2341 let export = FakeExport::at(Phase::Running {
2342 done: 3,
2343 total: 10,
2344 current: "kick.wav".to_owned(),
2345 });
2346 exporting(&export, Request::post("/export/cancel")).unwrap();
2347 assert_eq!(export.asked.borrow().as_slice(), ["cancel"]);
2348 }
2349
2350 #[test]
2351 fn a_clean_finish_says_so_and_a_dirty_one_names_every_file_that_failed() {
2352 let clean = FakeExport::at(Phase::Finished {
2353 total: 12,
2354 errors: Vec::new(),
2355 destination: Some("/tmp/export".to_owned()),
2356 });
2357 let screen = exported(&clean);
2358 assert!(said(&screen).contains("Successfully exported 12 files"));
2359
2360 let dirty = FakeExport::at(Phase::Finished {
2361 total: 10,
2362 errors: vec![
2363 ("kick.wav".to_owned(), "disk full".to_owned()),
2364 ("snare.wav".to_owned(), "permission denied".to_owned()),
2365 ],
2366 destination: None,
2367 });
2368 let screen = exported(&dirty);
2369 assert!(said(&screen).contains("10 files with 2 errors"));
2370
2371 // Every failure in one list, because the set is what is being reported.
2372 let rows = nodes(&screen)
2373 .into_iter()
2374 .find_map(|node| match node {
2375 Node::List { rows, .. } => Some(rows.clone()),
2376 _ => None,
2377 })
2378 .expect("the failures are a list");
2379 assert_eq!(rows.len(), 2);
2380 }
2381
2382 #[test]
2383 fn where_the_files_went_is_offered_only_when_the_app_knows_where_that_was() {
2384 let known = FakeExport::at(Phase::Finished {
2385 total: 1,
2386 errors: Vec::new(),
2387 destination: Some("/tmp/export".to_owned()),
2388 });
2389 let screen = exported(&known);
2390 assert!(said(&screen).contains("Export Complete"));
2391 assert!(
2392 nodes(&screen).iter().any(|node| matches!(
2393 node,
2394 Node::Act(act) if act.action.destination.route().is_none()
2395 )),
2396 "the folder is opened by the host, so the destination is external",
2397 );
2398
2399 let unknown = FakeExport::at(Phase::Finished {
2400 total: 1,
2401 errors: Vec::new(),
2402 destination: None,
2403 });
2404 let screen = exported(&unknown);
2405 assert!(
2406 !nodes(&screen).iter().any(|node| matches!(
2407 node,
2408 Node::Act(act) if act.action.destination.route().is_none()
2409 )),
2410 "an export with nowhere recorded offers no folder to open",
2411 );
2412 }
2413
2414 #[test]
2415 fn a_cancelled_export_says_what_landed_and_where_it_is() {
2416 // The whole reason the shipped app has this screen: partial files are on
2417 // the disk and the user needs to know whether to re-run or clean up.
2418 let export = FakeExport::at(Phase::Cancelled {
2419 done: 4,
2420 total: 10,
2421 destination: Some("/tmp/export".to_owned()),
2422 });
2423 let said = said(&exported(&export));
2424
2425 assert!(said.contains("4 of 10 samples were written"), "{said}");
2426 assert!(said.contains("/tmp/export"), "{said}");
2427 }
2428
2429 #[test]
2430 fn every_way_out_of_the_flow_goes_through_one_route() {
2431 // Done, Cancel-before-starting and Done-after-cancelling are one act: the
2432 // flow is over. Three routes would be three places to forget to reset it.
2433 for phase in [
2434 Phase::Configuring {
2435 subjects: vec![subject("kick", 2.0)],
2436 profiles: Vec::new(),
2437 settings: defaults(),
2438 },
2439 Phase::Finished {
2440 total: 1,
2441 errors: Vec::new(),
2442 destination: None,
2443 },
2444 Phase::Cancelled {
2445 done: 1,
2446 total: 2,
2447 destination: None,
2448 },
2449 ] {
2450 let export = FakeExport::at(phase);
2451 exporting(&export, Request::post("/export/dismiss")).unwrap();
2452 assert_eq!(export.asked.borrow().as_slice(), ["dismiss"]);
2453 }
2454 }
2455
2456 // The detail panel.
2457
2458 /// A detail panel with nothing chosen.
2459 ///
2460 /// [`Offline`]'s peer, and here for the same reason: `Panels` is one state for
2461 /// every screen, so a settings test still has to name a detail panel.
2462 struct Unfocused;
2463
2464 impl Detail for Unfocused {
2465 fn focus(&self) -> Focus {
2466 Focus::Nothing
2467 }
2468
2469 fn add_tag(&self, _tag: &str) {}
2470 fn remove_tag(&self, _tag: &str) {}
2471 fn suggest(&self) {}
2472 fn accept(&self, _tag: &str) {}
2473 fn copy_path(&self) {}
2474 fn edit(&self) {}
2475 fn forge(&self) {}
2476 fn find_similar(&self) {}
2477 fn find_duplicates(&self) {}
2478 fn spread_tag(&self, _tag: &str) {}
2479 fn strip_tag(&self, _tag: &str) {}
2480 }
2481
2482 /// A detail panel in memory, recording what was asked of it.
2483 struct FakeDetail {
2484 focus: Focus,
2485 asked: RefCell<Vec<String>>,
2486 }
2487
2488 impl FakeDetail {
2489 fn at(focus: Focus) -> Self {
2490 Self {
2491 focus,
2492 asked: RefCell::new(Vec::new()),
2493 }
2494 }
2495
2496 fn note(&self, what: impl Into<String>) {
2497 self.asked.borrow_mut().push(what.into());
2498 }
2499
2500 fn asked(&self) -> Vec<String> {
2501 self.asked.borrow().clone()
2502 }
2503 }
2504
2505 impl Detail for FakeDetail {
2506 fn focus(&self) -> Focus {
2507 self.focus.clone()
2508 }
2509
2510 fn add_tag(&self, tag: &str) {
2511 self.note(format!("add {tag}"));
2512 }
2513
2514 fn remove_tag(&self, tag: &str) {
2515 self.note(format!("remove {tag}"));
2516 }
2517
2518 fn suggest(&self) {
2519 self.note("suggest");
2520 }
2521
2522 fn accept(&self, tag: &str) {
2523 self.note(format!("accept {tag}"));
2524 }
2525
2526 fn copy_path(&self) {
2527 self.note("copy");
2528 }
2529
2530 fn edit(&self) {
2531 self.note("edit");
2532 }
2533
2534 fn forge(&self) {
2535 self.note("forge");
2536 }
2537
2538 fn find_similar(&self) {
2539 self.note("similar");
2540 }
2541
2542 fn find_duplicates(&self) {
2543 self.note("duplicates");
2544 }
2545
2546 fn spread_tag(&self, tag: &str) {
2547 self.note(format!("spread {tag}"));
2548 }
2549
2550 fn strip_tag(&self, tag: &str) {
2551 self.note(format!("strip {tag}"));
2552 }
2553 }
2554
2555 /// A router call against this detail panel.
2556 fn detailing(detail: &FakeDetail, request: Request) -> Result<Response, quasi_router::RouteError> {
2557 let store = Store::default();
2558 let sync = Offline;
2559 let files = FakeFiles::default();
2560 let themes = themes();
2561 let state = Panels {
2562 config: &store,
2563 sync: &sync,
2564 files: &files,
2565 export: &Idle,
2566 detail,
2567 bulk: &Unchosen,
2568 shell: &Quiet,
2569 library: &Empty,
2570 bar: &Still,
2571 naming: &Unnamed,
2572 importing: &NoImport,
2573 integrity: &Sound,
2574 editor: &Unedited,
2575 forge: &Unforged,
2576 queue: &Unqueued,
2577 filters: &Unfiltered,
2578 themes: &themes,
2579 };
2580 router().handle(&state, request)
2581 }
2582
2583 /// The screen the detail panel answers, at whatever it is focused on.
2584 fn detailed(detail: &FakeDetail) -> Screen {
2585 screen_of(&detailing(detail, Request::get("/detail")).unwrap()).clone()
2586 }
2587
2588 /// A sample with everything analysis can find.
2589 fn analysed() -> Detailed {
2590 Detailed {
2591 id: 7,
2592 name: "kick.wav".to_owned(),
2593 path: Some("/vault/kick.wav".to_owned()),
2594 analysis: Some(Analysis {
2595 duration: 1.5,
2596 sample_rate: 48_000,
2597 channels: 2,
2598 bpm: Some(120.0),
2599 musical_key: Some("Am".to_owned()),
2600 peak_db: Some(-3.2),
2601 rms_db: Some(-14.0),
2602 lufs: Some(-11.5),
2603 is_loop: Some(false),
2604 }),
2605 tags: vec![
2606 Tagged {
2607 name: "drums".to_owned(),
2608 source: Source::Manual,
2609 },
2610 Tagged {
2611 name: "kick".to_owned(),
2612 source: Source::Rule,
2613 },
2614 ],
2615 suggestions: Vec::new(),
2616 is_sample: true,
2617 has_spectral: true,
2618 has_fingerprint: true,
2619 }
2620 }
2621
2622 /// One sample, focused.
2623 fn one(sample: Detailed) -> Focus {
2624 Focus::One(Box::new(sample))
2625 }
2626
2627 /// Several samples, focused.
2628 fn several(spread: Spread) -> Focus {
2629 Focus::Several(Box::new(spread))
2630 }
2631
2632 /// A selection of two that agrees about nothing.
2633 fn mixed() -> Spread {
2634 Spread {
2635 samples: 2,
2636 folders: 1,
2637 bpm: Shared::Varies,
2638 musical_key: Shared::Absent,
2639 duration: Shared::Same("1.5s".to_owned()),
2640 tags: vec![
2641 Coverage {
2642 name: "drums".to_owned(),
2643 on: 2,
2644 },
2645 Coverage {
2646 name: "loop".to_owned(),
2647 on: 1,
2648 },
2649 ],
2650 }
2651 }
2652
2653 /// Every act on a screen that is not answering, by label.
2654 fn dead(screen: &Screen) -> Vec<String> {
2655 nodes(screen)
2656 .iter()
2657 .filter_map(|node| match node {
2658 Node::Act(act) if act.state == Some(quasi_router::layout::State::Disabled) => {
2659 Some(act.label.clone())
2660 }
2661 _ => None,
2662 })
2663 .collect()
2664 }
2665
2666 #[test]
2667 fn nothing_chosen_says_so_and_offers_nothing() {
2668 let detail = FakeDetail::at(Focus::Nothing);
2669 let screen = detailed(&detail);
2670
2671 assert!(said(&screen).contains("Select a sample"));
2672 assert!(acts(&screen).is_empty());
2673 }
2674
2675 #[test]
2676 fn one_sample_reports_every_field_analysis_found() {
2677 let detail = FakeDetail::at(one(analysed()));
2678 let screen = detailed(&detail);
2679 let (_, rows) = table_of(&screen);
2680
2681 let facts: Vec<(String, String)> = rows
2682 .iter()
2683 .map(|row| (cell_text(row, 0), cell_text(row, 1)))
2684 .collect();
2685 let field = |name: &str| {
2686 facts
2687 .iter()
2688 .find(|(field, _)| field == name)
2689 .map(|(_, value)| value.clone())
2690 };
2691
2692 assert_eq!(field("Duration").as_deref(), Some("1.5s"));
2693 assert_eq!(field("BPM").as_deref(), Some("120"));
2694 assert_eq!(field("Key").as_deref(), Some("Am"));
2695 assert_eq!(field("Sample rate").as_deref(), Some("48000 Hz"));
2696 assert_eq!(field("Channels").as_deref(), Some("2"));
2697 assert_eq!(field("Peak").as_deref(), Some("-3.2 dB"));
2698 assert_eq!(field("RMS").as_deref(), Some("-14.0 dB"));
2699 assert_eq!(field("LUFS").as_deref(), Some("-11.5"));
2700 assert_eq!(field("Loop").as_deref(), Some("No"));
2701 }
2702
2703 #[test]
2704 fn a_field_analysis_did_not_find_is_absent_rather_than_blank() {
2705 let mut sample = analysed();
2706 if let Some(analysis) = sample.analysis.as_mut() {
2707 analysis.bpm = None;
2708 analysis.musical_key = None;
2709 analysis.lufs = None;
2710 }
2711 let detail = FakeDetail::at(one(sample));
2712 let (_, rows) = table_of(&detailed(&detail));
2713
2714 let fields: Vec<String> = rows.iter().map(|row| cell_text(row, 0)).collect();
2715 assert!(!fields.iter().any(|field| field == "BPM"));
2716 assert!(!fields.iter().any(|field| field == "Key"));
2717 assert!(!fields.iter().any(|field| field == "LUFS"));
2718 assert!(fields.iter().any(|field| field == "Duration"));
2719 }
2720
2721 #[test]
2722 fn a_tag_carries_where_it_came_from_and_removes_itself() {
2723 let detail = FakeDetail::at(one(analysed()));
2724 let screen = detailed(&detail);
2725
2726 let tokens: Vec<quasi_router::Tag> = nodes(&screen)
2727 .iter()
2728 .filter_map(|node| match node {
2729 Node::Token(tag) => Some(tag.clone()),
2730 _ => None,
2731 })
2732 .collect();
2733 assert_eq!(tokens.len(), 2);
2734 assert!(tokens[0].label.contains("drums"));
2735 assert!(tokens[0].label.contains("manual"));
2736 assert!(tokens[1].label.contains("rule"));
2737
2738 // Every one of them is removable and says where the removal goes, which is
2739 // what the shipped panel's `tag_chip_removable` does with a bool.
2740 for tag in &tokens {
2741 assert_eq!(
2742 tag.kind,
2743 quasi_router::layout::Token::Chip { removable: true }
2744 );
2745 assert!(tag.action.is_some());
2746 }
2747 }
2748
2749 #[test]
2750 fn removing_a_tag_asks_for_that_tag() {
2751 let detail = FakeDetail::at(one(analysed()));
2752 detailing(&detail, Request::post("/detail/tags/drums/remove")).unwrap();
2753 assert_eq!(detail.asked(), ["remove drums"]);
2754 }
2755
2756 #[test]
2757 fn adding_a_tag_carries_what_was_typed_and_refuses_an_empty_one() {
2758 let detail = FakeDetail::at(one(analysed()));
2759 detailing(
2760 &detail,
2761 Request::post("/detail/tags")
2762 .sending(Params::new().with("tag".to_owned(), "genre.house".to_owned())),
2763 )
2764 .unwrap();
2765 assert_eq!(detail.asked(), ["add genre.house"]);
2766
2767 let empty = FakeDetail::at(one(analysed()));
2768 let response = detailing(
2769 &empty,
2770 Request::post("/detail/tags").sending(Params::new().with("tag".to_owned(), String::new())),
2771 )
2772 .unwrap();
2773 assert!(empty.asked().is_empty());
2774 assert!(response.notice.is_some());
2775 }
2776
2777 #[test]
2778 fn a_suggestion_says_its_score_and_how_many_carry_it() {
2779 let mut sample = analysed();
2780 sample.suggestions = vec![Suggested {
2781 tag: "percussion".to_owned(),
2782 score: 0.82,
2783 neighbours: 4,
2784 }];
2785 let detail = FakeDetail::at(one(sample));
2786 let labels = acts(&detailed(&detail));
2787
2788 let offer = labels
2789 .iter()
2790 .find(|label| label.contains("percussion"))
2791 .expect("the suggestion is offered");
2792 assert!(offer.contains("82%"));
2793 assert!(offer.contains('4'));
2794 }
2795
2796 #[test]
2797 fn discovery_is_offered_dead_with_its_precondition_said_beside_it() {
2798 let mut sample = analysed();
2799 sample.has_spectral = false;
2800 sample.has_fingerprint = false;
2801 let detail = FakeDetail::at(one(sample));
2802 let screen = detailed(&detail);
2803
2804 // Offered rather than hidden, which is the shipped panel's choice: a
2805 // control that vanishes teaches nothing.
2806 assert!(acts(&screen).iter().any(|label| label == "Find Similar"));
2807 assert_eq!(dead(&screen), ["Find Similar", "Find Duplicates"]);
2808
2809 // And the sentence that would revive each is said. THE FINDING is that it
2810 // is said beside the control rather than on it -- see the module header.
2811 let says = said(&screen);
2812 assert!(says.contains("spectral features"));
2813 assert!(says.contains("fingerprinting"));
2814 }
2815
2816 #[test]
2817 fn discovery_answers_where_the_features_are_there() {
2818 let detail = FakeDetail::at(one(analysed()));
2819 let screen = detailed(&detail);
2820 assert!(dead(&screen).is_empty());
2821
2822 detailing(&detail, Request::post("/detail/similar")).unwrap();
2823 detailing(&detail, Request::post("/detail/duplicates")).unwrap();
2824 assert_eq!(detail.asked(), ["similar", "duplicates"]);
2825 }
2826
2827 #[test]
2828 fn discovery_refuses_a_typed_request_the_control_would_have_refused() {
2829 let mut sample = analysed();
2830 sample.has_spectral = false;
2831 sample.has_fingerprint = false;
2832 let detail = FakeDetail::at(one(sample));
2833
2834 assert!(detailing(&detail, Request::post("/detail/similar")).is_err());
2835 assert!(detailing(&detail, Request::post("/detail/duplicates")).is_err());
2836 assert!(detail.asked().is_empty());
2837 }
2838
2839 #[test]
2840 fn a_folder_is_offered_neither_the_editors_nor_discovery() {
2841 let mut sample = analysed();
2842 sample.is_sample = false;
2843 let detail = FakeDetail::at(one(sample));
2844 let labels = acts(&detailed(&detail));
2845
2846 assert!(!labels.iter().any(|label| label == "Edit"));
2847 assert!(!labels.iter().any(|label| label == "Forge"));
2848 assert!(!labels.iter().any(|label| label == "Find Similar"));
2849 // The path is still copyable: a folder has one.
2850 assert!(labels.iter().any(|label| label == "Copy Path"));
2851 }
2852
2853 #[test]
2854 fn several_chosen_says_what_they_agree_on_three_ways() {
2855 let detail = FakeDetail::at(several(mixed()));
2856 let screen = detailed(&detail);
2857 let (_, rows) = table_of(&screen);
2858
2859 let facts: Vec<(String, String)> = rows
2860 .iter()
2861 .map(|row| (cell_text(row, 0), cell_text(row, 1)))
2862 .collect();
2863
2864 // Three answers rather than the shipped panel's two strings: disagreement
2865 // and absence are different facts and each renderer can now tell them
2866 // apart.
2867 assert_eq!(facts[0], ("BPM".to_owned(), "varies".to_owned()));
2868 assert_eq!(facts[1], ("Key".to_owned(), "\u{2014}".to_owned()));
2869 assert_eq!(facts[2], ("Duration".to_owned(), "1.5s".to_owned()));
2870
2871 assert!(said(&screen).contains("2 samples \u{b7} 1 folders selected"));
2872 }
2873
2874 #[test]
2875 fn a_partly_covered_tag_says_how_far_it_reaches_and_offers_both_ways() {
2876 let detail = FakeDetail::at(several(mixed()));
2877 let screen = detailed(&detail);
2878
2879 let rows = list_of(&screen);
2880 let full = &rows[0];
2881 let partial = &rows[1];
2882
2883 // The count is in the row rather than in a hover, so a reader with no
2884 // pointer still has it.
2885 assert_eq!(meta_of(full), None);
2886 assert_eq!(meta_of(partial).as_deref(), Some("1 of 2"));
2887
2888 // A tag every sample carries has nothing to spread, so only the removal is
2889 // offered. One that is partial offers both.
2890 assert_eq!(full.menu.len(), 1);
2891 assert_eq!(partial.menu.len(), 2);
2892 assert!(partial.menu[0].label.contains("Apply to remaining (1)"));
2893 assert_eq!(partial.menu[1].tone, quasi_router::layout::Tone::Danger);
2894 }
2895
2896 #[test]
2897 fn spreading_and_stripping_name_the_tag_they_act_on() {
2898 let detail = FakeDetail::at(several(mixed()));
2899 detailing(&detail, Request::post("/detail/selection/tags/loop/spread")).unwrap();
2900 detailing(&detail, Request::post("/detail/selection/tags/drums/strip")).unwrap();
2901 assert_eq!(detail.asked(), ["spread loop", "strip drums"]);
2902 }
2903
2904 #[test]
2905 fn a_selection_of_folders_alone_has_nothing_to_summarize() {
2906 let detail = FakeDetail::at(several(Spread {
2907 samples: 0,
2908 folders: 3,
2909 bpm: Shared::Absent,
2910 musical_key: Shared::Absent,
2911 duration: Shared::Absent,
2912 tags: Vec::new(),
2913 }));
2914 let screen = detailed(&detail);
2915
2916 assert!(said(&screen).contains("No sample metadata to summarize"));
2917 assert!(dead(&screen).is_empty());
2918 }
2919
2920 #[test]
2921 fn the_host_acts_are_asked_for_rather_than_performed() {
2922 let detail = FakeDetail::at(one(analysed()));
2923 for address in [
2924 "/detail/path/copy",
2925 "/detail/edit",
2926 "/detail/forge",
2927 "/detail/tags/suggest",
2928 "/detail/tags/percussion/accept",
2929 ] {
2930 detailing(&detail, Request::post(address)).unwrap();
2931 }
2932 assert_eq!(
2933 detail.asked(),
2934 ["copy", "edit", "forge", "suggest", "accept percussion"]
2935 );
2936 }
2937
2938 /// The text of one cell in a table row.
2939 fn cell_text(row: &quasi_router::Cells, at: usize) -> String {
2940 row.values[at]
2941 .parts
2942 .iter()
2943 .filter_map(|node| match node {
2944 Node::Text { text, .. } => Some(text.clone()),
2945 _ => None,
2946 })
2947 .collect::<String>()
2948 }
2949
2950 /// A row's trailing fact, if it has one.
2951 fn meta_of(row: &quasi_router::Row) -> Option<String> {
2952 row.parts
2953 .iter()
2954 .find(|part| part.role == quasi_router::layout::RowPart::Meta)
2955 .and_then(|part| match &part.node {
2956 Node::Text { text, .. } => Some(text.clone()),
2957 _ => None,
2958 })
2959 }
2960
2961 /// The rows of the list on a screen.
2962 fn list_of(screen: &Screen) -> Vec<quasi_router::Row> {
2963 nodes(screen)
2964 .iter()
2965 .find_map(|node| match node {
2966 Node::List { rows, .. } => Some(rows.clone()),
2967 _ => None,
2968 })
2969 .expect("the screen draws a list")
2970 }
2971
2972 // The bulk modals.
2973
2974 /// A selection with nothing in it.
2975 ///
2976 /// [`Unfocused`]'s peer, and here for the same reason `Offline` is.
2977 struct Unchosen;
2978
2979 impl Bulk for Unchosen {
2980 fn done(&self) {}
2981
2982 fn chosen(&self) -> Chosen {
2983 Chosen {
2984 names: Vec::new(),
2985 samples: 0,
2986 }
2987 }
2988
2989 fn known_tags(&self) -> Vec<String> {
2990 Vec::new()
2991 }
2992
2993 fn folders(&self) -> Vec<Folder> {
2994 Vec::new()
2995 }
2996
2997 fn previews(&self, _pattern: &str) -> Result<Vec<(String, String)>, String> {
2998 Ok(Vec::new())
2999 }
3000
3001 fn tag(&self, _tag: &str, _adding: bool) {}
3002 fn move_to(&self, _folder: Option<i64>) {}
3003 fn rename(&self, _pattern: &str) {}
3004 }
3005
3006 /// A selection in memory, recording what was asked of it.
3007 struct FakeBulk {
3008 chosen: Chosen,
3009 tags: Vec<String>,
3010 folders: Vec<Folder>,
3011 asked: RefCell<Vec<String>>,
3012 /// Whether the modal was told it is finished with.
3013 ///
3014 /// Its own field rather than a row in `asked`, for `FakeNaming::finished`'s
3015 /// reason: `asked` answers what this screen did to the library, and putting
3016 /// a window away does nothing to it.
3017 finished: std::cell::Cell<bool>,
3018 }
3019
3020 impl FakeBulk {
3021 fn of(names: &[&str], samples: usize) -> Self {
3022 Self {
3023 chosen: Chosen {
3024 names: names.iter().map(|name| (*name).to_owned()).collect(),
3025 samples,
3026 },
3027 tags: vec!["drums".to_owned(), "loop".to_owned()],
3028 folders: vec![
3029 Folder {
3030 id: 3,
3031 path: "/kits".to_owned(),
3032 },
3033 Folder {
3034 id: 4,
3035 path: "/kits/808".to_owned(),
3036 },
3037 ],
3038 asked: RefCell::new(Vec::new()),
3039 finished: std::cell::Cell::new(false),
3040 }
3041 }
3042
3043 fn finished(&self) -> bool {
3044 self.finished.get()
3045 }
3046
3047 fn asked(&self) -> Vec<String> {
3048 self.asked.borrow().clone()
3049 }
3050 }
3051
3052 impl Bulk for FakeBulk {
3053 fn done(&self) {
3054 self.finished.set(true);
3055 }
3056
3057 fn chosen(&self) -> Chosen {
3058 self.chosen.clone()
3059 }
3060
3061 fn known_tags(&self) -> Vec<String> {
3062 self.tags.clone()
3063 }
3064
3065 fn folders(&self) -> Vec<Folder> {
3066 self.folders.clone()
3067 }
3068
3069 /// A stand-in for the app's rename engine, with the two answers the screen
3070 /// branches on: a pattern with no `{` is a literal, which renames every file
3071 /// to the same name, and an unclosed `{` is what half-typed looks like.
3072 fn previews(&self, pattern: &str) -> Result<Vec<(String, String)>, String> {
3073 if pattern.contains('{') && !pattern.contains('}') {
3074 return Err("unclosed token".to_owned());
3075 }
3076 Ok(self
3077 .chosen
3078 .names
3079 .iter()
3080 .map(|name| {
3081 let new = if pattern.contains("{name}") {
3082 pattern.replace("{name}", name.trim_end_matches(".wav"))
3083 } else {
3084 pattern.to_owned()
3085 };
3086 (name.clone(), format!("{new}.wav"))
3087 })
3088 .collect())
3089 }
3090
3091 fn tag(&self, tag: &str, adding: bool) {
3092 self.asked
3093 .borrow_mut()
3094 .push(format!("{} {tag}", if adding { "add" } else { "remove" }));
3095 }
3096
3097 fn move_to(&self, folder: Option<i64>) {
3098 self.asked.borrow_mut().push(match folder {
3099 Some(id) => format!("move {id}"),
3100 None => "move root".to_owned(),
3101 });
3102 }
3103
3104 fn rename(&self, pattern: &str) {
3105 self.asked.borrow_mut().push(format!("rename {pattern}"));
3106 }
3107 }
3108
3109 /// A router call against this selection.
3110 fn bulking(bulk: &FakeBulk, request: Request) -> Result<Response, quasi_router::RouteError> {
3111 let store = Store::default();
3112 let sync = Offline;
3113 let files = FakeFiles::default();
3114 let themes = themes();
3115 let state = Panels {
3116 config: &store,
3117 sync: &sync,
3118 files: &files,
3119 export: &Idle,
3120 detail: &Unfocused,
3121 bulk,
3122 shell: &Quiet,
3123 library: &Empty,
3124 bar: &Still,
3125 naming: &Unnamed,
3126 importing: &NoImport,
3127 integrity: &Sound,
3128 editor: &Unedited,
3129 forge: &Unforged,
3130 queue: &Unqueued,
3131 filters: &Unfiltered,
3132 themes: &themes,
3133 };
3134 router().handle(&state, request)
3135 }
3136
3137 /// The screen a bulk address answers, and the outcome it came in.
3138 fn overlay(response: &Response) -> &Screen {
3139 match &response.outcome {
3140 Outcome::Over(screen) => screen,
3141 other => panic!("expected an overlay, got {other:?}"),
3142 }
3143 }
3144
3145 #[test]
3146 fn every_bulk_modal_is_drawn_over_what_is_showing() {
3147 // THE POINT OF THIS PORT. `Outcome::Over` is what a modal is, and nothing
3148 // had handed one to the egui renderer before this: a modal that answered
3149 // `Screen` would replace the list underneath instead of covering it.
3150 let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
3151 for address in ["/bulk/tag", "/bulk/move", "/bulk/rename"] {
3152 let response = bulking(&bulk, Request::get(address)).unwrap();
3153 assert!(
3154 matches!(response.outcome, Outcome::Over(_)),
3155 "{address} did not answer an overlay"
3156 );
3157 }
3158 }
3159
3160 #[test]
3161 fn a_bulk_modal_refuses_to_open_over_nothing() {
3162 let empty = FakeBulk::of(&[], 0);
3163 for address in ["/bulk/tag", "/bulk/move", "/bulk/rename"] {
3164 assert!(bulking(&empty, Request::get(address)).is_err());
3165 }
3166
3167 // Folders can be moved and renamed but not tagged, which is the shipped
3168 // app's rule: `open_bulk_tag_modal` returns early on an empty hash list.
3169 let folders = FakeBulk::of(&["kits", "loops"], 0);
3170 assert!(bulking(&folders, Request::get("/bulk/tag")).is_err());
3171 assert!(bulking(&folders, Request::get("/bulk/move")).is_ok());
3172 assert!(bulking(&folders, Request::get("/bulk/rename")).is_ok());
3173 }
3174
3175 #[test]
3176 fn the_tag_modal_names_what_it_will_touch_and_what_the_vault_knows() {
3177 let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
3178 let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap();
3179 let screen = overlay(&response);
3180
3181 assert!(said(screen).contains("Tag 2 samples"));
3182 // The subjects are rows rather than prose, so a host can scroll them as a
3183 // list and the description can say how many were withheld.
3184 let named: Vec<String> = list_of(screen).iter().filter_map(primary_of).collect();
3185 assert_eq!(named, ["kick.wav", "snare.wav"]);
3186
3187 // The known tags, as badges. THE FINDING is that a field cannot say what
3188 // completes it, so the set is named beside it -- the same workaround the
3189 // export port's naming tokens use, and its second consumer.
3190 let known: Vec<String> = nodes(screen)
3191 .iter()
3192 .filter_map(|node| match node {
3193 Node::Token(tag) => Some(tag.label.clone()),
3194 _ => None,
3195 })
3196 .collect();
3197 assert_eq!(known, ["drums", "loop"]);
3198 }
3199
3200 #[test]
3201 fn tagging_carries_the_typed_tag_and_which_way_it_goes() {
3202 let bulk = FakeBulk::of(&["kick.wav"], 1);
3203 bulking(
3204 &bulk,
3205 Request::post("/bulk/tag").sending(
3206 Params::new()
3207 .with("tag".to_owned(), "genre.house".to_owned())
3208 .with("mode".to_owned(), "add".to_owned()),
3209 ),
3210 )
3211 .unwrap();
3212 bulking(
3213 &bulk,
3214 Request::post("/bulk/tag").sending(
3215 Params::new()
3216 .with("tag".to_owned(), "drums".to_owned())
3217 .with("mode".to_owned(), "remove".to_owned()),
3218 ),
3219 )
3220 .unwrap();
3221 assert_eq!(bulk.asked(), ["add genre.house", "remove drums"]);
3222 }
3223
3224 #[test]
3225 fn removing_a_tag_the_vault_does_not_know_is_refused_by_the_route() {
3226 // The shipped modal disables Apply on this condition; the route refuses it
3227 // too, because an address is reachable by typing.
3228 let bulk = FakeBulk::of(&["kick.wav"], 1);
3229 let refused = bulking(
3230 &bulk,
3231 Request::post("/bulk/tag").sending(
3232 Params::new()
3233 .with("tag".to_owned(), "nothing-has-this".to_owned())
3234 .with("mode".to_owned(), "remove".to_owned()),
3235 ),
3236 );
3237 assert!(refused.is_err());
3238 assert!(bulk.asked().is_empty());
3239
3240 // Adding one the vault has never seen is fine: that is how a vault learns a
3241 // tag.
3242 bulking(
3243 &bulk,
3244 Request::post("/bulk/tag").sending(
3245 Params::new()
3246 .with("tag".to_owned(), "nothing-has-this".to_owned())
3247 .with("mode".to_owned(), "add".to_owned()),
3248 ),
3249 )
3250 .unwrap();
3251 assert_eq!(bulk.asked(), ["add nothing-has-this"]);
3252 }
3253
3254 #[test]
3255 fn an_empty_tag_keeps_the_modal_open_and_says_why() {
3256 let bulk = FakeBulk::of(&["kick.wav"], 1);
3257 let response = bulking(
3258 &bulk,
3259 Request::post("/bulk/tag").sending(Params::new().with("tag".to_owned(), " ".to_owned())),
3260 )
3261 .unwrap();
3262
3263 // Still an overlay: a refusal that navigated away would take the modal down
3264 // and lose what was typed.
3265 assert!(matches!(response.outcome, Outcome::Over(_)));
3266 assert!(response.notice.is_some());
3267 assert!(bulk.asked().is_empty());
3268 }
3269
3270 #[test]
3271 fn the_move_modal_offers_the_root_and_every_folder() {
3272 let bulk = FakeBulk::of(&["kick.wav"], 1);
3273 let response = bulking(&bulk, Request::get("/bulk/move")).unwrap();
3274 let (_, rows) = table_of(overlay(&response));
3275
3276 let paths: Vec<String> = rows.iter().map(|row| cell_text(row, 0)).collect();
3277 assert_eq!(paths, ["/", "/kits", "/kits/808"]);
3278
3279 // Every row submits its own destination, so picking one is the whole
3280 // interaction. The shipped modal has a selection index and a separate Move
3281 // button.
3282 for row in &rows {
3283 assert!(row.activate.is_some());
3284 }
3285 }
3286
3287 #[test]
3288 fn moving_names_the_folder_by_id_and_the_root_by_absence() {
3289 let bulk = FakeBulk::of(&["kick.wav"], 1);
3290 bulking(
3291 &bulk,
3292 Request::post("/bulk/move")
3293 .sending(Params::new().with("folder".to_owned(), "4".to_owned())),
3294 )
3295 .unwrap();
3296 bulking(
3297 &bulk,
3298 Request::post("/bulk/move").sending(Params::new().with("folder".to_owned(), String::new())),
3299 )
3300 .unwrap();
3301 assert_eq!(bulk.asked(), ["move 4", "move root"]);
3302 }
3303
3304 #[test]
3305 fn moving_somewhere_that_is_not_a_folder_is_refused() {
3306 let bulk = FakeBulk::of(&["kick.wav"], 1);
3307 for value in ["99", "not-a-number"] {
3308 assert!(
3309 bulking(
3310 &bulk,
3311 Request::post("/bulk/move")
3312 .sending(Params::new().with("folder".to_owned(), value.to_owned())),
3313 )
3314 .is_err()
3315 );
3316 }
3317 // And a request that names no destination at all is not the root.
3318 assert!(bulking(&bulk, Request::post("/bulk/move")).is_err());
3319 assert!(bulk.asked().is_empty());
3320 }
3321
3322 #[test]
3323 fn the_rename_modal_previews_the_starting_pattern() {
3324 let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
3325 let response = bulking(&bulk, Request::get("/bulk/rename")).unwrap();
3326 let screen = overlay(&response);
3327 let (_, rows) = table_of(screen);
3328
3329 let pairs: Vec<(String, String)> = rows
3330 .iter()
3331 .map(|row| (cell_text(row, 0), cell_text(row, 1)))
3332 .collect();
3333 assert_eq!(pairs[0], ("kick.wav".to_owned(), "kick.wav".to_owned()));
3334 assert_eq!(pairs[1], ("snare.wav".to_owned(), "snare.wav".to_owned()));
3335 }
3336
3337 #[test]
3338 fn a_typed_pattern_answers_a_fragment_so_the_overlay_survives() {
3339 // THE OTHER FINDING. An overlay cannot re-answer itself: `Outcome::Screen`
3340 // clears the layer stack, so a live preview that answered a screen would
3341 // take the modal down on every keystroke. A fragment replaces one region of
3342 // what is showing, which is what a preview is.
3343 let bulk = FakeBulk::of(&["kick.wav"], 1);
3344 let response = bulking(
3345 &bulk,
3346 Request::post("/bulk/rename/preview")
3347 .sending(Params::new().with("pattern".to_owned(), "{name}_808".to_owned())),
3348 )
3349 .unwrap();
3350
3351 let Outcome::Fragment { region, node } = &response.outcome else {
3352 panic!("expected a fragment, got {:?}", response.outcome);
3353 };
3354 assert_eq!(region, "bulk-rename-preview");
3355 let Node::Table { rows, .. } = node else {
3356 panic!("expected the preview table");
3357 };
3358 assert_eq!(cell_text(&rows[0], 1), "kick_808.wav");
3359
3360 // Nothing was renamed: a preview is a question, not an instruction.
3361 assert!(bulk.asked().is_empty());
3362 }
3363
3364 #[test]
3365 fn a_half_typed_pattern_says_what_is_wrong_rather_than_previewing_nothing() {
3366 let bulk = FakeBulk::of(&["kick.wav"], 1);
3367 let response = bulking(
3368 &bulk,
3369 Request::post("/bulk/rename/preview")
3370 .sending(Params::new().with("pattern".to_owned(), "{na".to_owned())),
3371 )
3372 .unwrap();
3373
3374 let Outcome::Fragment { node, .. } = &response.outcome else {
3375 panic!("expected a fragment");
3376 };
3377 assert!(matches!(node, Node::Notice { .. }));
3378 }
3379
3380 #[test]
3381 fn a_colliding_output_name_is_marked_on_the_row_rather_than_hovered() {
3382 // A literal pattern renames every file to the same name. The shipped modal
3383 // colours the duplicates and explains itself in `on_hover_text`, which a
3384 // reader with no pointer never sees.
3385 let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
3386 let response = bulking(
3387 &bulk,
3388 Request::post("/bulk/rename/preview")
3389 .sending(Params::new().with("pattern".to_owned(), "same".to_owned())),
3390 )
3391 .unwrap();
3392
3393 let Outcome::Fragment {
3394 node: Node::Table { rows, .. },
3395 ..
3396 } = &response.outcome
3397 else {
3398 panic!("expected the preview table");
3399 };
3400 for row in rows {
3401 let marked = row.values[1].parts.iter().any(|part| {
3402 matches!(part, Node::Token(tag) if tag.tone == quasi_router::layout::Tone::Warning)
3403 });
3404 assert!(marked, "a colliding name is not marked");
3405 }
3406 }
3407
3408 #[test]
3409 fn renaming_carries_the_pattern_and_refuses_one_that_does_not_parse() {
3410 let bulk = FakeBulk::of(&["kick.wav"], 1);
3411 bulking(
3412 &bulk,
3413 Request::post("/bulk/rename")
3414 .sending(Params::new().with("pattern".to_owned(), "{name}_808".to_owned())),
3415 )
3416 .unwrap();
3417 assert_eq!(bulk.asked(), ["rename {name}_808"]);
3418
3419 assert!(
3420 bulking(
3421 &bulk,
3422 Request::post("/bulk/rename")
3423 .sending(Params::new().with("pattern".to_owned(), "{na".to_owned())),
3424 )
3425 .is_err()
3426 );
3427 assert_eq!(bulk.asked().len(), 1);
3428 }
3429
3430 #[test]
3431 fn a_finished_modal_goes_somewhere_because_that_is_all_it_can_say() {
3432 // FINDING 1, asserted rather than only written down: there is no action
3433 // meaning "close what is on top", so every way out of a described modal is
3434 // a navigation. Since the flip (2026-08-22) it is a navigation with a stop
3435 // on the way -- `/bulk/done`, which tells the host to put the window away,
3436 // because the host's own `bulk_modal` is what keeps it up and leaving the
3437 // address is not leaving the screen. The finding is unchanged: the
3438 // vocabulary still cannot say "this overlay is finished".
3439 let bulk = FakeBulk::of(&["kick.wav"], 1);
3440 let done = bulking(
3441 &bulk,
3442 Request::post("/bulk/tag").sending(Params::new().with("tag".to_owned(), "new".to_owned())),
3443 )
3444 .unwrap();
3445 assert!(matches!(done.outcome, Outcome::Goto(_)));
3446 assert!(done.notice.is_some());
3447
3448 // And Cancel is the same navigation, drawn as a control.
3449 let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap();
3450 let cancel = nodes(overlay(&response))
3451 .into_iter()
3452 .find_map(|node| match node {
3453 Node::Act(act) if act.label == "Cancel" => Some(act.clone()),
3454 _ => None,
3455 })
3456 .expect("the modal offers a way out");
3457 assert_eq!(cancel.key.as_deref(), Some("esc"));
3458 assert_eq!(cancel.action.destination.route(), Some("/bulk/done"));
3459
3460 // And that route says both halves: the host is told, and the answer leaves.
3461 let left = bulking(&bulk, Request::post("/bulk/done")).unwrap();
3462 assert!(bulk.finished());
3463 match left.outcome {
3464 Outcome::Goto(action) => assert_eq!(action.destination.route(), Some("/detail")),
3465 other => panic!("expected a navigation, got {other:?}"),
3466 }
3467 }
3468
3469 #[test]
3470 fn a_long_selection_says_how_many_it_did_not_name() {
3471 let many: Vec<String> = (0..60).map(|at| format!("sample{at}.wav")).collect();
3472 let names: Vec<&str> = many.iter().map(String::as_str).collect();
3473 let bulk = FakeBulk::of(&names, 60);
3474 let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap();
3475
3476 let screen = overlay(&response);
3477 assert!(said(screen).contains("60 chosen"));
3478
3479 // The overflow is a described fact as of quasi 0.15, not a sentence at the
3480 // end of the list: fifty rows, and `Rest` saying there are sixty.
3481 let rows = list_of(screen);
3482 assert_eq!(rows.len(), 50);
3483 let more = more_of(screen).expect("the list says it is not all of them");
3484 assert_eq!(more.paging.window.of, Some(60));
3485 assert_eq!(more.paging.window.count, 50);
3486 // Nothing to ask for: the cap is a rendering budget and the operation acts
3487 // on all sixty either way.
3488 assert!(more.forward.is_none());
3489 }
3490
3491 // The help overlay.
3492
3493 /// A router call against the help overlay.
3494 ///
3495 /// It borrows nothing: the shortcuts are read off `help::chrome`, which is a
3496 /// free function, and the features tab is a constant. That is the point of the
3497 /// screen rather than an accident of the fixture -- a help overlay that needed
3498 /// app state would be describing something other than the app's own keys.
3499 fn helping(request: Request) -> Result<Response, quasi_router::RouteError> {
3500 let store = Store::default();
3501 let sync = Offline;
3502 let files = FakeFiles::default();
3503 let themes = themes();
3504 let state = Panels {
3505 config: &store,
3506 sync: &sync,
3507 files: &files,
3508 export: &Idle,
3509 detail: &Unfocused,
3510 bulk: &Unchosen,
3511 shell: &Quiet,
3512 library: &Empty,
3513 bar: &Still,
3514 naming: &Unnamed,
3515 importing: &NoImport,
3516 integrity: &Sound,
3517 editor: &Unedited,
3518 forge: &Unforged,
3519 queue: &Unqueued,
3520 filters: &Unfiltered,
3521 themes: &themes,
3522 };
3523 router().handle(&state, request)
3524 }
3525
3526 /// The shortcuts tab, read back as the headings and rows it draws.
3527 ///
3528 /// One table per group since `cf7872dc`, so `table_of` -- which answers with
3529 /// the first it finds -- would assert about the Bulk group and call it the
3530 /// whole screen.
3531 fn shortcut_sections(screen: &Screen) -> Vec<(Option<String>, Vec<(String, String)>)> {
3532 fn walk(
3533 body: &[quasi_router::Ranked],
3534 found: &mut Vec<(Option<String>, Vec<(String, String)>)>,
3535 heading: &mut Option<String>,
3536 ) {
3537 for placed in body {
3538 match &placed.node {
3539 Node::Heading { text, .. } => *heading = Some(text.clone()),
3540 Node::Table { rows, .. } => found.push((
3541 heading.take(),
3542 rows.iter()
3543 .map(|row| (cell_text(row, 0), cell_text(row, 1)))
3544 .collect(),
3545 )),
3546 Node::Region(slot) => walk(&slot.body, found, heading),
3547 _ => {}
3548 }
3549 }
3550 }
3551
3552 let mut found = Vec::new();
3553 let mut heading = None;
3554 for slot in &screen.slots {
3555 walk(&slot.body, &mut found, &mut heading);
3556 }
3557 found
3558 }
3559
3560 #[test]
3561 fn the_help_overlay_lists_exactly_the_keys_that_are_bound() {
3562 // THE POINT OF THIS PORT. `Binding`'s own header says a help overlay is
3563 // otherwise "a second, hand-written copy of them, free to drift from what
3564 // the keys actually do", and the shipped tab is that copy in seven arrays.
3565 // Here the two cannot disagree, and this is what says so.
3566 //
3567 // Read across the groups, because the grouping is a heading over the same
3568 // one table: every key is still listed exactly once and in the order it was
3569 // bound, which is what a listing withholding nothing means.
3570 let response = helping(Request::get("/help")).unwrap();
3571 let listed: Vec<(String, String)> = shortcut_sections(overlay(&response))
3572 .into_iter()
3573 .flat_map(|(_, rows)| rows)
3574 .collect();
3575 let bound: Vec<(String, String)> = super::help::chrome()
3576 .bindings
3577 .iter()
3578 .map(|binding| (binding.key.clone(), binding.label.clone()))
3579 .collect();
3580
3581 assert_eq!(listed, bound);
3582 assert!(!bound.is_empty());
3583 }
3584
3585 #[test]
3586 fn the_shortcuts_tab_is_grouped_under_the_headings_the_shipped_tab_uses() {
3587 // `cf7872dc`. At four rows a flat table was fine; at thirteen it is the
3588 // same wall the shipped tab broke into seven hand-written arrays, and the
3589 // arrays are the evidence that somebody already thought so.
3590 let response = helping(Request::get("/help")).unwrap();
3591 let sections = shortcut_sections(overlay(&response));
3592
3593 let headings: Vec<Option<String>> = sections
3594 .iter()
3595 .map(|(heading, _)| heading.clone())
3596 .collect();
3597 assert_eq!(
3598 headings,
3599 [
3600 Some("Bulk".to_owned()),
3601 Some("Discovery".to_owned()),
3602 Some("Toggles".to_owned()),
3603 Some("System".to_owned()),
3604 ],
3605 "the reading order is the app's, not the alphabet's"
3606 );
3607
3608 // Every row sits under a heading: an ungrouped run would come back with
3609 // `None`, and this table has none today.
3610 assert!(
3611 sections
3612 .iter()
3613 .all(|(heading, rows)| heading.is_some() && !rows.is_empty())
3614 );
3615
3616 // And the group is a listing fact and nothing more -- the keys still work
3617 // the way they did, which is what `bound` answers.
3618 let chrome = super::help::chrome();
3619 assert_eq!(
3620 chrome.bound("f1").and_then(|binding| binding.group.clone()),
3621 Some("System".to_owned())
3622 );
3623 assert_eq!(
3624 chrome.bound("f1").map(|binding| binding.action.clone()),
3625 Some(quasi_router::Action::get("/help"))
3626 );
3627 }
3628
3629 #[test]
3630 fn every_bound_key_points_at_an_address_this_router_serves() {
3631 // The other half of "cannot disagree": a binding naming a route that does
3632 // not exist would be a NotFound the first time it was pressed, which is a
3633 // lie in a table that only shows up under a finger.
3634 //
3635 // Asked of the route table rather than by calling each address, and the
3636 // difference started mattering when the table grew past the four safe keys.
3637 // A live route refuses a state it cannot act in -- `/undo` with an empty
3638 // stack, `/detail/similar` with nothing analysed -- and both refuse with
3639 // `NotFound`, which is indistinguishable from an address nobody serves.
3640 // Calling would have this test asserting that thirteen preconditions are
3641 // satisfiable by one fixture, which is not what it is for.
3642 let router = router();
3643 let served: Vec<(Method, &str)> = router.routes().collect();
3644 for binding in super::help::chrome().bindings {
3645 let path = binding
3646 .action
3647 .destination
3648 .route()
3649 .expect("a binding goes somewhere in the app");
3650 assert!(
3651 served
3652 .iter()
3653 .any(|(method, pattern)| *method == binding.action.method && covers(pattern, path)),
3654 "{} points at {:?} {path}, which no route serves",
3655 binding.key,
3656 binding.action.method,
3657 );
3658 }
3659 }
3660
3661 /// Whether a registered route pattern is the one this address lands on.
3662 ///
3663 /// The five panel keys address `/panels/sidebar` and the router holds
3664 /// `/panels/{panel}`, so a string comparison answers no to a binding that works.
3665 /// Segment counts and literals have to agree; a `{name}` segment takes whatever
3666 /// is in its place, which is the only thing the router's own matching does that
3667 /// matters here.
3668 fn covers(pattern: &str, path: &str) -> bool {
3669 let pattern = pattern.split('/');
3670 let mut path = path.split('/');
3671 for expected in pattern {
3672 let Some(actual) = path.next() else {
3673 return false;
3674 };
3675 let placeholder = expected.starts_with('{') && expected.ends_with('}');
3676 if !placeholder && expected != actual {
3677 return false;
3678 }
3679 }
3680 path.next().is_none()
3681 }
3682
3683 #[test]
3684 fn the_shifted_bindings_do_not_shadow_their_bare_twins() {
3685 // `f`/`shift+f` and `d`/`shift+d`. The renderer matches exactly as of
3686 // quasi-immediate 0.52.0, so these are four entries rather than two; before
3687 // that the bare one answered both and this table is the app that found it.
3688 let bindings = super::help::chrome().bindings;
3689 let key = |wanted: &str| {
3690 bindings
3691 .iter()
3692 .find(|binding| binding.key == wanted)
3693 .unwrap_or_else(|| panic!("{wanted} is not bound"))
3694 .action
3695 .route()
3696 .expect("bound to this app's own router")
3697 .to_owned()
3698 };
3699 assert_ne!(key("f"), key("shift+f"));
3700 assert_ne!(key("d"), key("shift+d"));
3701 }
3702
3703 #[test]
3704 fn the_help_overlay_is_drawn_over_what_is_showing() {
3705 let response = helping(Request::get("/help")).unwrap();
3706 assert!(matches!(response.outcome, Outcome::Over(_)));
3707 }
3708
3709 #[test]
3710 fn switching_tabs_answers_a_fragment_so_the_overlay_survives() {
3711 // Second consumer of the overlay-refresh finding, and a sharper one than
3712 // the rename preview: a tabbed overlay is not buildable at all without
3713 // fragments, because both outcomes that carry a screen destroy the layer.
3714 let response = helping(
3715 Request::post("/help/tab")
3716 .sending(Params::new().with(Node::SELECTED.to_owned(), "features".to_owned())),
3717 )
3718 .unwrap();
3719
3720 let Outcome::Fragment { region, node } = &response.outcome else {
3721 panic!("expected a fragment, got {:?}", response.outcome);
3722 };
3723 assert_eq!(region, "help-tab");
3724 // The features tab is a document, so it is markdown source rather than a
3725 // tree of headings the description would have to invent structure for.
3726 assert!(matches!(node, Node::Rich { .. }));
3727 }
3728
3729 #[test]
3730 fn a_tab_that_is_not_one_of_the_two_is_refused() {
3731 assert!(
3732 helping(
3733 Request::post("/help/tab")
3734 .sending(Params::new().with(Node::SELECTED.to_owned(), "elsewhere".to_owned())),
3735 )
3736 .is_err()
3737 );
3738 }
3739
3740 #[test]
3741 fn the_shortcuts_tab_is_what_a_bare_help_request_answers() {
3742 let response = helping(Request::get("/help")).unwrap();
3743 let screen = overlay(&response);
3744
3745 let chosen = nodes(screen).iter().find_map(|node| match node {
3746 Node::Select { chosen, .. } => chosen.clone(),
3747 _ => None,
3748 });
3749 assert_eq!(chosen.as_deref(), Some("shortcuts"));
3750 }
3751
3752 /// A row's primary part.
3753 fn primary_of(row: &quasi_router::Row) -> Option<String> {
3754 row.parts
3755 .iter()
3756 .find(|part| part.role == quasi_router::layout::RowPart::Primary)
3757 .and_then(|part| match &part.node {
3758 Node::Text { text, .. } => Some(text.clone()),
3759 _ => None,
3760 })
3761 }
3762
3763 /// What the list on a screen says it is not showing.
3764 fn more_of(screen: &Screen) -> Option<quasi_router::Rest> {
3765 nodes(screen).iter().find_map(|node| match node {
3766 Node::List { more, .. } => more.clone(),
3767 _ => None,
3768 })
3769 }
3770
3771 // The main window.
3772
3773 /// A window with nothing playing and nothing to say.
3774 struct Quiet;
3775
3776 impl Shell for Quiet {
3777 fn playing(&self) -> Option<Playing> {
3778 None
3779 }
3780
3781 fn chosen(&self) -> usize {
3782 0
3783 }
3784
3785 fn analysed(&self) -> Analysed {
3786 Analysed::default()
3787 }
3788
3789 fn status(&self) -> Option<(String, Saying)> {
3790 None
3791 }
3792
3793 fn hinting(&self) -> bool {
3794 false
3795 }
3796
3797 fn device(&self) -> Option<String> {
3798 Some("Built-in Output".to_owned())
3799 }
3800
3801 fn tags(&self) -> Vec<String> {
3802 Vec::new()
3803 }
3804
3805 fn migrating(&self) -> Option<Migrating> {
3806 None
3807 }
3808
3809 fn stop(&self) {}
3810 fn dismiss_hint(&self) {}
3811 fn pause_migration(&self) {}
3812 }
3813
3814 /// A window in memory, recording what was asked of it.
3815 #[derive(Default)]
3816 struct FakeShell {
3817 playing: Option<Playing>,
3818 chosen: usize,
3819 analysed: Analysed,
3820 status: Option<(String, Saying)>,
3821 hinting: bool,
3822 device: Option<String>,
3823 tags: Vec<String>,
3824 migrating: Option<Migrating>,
3825 asked: RefCell<Vec<String>>,
3826 }
3827
3828 impl Shell for FakeShell {
3829 fn playing(&self) -> Option<Playing> {
3830 self.playing.clone()
3831 }
3832
3833 fn chosen(&self) -> usize {
3834 self.chosen
3835 }
3836
3837 fn analysed(&self) -> Analysed {
3838 self.analysed
3839 }
3840
3841 fn status(&self) -> Option<(String, Saying)> {
3842 self.status.clone()
3843 }
3844
3845 fn hinting(&self) -> bool {
3846 self.hinting
3847 }
3848
3849 fn device(&self) -> Option<String> {
3850 self.device.clone()
3851 }
3852
3853 fn tags(&self) -> Vec<String> {
3854 self.tags.clone()
3855 }
3856
3857 fn migrating(&self) -> Option<Migrating> {
3858 self.migrating
3859 }
3860
3861 fn stop(&self) {
3862 self.asked.borrow_mut().push("stop".to_owned());
3863 }
3864
3865 fn dismiss_hint(&self) {
3866 self.asked.borrow_mut().push("dismiss".to_owned());
3867 }
3868
3869 fn pause_migration(&self) {
3870 self.asked.borrow_mut().push("pause".to_owned());
3871 }
3872 }
3873
3874 /// A router call against this window.
3875 fn showing(shell: &FakeShell, request: Request) -> Result<Response, quasi_router::RouteError> {
3876 let store = Store::default();
3877 let sync = Offline;
3878 let files = FakeFiles::with(vec![sample(1, "kick.wav"), sample(2, "snare.wav")]);
3879 let themes = themes();
3880 let state = Panels {
3881 config: &store,
3882 sync: &sync,
3883 files: &files,
3884 export: &Idle,
3885 detail: &Unfocused,
3886 bulk: &Unchosen,
3887 shell,
3888 library: &Empty,
3889 bar: &Still,
3890 naming: &Unnamed,
3891 importing: &NoImport,
3892 integrity: &Sound,
3893 editor: &Unedited,
3894 forge: &Unforged,
3895 queue: &Unqueued,
3896 filters: &Unfiltered,
3897 themes: &themes,
3898 };
3899 router().handle(&state, request)
3900 }
3901
3902 /// The main screen.
3903 fn shown(shell: &FakeShell) -> Screen {
3904 screen_of(&showing(shell, Request::get("/")).unwrap()).clone()
3905 }
3906
3907 /// The regions of a screen, by kind.
3908 fn regions(screen: &Screen) -> Vec<(String, quasi_router::RegionKind)> {
3909 screen
3910 .slots
3911 .iter()
3912 .map(|slot| (slot.id.clone(), slot.kind.clone()))
3913 .collect()
3914 }
3915
3916 /// Every meter on a screen.
3917 fn meters(screen: &Screen) -> Vec<quasi_router::Meter> {
3918 nodes(screen)
3919 .iter()
3920 .filter_map(|node| match node {
3921 Node::Meter(meter) => Some(meter.clone()),
3922 _ => None,
3923 })
3924 .collect()
3925 }
3926
3927 /// Every figure on a screen, as value and caption.
3928 fn figures(screen: &Screen) -> Vec<(String, String)> {
3929 nodes(screen)
3930 .iter()
3931 .filter_map(|node| match node {
3932 Node::Figure(figure) => Some((figure.value.clone(), figure.caption.clone())),
3933 _ => None,
3934 })
3935 .collect()
3936 }
3937
3938 #[test]
3939 fn the_main_screen_is_a_list_and_a_band() {
3940 // THE POINT OF THIS PORT. Every described screen before it was one Pane, so
3941 // the arrangement had nothing to arrange and RegionKind::Band had never been
3942 // written by this app.
3943 let shell = FakeShell::default();
3944 let screen = shown(&shell);
3945
3946 assert_eq!(
3947 regions(&screen),
3948 [
3949 ("toolbar-bar".to_owned(), quasi_router::RegionKind::Band),
3950 ("library-side".to_owned(), quasi_router::RegionKind::Sidebar),
3951 ("files-body".to_owned(), quasi_router::RegionKind::Pane),
3952 ("shell-foot".to_owned(), quasi_router::RegionKind::Band),
3953 ]
3954 );
3955 }
3956
3957 #[test]
3958 fn the_list_region_is_the_same_description_the_files_window_answers() {
3959 // `files::body` has two callers and one definition. If that ever stops
3960 // being true this is what says so: the table in the main window and the
3961 // table in the standalone window are the same rows and the same columns.
3962 let shell = FakeShell::default();
3963 let embedded = table_of(&shown(&shell));
3964
3965 let files = FakeFiles::with(vec![sample(1, "kick.wav"), sample(2, "snare.wav")]);
3966 let alone = table_of(screen_of(&listing(&files, Request::get("/files")).unwrap()));
3967
3968 assert_eq!(embedded.0, alone.0);
3969 assert_eq!(embedded.1, alone.1);
3970 }
3971
3972 #[test]
3973 fn a_playing_sample_reports_its_position_as_a_proportion() {
3974 // THE FINDING, and the second consumer of quasi:docs:meter-refuses-progress.
3975 // `Meter`'s header says it is "a proportion of a set and not the progress of
3976 // an operation", and playback position is exactly the refused case -- it
3977 // moves at the sample clock with nobody touching anything. It is described
3978 // as a Meter regardless, because `Runtime::reload` moved the premise the
3979 // paragraph rests on.
3980 let shell = FakeShell {
3981 playing: Some(Playing {
3982 name: "kick.wav".to_owned(),
3983 position: 42,
3984 total: 130,
3985 }),
3986 ..FakeShell::default()
3987 };
3988 let screen = shown(&shell);
3989
3990 let transport = &meters(&screen)[0];
3991 assert_eq!((transport.done, transport.total), (42, 130));
3992
3993 // The clock is said as well as the bar, because a proportion is not a
3994 // duration and a reader wants both.
3995 assert!(said(&screen).contains("0:42/2:10"));
3996 assert!(said(&screen).contains("Playing: kick.wav"));
3997 assert!(acts(&screen).iter().any(|label| label == "Stop"));
3998 }
3999
4000 #[test]
4001 fn nothing_playing_means_no_transport_at_all() {
4002 let shell = FakeShell::default();
4003 let screen = shown(&shell);
4004 assert!(meters(&screen).is_empty());
4005 assert!(!acts(&screen).iter().any(|label| label == "Stop"));
4006 }
4007
4008 #[test]
4009 fn analysis_coverage_is_the_proportion_meter_was_added_for() {
4010 let shell = FakeShell {
4011 analysed: Analysed {
4012 samples: 200,
4013 analysed: 142,
4014 untagged: 17,
4015 },
4016 ..FakeShell::default()
4017 };
4018 let screen = shown(&shell);
4019
4020 let coverage = &meters(&screen)[0];
4021 assert_eq!((coverage.done, coverage.total), (142, 200));
4022 assert_eq!(coverage.tone, quasi_router::layout::Tone::Neutral);
4023
4024 // The untagged count is a separate fact about the same set rather than a
4025 // second proportion of it.
4026 assert_eq!(figures(&screen), [("17".to_owned(), "untagged".to_owned())]);
4027 }
4028
4029 #[test]
4030 fn a_fully_analysed_set_says_so_in_its_tone() {
4031 let shell = FakeShell {
4032 analysed: Analysed {
4033 samples: 200,
4034 analysed: 200,
4035 untagged: 0,
4036 },
4037 ..FakeShell::default()
4038 };
4039 assert_eq!(
4040 meters(&shown(&shell))[0].tone,
4041 quasi_router::layout::Tone::Success
4042 );
4043 }
4044
4045 #[test]
4046 fn the_untagged_count_waits_for_analysis_to_produce_something() {
4047 // The shipped footer's rule: before the first result every sample is
4048 // untagged and the count says nothing.
4049 let shell = FakeShell {
4050 analysed: Analysed {
4051 samples: 200,
4052 analysed: 0,
4053 untagged: 200,
4054 },
4055 ..FakeShell::default()
4056 };
4057 assert!(figures(&shown(&shell)).is_empty());
4058 }
4059
4060 #[test]
4061 fn a_status_carries_its_tone_and_not_a_timer() {
4062 // The shipped footer picks the colour by matching substrings against the
4063 // message and then decides how long to keep it up from two constants and an
4064 // elapsed Instant. The first is a described fact; the second is renderer
4065 // policy and is gone.
4066 let failed = FakeShell {
4067 status: Some(("Import error: bad header".to_owned(), Saying::Failed)),
4068 ..FakeShell::default()
4069 };
4070 let notices: Vec<quasi_router::layout::Tone> = nodes(&shown(&failed))
4071 .iter()
4072 .filter_map(|node| match node {
4073 Node::Notice { tone, .. } => Some(*tone),
4074 _ => None,
4075 })
4076 .collect();
4077 assert_eq!(notices, [quasi_router::layout::Tone::Danger]);
4078
4079 let fine = FakeShell {
4080 status: Some(("Imported 42 samples".to_owned(), Saying::Ordinary)),
4081 ..FakeShell::default()
4082 };
4083 assert!(said(&shown(&fine)).contains("Imported 42 samples"));
4084 }
4085
4086 #[test]
4087 fn the_first_launch_hint_shows_only_while_there_is_nothing_to_say() {
4088 let hinting = FakeShell {
4089 hinting: true,
4090 ..FakeShell::default()
4091 };
4092 assert!(
4093 acts(&shown(&hinting))
4094 .iter()
4095 .any(|label| label == "Dismiss")
4096 );
4097
4098 // A status displaces it, which is the shipped footer's `else if`.
4099 let both = FakeShell {
4100 hinting: true,
4101 status: Some(("Imported 42 samples".to_owned(), Saying::Ordinary)),
4102 ..FakeShell::default()
4103 };
4104 assert!(!acts(&shown(&both)).iter().any(|label| label == "Dismiss"));
4105 }
4106
4107 #[test]
4108 fn a_missing_preview_device_is_said_rather_than_left_out() {
4109 // The line exists so a silent preview is diagnosable without opening
4110 // Settings, so the case it exists for is the one that must not vanish.
4111 let none = FakeShell::default();
4112 let screen = shown(&none);
4113 assert!(said(&screen).contains("Preview: no device"));
4114
4115 let toned = nodes(&screen).iter().any(|node| {
4116 matches!(node, Node::Text { text, tone }
4117 if text.contains("no device") && *tone == quasi_router::layout::Tone::Warning)
4118 });
4119 assert!(toned, "a missing device is a warning, not an ordinary fact");
4120 }
4121
4122 #[test]
4123 fn the_bands_writes_are_asked_for_rather_than_performed() {
4124 let shell = FakeShell::default();
4125 showing(&shell, Request::post("/playback/stop")).unwrap();
4126 showing(&shell, Request::post("/hint/dismiss")).unwrap();
4127 assert_eq!(shell.asked.borrow().as_slice(), ["stop", "dismiss"]);
4128 }
4129
4130 #[test]
4131 fn a_lone_selection_is_not_counted_at_the_reader() {
4132 // One row selected is what the app looks like most of the time, so saying
4133 // "1 selected" is noise. The shipped footer's threshold, kept.
4134 let one = FakeShell {
4135 chosen: 1,
4136 ..FakeShell::default()
4137 };
4138 assert!(figures(&shown(&one)).is_empty());
4139
4140 let several = FakeShell {
4141 chosen: 4,
4142 ..FakeShell::default()
4143 };
4144 assert_eq!(
4145 figures(&shown(&several)),
4146 [("4".to_owned(), "selected".to_owned())]
4147 );
4148 }
4149
4150 // The sidebar.
4151
4152 /// A library with nothing in it but the one vault it must have.
4153 struct Empty;
4154
4155 impl Library for Empty {
4156 fn vaults(&self) -> Vec<Vault> {
4157 vec![Vault {
4158 id: 1,
4159 name: "Library".to_owned(),
4160 current: true,
4161 }]
4162 }
4163
4164 fn collections(&self) -> Vec<Collection> {
4165 Vec::new()
4166 }
4167
4168 fn tags(&self) -> Vec<Filter> {
4169 Vec::new()
4170 }
4171
4172 fn open_vault(&self, _id: i64) {}
4173 fn delete_vault(&self, _id: i64) {}
4174 fn toggle_tag(&self, _path: &str) {}
4175 fn remove_tag(&self, _path: &str) {}
4176 fn open_collection(&self, _id: i64) {}
4177 fn close_collection(&self) {}
4178 fn delete_collection(&self, _id: i64) {}
4179 }
4180
4181 /// A library in memory, recording what was asked of it.
4182 #[derive(Default)]
4183 struct FakeLibrary {
4184 vaults: Vec<Vault>,
4185 collections: Vec<Collection>,
4186 tags: Vec<Filter>,
4187 asked: RefCell<Vec<String>>,
4188 }
4189
4190 impl FakeLibrary {
4191 fn stocked() -> Self {
4192 Self {
4193 vaults: vec![
4194 Vault {
4195 id: 1,
4196 name: "Drums".to_owned(),
4197 current: true,
4198 },
4199 Vault {
4200 id: 2,
4201 name: "Synths".to_owned(),
4202 current: false,
4203 },
4204 ],
4205 collections: vec![
4206 Collection {
4207 id: 10,
4208 name: "Favourites".to_owned(),
4209 holding: Holding::Fixed(12),
4210 active: false,
4211 },
4212 Collection {
4213 id: 11,
4214 name: "Fast".to_owned(),
4215 holding: Holding::Dynamic,
4216 active: true,
4217 },
4218 ],
4219 tags: vec![
4220 Filter {
4221 path: "drums".to_owned(),
4222 on: false,
4223 },
4224 Filter {
4225 path: "drums.kick".to_owned(),
4226 on: true,
4227 },
4228 ],
4229 asked: RefCell::new(Vec::new()),
4230 }
4231 }
4232
4233 fn only_one_vault() -> Self {
4234 Self {
4235 vaults: vec![Vault {
4236 id: 1,
4237 name: "Library".to_owned(),
4238 current: true,
4239 }],
4240 ..Self::default()
4241 }
4242 }
4243
4244 fn note(&self, what: impl Into<String>) {
4245 self.asked.borrow_mut().push(what.into());
4246 }
4247
4248 fn asked(&self) -> Vec<String> {
4249 self.asked.borrow().clone()
4250 }
4251 }
4252
4253 impl Library for FakeLibrary {
4254 fn vaults(&self) -> Vec<Vault> {
4255 self.vaults.clone()
4256 }
4257
4258 fn collections(&self) -> Vec<Collection> {
4259 self.collections.clone()
4260 }
4261
4262 fn tags(&self) -> Vec<Filter> {
4263 self.tags.clone()
4264 }
4265
4266 fn open_vault(&self, id: i64) {
4267 self.note(format!("open vault {id}"));
4268 }
4269
4270 fn delete_vault(&self, id: i64) {
4271 self.note(format!("delete vault {id}"));
4272 }
4273
4274 fn toggle_tag(&self, path: &str) {
4275 self.note(format!("toggle {path}"));
4276 }
4277
4278 fn remove_tag(&self, path: &str) {
4279 self.note(format!("remove {path}"));
4280 }
4281
4282 fn open_collection(&self, id: i64) {
4283 self.note(format!("open collection {id}"));
4284 }
4285
4286 fn close_collection(&self) {
4287 self.note("close collection");
4288 }
4289
4290 fn delete_collection(&self, id: i64) {
4291 self.note(format!("delete collection {id}"));
4292 }
4293 }
4294
4295 /// A router call against this library.
4296 fn browsing(library: &FakeLibrary, request: Request) -> Result<Response, quasi_router::RouteError> {
4297 let store = Store::default();
4298 let sync = Offline;
4299 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
4300 let themes = themes();
4301 let state = Panels {
4302 config: &store,
4303 sync: &sync,
4304 files: &files,
4305 export: &Idle,
4306 detail: &Unfocused,
4307 bulk: &Unchosen,
4308 shell: &Quiet,
4309 library,
4310 bar: &Still,
4311 naming: &Unnamed,
4312 importing: &NoImport,
4313 integrity: &Sound,
4314 editor: &Unedited,
4315 forge: &Unforged,
4316 queue: &Unqueued,
4317 filters: &Unfiltered,
4318 themes: &themes,
4319 };
4320 router().handle(&state, request)
4321 }
4322
4323 /// The main screen, seen through this library.
4324 fn browsed(library: &FakeLibrary) -> Screen {
4325 screen_of(&browsing(library, Request::get("/")).unwrap()).clone()
4326 }
4327
4328 /// Every row on a screen, across every list, with its acts.
4329 fn all_rows(screen: &Screen) -> Vec<quasi_router::Row> {
4330 nodes(screen)
4331 .iter()
4332 .filter_map(|node| match node {
4333 Node::List { rows, .. } => Some(rows.clone()),
4334 _ => None,
4335 })
4336 .flatten()
4337 .collect()
4338 }
4339
4340 /// Every latched chip on a screen, by label.
4341 fn latched(screen: &Screen) -> Vec<String> {
4342 nodes(screen)
4343 .iter()
4344 .filter_map(|node| match node {
4345 Node::Token(tag) if tag.latched => Some(tag.label.clone()),
4346 _ => None,
4347 })
4348 .collect()
4349 }
4350
4351 #[test]
4352 fn the_main_screen_now_has_all_three_region_kinds() {
4353 // The sidebar completes the window. Pane and Band landed with `shell`;
4354 // this is the third and last kind this app has a use for.
4355 let library = FakeLibrary::stocked();
4356 assert_eq!(
4357 regions(&browsed(&library)),
4358 [
4359 ("toolbar-bar".to_owned(), quasi_router::RegionKind::Band),
4360 ("library-side".to_owned(), quasi_router::RegionKind::Sidebar),
4361 ("files-body".to_owned(), quasi_router::RegionKind::Pane),
4362 ("shell-foot".to_owned(), quasi_router::RegionKind::Band),
4363 ]
4364 );
4365 }
4366
4367 #[test]
4368 fn a_destructive_control_carries_its_own_prompt() {
4369 // `ConfirmAction::DeleteVfs`, `::DeleteCollection` and `::RemoveTagGlobally`
4370 // written the other way round: the prompt lives on the act rather than in a
4371 // 140-line match that turns an enum variant back into a sentence.
4372 let library = FakeLibrary::stocked();
4373 let screen = browsed(&library);
4374
4375 let prompts: Vec<String> = all_rows(&screen)
4376 .iter()
4377 .flat_map(|row| row.menu.clone())
4378 .filter_map(|act| act.confirm.clone())
4379 .collect();
4380
4381 assert!(
4382 prompts
4383 .iter()
4384 .any(|ask| ask == "Delete vault \"Drums\" and all its contents?")
4385 );
4386 assert!(
4387 prompts
4388 .iter()
4389 .any(|ask| ask == "Delete collection \"Favourites\"?")
4390 );
4391 assert!(
4392 prompts
4393 .iter()
4394 .any(|ask| ask == "Remove tag \"drums\" from every sample that has it?")
4395 );
4396
4397 // And every one of them is toned, which is the second half of what the
4398 // dialog's `danger` flag was carrying.
4399 for act in all_rows(&screen).iter().flat_map(|row| row.menu.clone()) {
4400 if act.confirm.is_some() {
4401 assert_eq!(act.tone, quasi_router::layout::Tone::Danger);
4402 }
4403 }
4404 }
4405
4406 #[test]
4407 fn the_last_vault_offers_delete_dead_and_says_why() {
4408 // Offered rather than hidden, which is the shipped menu's own choice:
4409 // "Always render Delete so the user can see the capability exists."
4410 // FOURTH consumer of quasi:vocabulary:act-precondition -- the sentence that
4411 // would revive it sits beside the control instead of on it.
4412 let alone = FakeLibrary::only_one_vault();
4413 let screen = browsed(&alone);
4414
4415 let delete = all_rows(&screen)
4416 .iter()
4417 .flat_map(|row| row.menu.clone())
4418 .find(|act| act.label == "Delete")
4419 .expect("the capability is still shown");
4420 assert_eq!(delete.state, Some(quasi_router::layout::State::Disabled));
4421 assert!(said(&screen).contains("audiofiles needs at least one"));
4422
4423 // And the route refuses it too, because an address is reachable by typing.
4424 assert!(browsing(&alone, Request::post("/vaults/1/delete")).is_err());
4425 assert!(alone.asked().is_empty());
4426 }
4427
4428 #[test]
4429 fn deleting_a_vault_is_allowed_once_there_are_two() {
4430 let library = FakeLibrary::stocked();
4431 browsing(&library, Request::post("/vaults/2/delete")).unwrap();
4432 assert_eq!(library.asked(), ["delete vault 2"]);
4433 }
4434
4435 #[test]
4436 fn a_tag_filter_is_a_chip_that_latches() {
4437 // A filter is on or off, which is exactly what Token::Chip's `latched`
4438 // says, and what a plain badge could not.
4439 let library = FakeLibrary::stocked();
4440 assert_eq!(latched(&browsed(&library)), ["drums.kick"]);
4441
4442 browsing(&library, Request::post("/tags/drums/filter")).unwrap();
4443 assert_eq!(library.asked(), ["toggle drums"]);
4444 }
4445
4446 #[test]
4447 fn a_tag_is_named_by_its_whole_path_because_the_tree_is_not_described() {
4448 // THE FINDING. `RowPart` has no depth and no member holds rows inside a
4449 // row, so the shipped sidebar's TagNode tree flattens to full dotted paths.
4450 // Honest about what the filter operates on -- `required_tags` holds exact
4451 // paths -- and it loses the grouping, the collapse, and the
4452 // parent-that-is-only-a-parent distinction.
4453 let library = FakeLibrary::stocked();
4454 let screen = browsed(&library);
4455
4456 // The sidebar's own region, since the toolbar's panel toggles are chips too.
4457 let chips: Vec<String> = screen
4458 .slots
4459 .iter()
4460 .filter(|slot| slot.id == "library-side")
4461 .flat_map(|slot| &slot.body)
4462 .filter_map(|placed| match &placed.node {
4463 Node::Token(tag) => Some(tag.label.clone()),
4464 _ => None,
4465 })
4466 .collect();
4467 assert_eq!(chips, ["drums", "drums.kick"]);
4468 }
4469
4470 #[test]
4471 fn an_active_collection_offers_to_close_rather_than_to_open() {
4472 let library = FakeLibrary::stocked();
4473 browsing(&library, Request::post("/collections/10/open")).unwrap();
4474 browsing(&library, Request::post("/collections/close")).unwrap();
4475 assert_eq!(library.asked(), ["open collection 10", "close collection"]);
4476
4477 // Which one a row calls is a fact about whether it is showing.
4478 // The collection rows, not the vault rows: `current` marks the vault being
4479 // browsed as well as the collection being shown.
4480 let rows = all_rows(&browsed(&library));
4481 let active = rows
4482 .iter()
4483 .filter(|row| {
4484 row.activate
4485 .as_ref()
4486 .and_then(|action| action.destination.route())
4487 .is_some_and(|path| path.contains("collection"))
4488 })
4489 .find(|row| row.current)
4490 .expect("one collection is showing");
4491 assert_eq!(
4492 active
4493 .activate
4494 .as_ref()
4495 .and_then(|action| action.destination.route()),
4496 Some("/collections/close")
4497 );
4498 }
4499
4500 #[test]
4501 fn a_collection_says_what_it_holds_beside_its_name_rather_than_inside_it() {
4502 // The shipped row appends " (auto)" or " (12)" to the label. A token is
4503 // where a second fact about a row goes.
4504 let library = FakeLibrary::stocked();
4505 let rows = all_rows(&browsed(&library));
4506
4507 let marks: Vec<String> = rows
4508 .iter()
4509 .flat_map(|row| {
4510 row.parts
4511 .iter()
4512 .filter(|part| part.role == quasi_router::layout::RowPart::Tokens)
4513 .filter_map(|part| match &part.node {
4514 Node::Token(tag) => Some(tag.label.clone()),
4515 _ => None,
4516 })
4517 .collect::<Vec<_>>()
4518 })
4519 .collect();
4520 assert_eq!(marks, ["12", "auto"]);
4521
4522 // And the name is just the name.
4523 assert!(
4524 rows.iter()
4525 .any(|row| primary_of(row).as_deref() == Some("Favourites"))
4526 );
4527 }
4528
4529 #[test]
4530 fn an_empty_library_says_so_in_each_section() {
4531 let bare = FakeLibrary::only_one_vault();
4532 let says = said(&browsed(&bare));
4533 assert!(says.contains("No collections yet."));
4534 assert!(says.contains("No tags yet."));
4535 }
4536
4537 #[test]
4538 fn opening_a_vault_that_is_not_there_is_refused() {
4539 let library = FakeLibrary::stocked();
4540 assert!(browsing(&library, Request::post("/vaults/99/open")).is_err());
4541 assert!(browsing(&library, Request::post("/vaults/nope/open")).is_err());
4542 assert!(library.asked().is_empty());
4543
4544 browsing(&library, Request::post("/vaults/2/open")).unwrap();
4545 assert_eq!(library.asked(), ["open vault 2"]);
4546 }
4547
4548 #[test]
4549 fn deleting_a_collection_and_a_tag_are_asked_for() {
4550 let library = FakeLibrary::stocked();
4551 browsing(&library, Request::post("/collections/11/delete")).unwrap();
4552 browsing(&library, Request::post("/tags/drums.kick/remove")).unwrap();
4553 assert_eq!(
4554 library.asked(),
4555 ["delete collection 11", "remove drums.kick"]
4556 );
4557 }
4558
4559 #[test]
4560 fn new_vault_opens_a_described_modal_rather_than_asking_the_app_for_one() {
4561 // It was `Intent::NewVault`, which opened the *shipped* name modal: the one
4562 // control on this screen whose answer was still drawn by hand. It is a
4563 // navigation to `naming`'s address now, and the sidebar asks the app for
4564 // nothing.
4565 let library = FakeLibrary::stocked();
4566 let opened = acts(&browsed(&library));
4567 assert!(opened.contains(&"New vault".to_owned()), "{opened:?}");
4568 // The address is `naming`'s now, both verbs of it, and the sidebar's own
4569 // capability is never asked for a vault it cannot make.
4570 assert!(matches!(
4571 browsing(&library, Request::get("/vaults/new"))
4572 .unwrap()
4573 .outcome,
4574 Outcome::Over(_)
4575 ));
4576 assert!(library.asked().is_empty());
4577 }
4578
4579 // The toolbar.
4580
4581 /// A toolbar at the root with nothing typed.
4582 struct Still;
4583
4584 impl Bar for Still {
4585 fn place(&self) -> Where {
4586 Where::Folder { trail: Vec::new() }
4587 }
4588
4589 fn searching(&self) -> Searching {
4590 Searching {
4591 query: String::new(),
4592 everywhere: false,
4593 filtered: false,
4594 results: 0,
4595 filters: 0,
4596 describes: String::new(),
4597 }
4598 }
4599
4600 fn showing(&self) -> Vec<Panel> {
4601 Vec::new()
4602 }
4603
4604 fn undoable(&self) -> bool {
4605 false
4606 }
4607
4608 fn search(&self, _query: &str) {}
4609 fn set_scope(&self, _everywhere: bool) {}
4610 fn save_collection(&self, _name: &str) {}
4611 fn undo(&self) {}
4612 fn toggle(&self, _panel: Panel) {}
4613 fn go_root(&self) {}
4614 fn go_to(&self, _id: i64, _depth: usize) {}
4615 fn leave(&self) {}
4616 }
4617
4618 /// A toolbar in memory, recording what was asked of it.
4619 struct FakeBar {
4620 place: Where,
4621 searching: Searching,
4622 showing: Vec<Panel>,
4623 undoable: bool,
4624 asked: RefCell<Vec<String>>,
4625 }
4626
4627 impl FakeBar {
4628 fn at(place: Where) -> Self {
4629 Self {
4630 place,
4631 searching: Searching {
4632 query: String::new(),
4633 everywhere: false,
4634 filtered: false,
4635 results: 0,
4636 filters: 0,
4637 describes: String::new(),
4638 },
4639 showing: Vec::new(),
4640 undoable: false,
4641 asked: RefCell::new(Vec::new()),
4642 }
4643 }
4644
4645 fn deep() -> Self {
4646 Self::at(Where::Folder {
4647 trail: vec![
4648 Crumb {
4649 id: 7,
4650 name: "kits".to_owned(),
4651 },
4652 Crumb {
4653 id: 8,
4654 name: "808".to_owned(),
4655 },
4656 ],
4657 })
4658 }
4659
4660 fn filtering() -> Self {
4661 let mut bar = Self::at(Where::Folder { trail: Vec::new() });
4662 bar.searching = Searching {
4663 query: "kick".to_owned(),
4664 everywhere: true,
4665 filtered: true,
4666 results: 42,
4667 filters: 3,
4668 describes: "Kicks under 120 BPM".to_owned(),
4669 };
4670 bar
4671 }
4672
4673 fn note(&self, what: impl Into<String>) {
4674 self.asked.borrow_mut().push(what.into());
4675 }
4676
4677 fn asked(&self) -> Vec<String> {
4678 self.asked.borrow().clone()
4679 }
4680 }
4681
4682 impl Bar for FakeBar {
4683 fn place(&self) -> Where {
4684 self.place.clone()
4685 }
4686
4687 fn searching(&self) -> Searching {
4688 self.searching.clone()
4689 }
4690
4691 fn showing(&self) -> Vec<Panel> {
4692 self.showing.clone()
4693 }
4694
4695 fn undoable(&self) -> bool {
4696 self.undoable
4697 }
4698
4699 fn search(&self, query: &str) {
4700 self.note(format!("search {query}"));
4701 }
4702
4703 fn set_scope(&self, everywhere: bool) {
4704 self.note(if everywhere { "everywhere" } else { "here" });
4705 }
4706
4707 fn save_collection(&self, name: &str) {
4708 self.note(format!("save {name}"));
4709 }
4710
4711 fn undo(&self) {
4712 self.note("undo");
4713 }
4714
4715 fn toggle(&self, panel: Panel) {
4716 self.note(format!("toggle {}", panel.as_str()));
4717 }
4718
4719 fn go_root(&self) {
4720 self.note("root");
4721 }
4722
4723 fn go_to(&self, id: i64, depth: usize) {
4724 self.note(format!("go {id} at {depth}"));
4725 }
4726
4727 fn leave(&self) {
4728 self.note("leave");
4729 }
4730 }
4731
4732 /// A router call against this toolbar.
4733 fn barred(bar: &FakeBar, request: Request) -> Result<Response, quasi_router::RouteError> {
4734 let store = Store::default();
4735 let sync = Offline;
4736 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
4737 let themes = themes();
4738 let state = Panels {
4739 config: &store,
4740 sync: &sync,
4741 files: &files,
4742 export: &Idle,
4743 detail: &Unfocused,
4744 bulk: &Unchosen,
4745 shell: &Quiet,
4746 library: &Empty,
4747 bar,
4748 naming: &Unnamed,
4749 importing: &NoImport,
4750 integrity: &Sound,
4751 editor: &Unedited,
4752 forge: &Unforged,
4753 queue: &Unqueued,
4754 filters: &Unfiltered,
4755 themes: &themes,
4756 };
4757 router().handle(&state, request)
4758 }
4759
4760 /// The main screen, seen through this toolbar.
4761 fn topped(bar: &FakeBar) -> Screen {
4762 screen_of(&barred(bar, Request::get("/")).unwrap()).clone()
4763 }
4764
4765 /// Every link on a screen, as text and destination.
4766 fn links(screen: &Screen) -> Vec<(String, String)> {
4767 nodes(screen)
4768 .iter()
4769 .filter_map(|node| match node {
4770 Node::Link { text, action } => Some((
4771 text.clone(),
4772 action.destination.route().unwrap_or_default().to_owned(),
4773 )),
4774 _ => None,
4775 })
4776 .collect()
4777 }
4778
4779 #[test]
4780 fn the_toolbar_is_the_window_s_fourth_region_and_its_first() {
4781 let bar = FakeBar::deep();
4782 assert_eq!(
4783 regions(&topped(&bar))
4784 .into_iter()
4785 .map(|(id, _)| id)
4786 .collect::<Vec<_>>(),
4787 ["toolbar-bar", "library-side", "files-body", "shell-foot"]
4788 );
4789 }
4790
4791 #[test]
4792 fn a_breadcrumb_is_links_and_the_place_you_are_is_not_one() {
4793 // Links rather than acts, which is `Node::Link`'s own argument: "making
4794 // every linked value a button would put a row of bevels down the first
4795 // column of half a dashboard".
4796 let bar = FakeBar::deep();
4797 let screen = topped(&bar);
4798
4799 assert_eq!(
4800 links(&screen),
4801 [
4802 ("/".to_owned(), "/here/root".to_owned()),
4803 ("kits".to_owned(), "/here/7/1".to_owned()),
4804 ]
4805 );
4806 // The last crumb is where you are, so it goes nowhere at all rather than
4807 // being a link that does nothing.
4808 assert!(said(&screen).contains("808"));
4809 }
4810
4811 #[test]
4812 fn walking_back_up_the_trail_names_how_far_along_it_went() {
4813 // The depth rides with the id because navigating to a crumb truncates the
4814 // trail behind it, and how far along a folder sits is a fact about this
4815 // trail rather than about the folder.
4816 let bar = FakeBar::deep();
4817 barred(&bar, Request::post("/here/7/1")).unwrap();
4818 barred(&bar, Request::post("/here/root")).unwrap();
4819 assert_eq!(bar.asked(), ["go 7 at 1", "root"]);
4820
4821 assert!(barred(&bar, Request::post("/here/seven/1")).is_err());
4822 assert!(barred(&bar, Request::post("/here/7/deep")).is_err());
4823 }
4824
4825 #[test]
4826 fn a_mode_offers_a_way_out_rather_than_a_shorter_path() {
4827 for place in [
4828 Where::Collection {
4829 name: "Favourites".to_owned(),
4830 },
4831 Where::Similar {
4832 name: "kick.wav".to_owned(),
4833 },
4834 ] {
4835 let bar = FakeBar::at(place);
4836 let screen = topped(&bar);
4837 assert!(links(&screen).is_empty(), "a mode is not a trail");
4838 assert!(
4839 acts(&screen)
4840 .iter()
4841 .any(|label| label == "Back to browsing")
4842 );
4843 }
4844
4845 // One control for both, because leaving either means the same thing to the
4846 // user; which mode is showing is what `Where` already says.
4847 let bar = FakeBar::at(Where::Similar {
4848 name: "kick.wav".to_owned(),
4849 });
4850 barred(&bar, Request::post("/here/leave")).unwrap();
4851 assert_eq!(bar.asked(), ["leave"]);
4852 }
4853
4854 #[test]
4855 fn the_similarity_mode_says_why_the_columns_stopped_sorting() {
4856 // The shipped breadcrumb moved this off the column headings, "where the
4857 // explanation lived on a control the user had no reason to point at". Here
4858 // it is prose beside the mode, which is where the mode is.
4859 let bar = FakeBar::at(Where::Similar {
4860 name: "kick.wav".to_owned(),
4861 });
4862 assert!(said(&topped(&bar)).contains("ranked by similarity"));
4863 }
4864
4865 #[test]
4866 fn searching_carries_what_was_typed_and_which_scope() {
4867 let bar = FakeBar::filtering();
4868 barred(
4869 &bar,
4870 Request::post("/search").sending(Params::new().with("query".to_owned(), "808".to_owned())),
4871 )
4872 .unwrap();
4873 barred(
4874 &bar,
4875 Request::post("/search/scope")
4876 .sending(Params::new().with(Node::SELECTED.to_owned(), "all".to_owned())),
4877 )
4878 .unwrap();
4879 assert_eq!(bar.asked(), ["search 808", "everywhere"]);
4880
4881 assert!(
4882 barred(
4883 &bar,
4884 Request::post("/search/scope")
4885 .sending(Params::new().with(Node::SELECTED.to_owned(), "sideways".to_owned())),
4886 )
4887 .is_err()
4888 );
4889 }
4890
4891 #[test]
4892 fn the_result_count_and_save_appear_only_once_something_narrows_the_list() {
4893 let quiet = FakeBar::at(Where::Folder { trail: Vec::new() });
4894 assert!(figures(&topped(&quiet)).is_empty());
4895 assert!(
4896 !acts(&topped(&quiet))
4897 .iter()
4898 .any(|label| label == "Save as collection")
4899 );
4900
4901 let filtering = FakeBar::filtering();
4902 assert_eq!(
4903 figures(&topped(&filtering)),
4904 [("42".to_owned(), "results".to_owned())]
4905 );
4906 assert!(
4907 acts(&topped(&filtering))
4908 .iter()
4909 .any(|label| label == "Save as collection")
4910 );
4911 }
4912
4913 #[test]
4914 fn saving_a_collection_offers_the_name_the_app_would_give_it() {
4915 // `SearchFilter::describe` is the app's, so the screen asks for it rather
4916 // than writing a second one -- `Sync::quote_cents`'s rule.
4917 let bar = FakeBar::filtering();
4918 let response = barred(&bar, Request::get("/search/save")).unwrap();
4919 let filled = fields(overlay(&response));
4920 assert_eq!(
4921 filled.get("name").cloned().flatten().as_deref(),
4922 Some("Kicks under 120 BPM")
4923 );
4924
4925 barred(
4926 &bar,
4927 Request::post("/search/save")
4928 .sending(Params::new().with("name".to_owned(), "Kicks".to_owned())),
4929 )
4930 .unwrap();
4931 assert_eq!(bar.asked(), ["save Kicks"]);
4932
4933 // An unnamed collection is refused, and so is saving nothing.
4934 assert!(barred(&bar, Request::post("/search/save")).is_err());
4935 let quiet = FakeBar::at(Where::Folder { trail: Vec::new() });
4936 assert!(barred(&quiet, Request::get("/search/save")).is_err());
4937 }
4938
4939 #[test]
4940 fn undo_is_offered_dead_when_there_is_nothing_to_undo() {
4941 let nothing = FakeBar::at(Where::Folder { trail: Vec::new() });
4942 assert!(dead(&topped(&nothing)).iter().any(|label| label == "Undo"));
4943 assert!(barred(&nothing, Request::post("/undo")).is_err());
4944
4945 let mut something = FakeBar::at(Where::Folder { trail: Vec::new() });
4946 something.undoable = true;
4947 assert!(
4948 !dead(&topped(&something))
4949 .iter()
4950 .any(|label| label == "Undo")
4951 );
4952 barred(&something, Request::post("/undo")).unwrap();
4953 assert_eq!(something.asked(), ["undo"]);
4954 }
4955
4956 #[test]
4957 fn every_panel_toggle_latches_and_is_addressable() {
4958 let mut bar = FakeBar::at(Where::Folder { trail: Vec::new() });
4959 bar.showing = vec![Panel::Sidebar, Panel::Loop];
4960
4961 let on = latched(&topped(&bar));
4962 assert_eq!(on, ["Sidebar", "Loop"]);
4963
4964 for panel in Panel::ALL {
4965 barred(&bar, Request::post(format!("/panels/{}", panel.as_str()))).unwrap();
4966 }
4967 assert_eq!(
4968 bar.asked(),
4969 [
4970 "toggle sidebar",
4971 "toggle detail",
4972 "toggle edit",
4973 "toggle instrument",
4974 "toggle loop",
4975 "toggle filters",
4976 ]
4977 );
4978
4979 assert!(barred(&bar, Request::post("/panels/nonsense")).is_err());
4980 }
4981
4982 #[test]
4983 fn the_filters_toggle_is_the_one_that_carries_a_count() {
4984 let bar = FakeBar::filtering();
4985 let labelled: Vec<String> = nodes(&topped(&bar))
4986 .iter()
4987 .filter_map(|node| match node {
4988 Node::Token(tag) => Some(tag.label.clone()),
4989 _ => None,
4990 })
4991 .collect();
4992 assert!(labelled.iter().any(|label| label == "Filters (3)"));
4993 assert!(labelled.iter().any(|label| label == "Sidebar"));
4994 }
4995
4996 #[test]
4997 fn the_toolbar_reaches_the_other_described_screens_by_address() {
4998 // The port stops being a set of windows here: Settings, Sync and Help are
4999 // screens this router serves, so getting to them is navigation.
5000 let bar = FakeBar::at(Where::Folder { trail: Vec::new() });
5001 let screen = topped(&bar);
5002
5003 let destinations: Vec<String> = nodes(&screen)
5004 .iter()
5005 .filter_map(|node| match node {
5006 Node::Act(act) => act.action.destination.route().map(ToOwned::to_owned),
5007 _ => None,
5008 })
5009 .collect();
5010 for address in ["/settings", "/sync", "/help"] {
5011 assert!(
5012 destinations.iter().any(|to| to == address),
5013 "the toolbar does not reach {address}"
5014 );
5015 assert!(barred(&bar, Request::get(address)).is_ok());
5016 }
5017 }
5018
5019 #[test]
5020 fn the_search_field_says_it_takes_the_room_the_buttons_do_not() {
5021 // What `trailing_width` was measuring for, said instead of measured. Filed
5022 // as quasicoherent `6d6a9160`, settled by Max the same day -- fill is
5023 // determined at the description stage -- and landed as `Field::width` in
5024 // quasi 0.17.0.
5025 let bar = FakeBar::at(Where::Folder { trail: Vec::new() });
5026 let asked = nodes(&topped(&bar))
5027 .iter()
5028 .find_map(|node| match node {
5029 Node::Field(field) if field.name == "query" => Some(field.width),
5030 _ => None,
5031 })
5032 .expect("the toolbar has a search field");
5033 assert_eq!(asked, quasi_router::layout::Width::Fill);
5034 }
5035
5036 /// What every member of a region is worth, by the text it carries.
5037 fn worths(screen: &Screen, region: &str) -> Vec<(String, quasi_router::layout::Priority)> {
5038 screen
5039 .slots
5040 .iter()
5041 .find(|slot| slot.id == region)
5042 .expect("the region is on the screen")
5043 .body
5044 .iter()
5045 .map(|placed| {
5046 let name = match &placed.node {
5047 Node::Token(tag) => tag.label.clone(),
5048 Node::Act(act) => act.label.clone(),
5049 Node::Text { text, .. } => text.clone(),
5050 other => format!("{other:?}"),
5051 };
5052 (name, placed.priority)
5053 })
5054 .collect()
5055 }
5056
5057 #[test]
5058 fn the_panel_toggles_say_what_they_are_worth_instead_of_collapsing_at_900px() {
5059 // The described replacement for `screen_w < 900.0`, which put all six
5060 // toggles into a View menu at a width this app chose. Ranked now, so a
5061 // narrow window keeps the two toggles that decide the shape of the window
5062 // and loses the three that open an inspector.
5063 use quasi_router::layout::Priority;
5064
5065 let bar = FakeBar::deep();
5066 let worth = worths(&topped(&bar), "toolbar-bar");
5067 let of = |label: &str| {
5068 worth
5069 .iter()
5070 .find(|(name, _)| name.starts_with(label))
5071 .unwrap_or_else(|| panic!("{label} is on the toolbar: {worth:?}"))
5072 .1
5073 };
5074
5075 assert_eq!(of("Sidebar"), Priority::Essential);
5076 assert_eq!(of("Detail"), Priority::Essential);
5077 assert_eq!(of("Filters"), Priority::Secondary);
5078 for inspector in ["Edit", "Instrument", "Loop"] {
5079 assert_eq!(of(inspector), Priority::Optional, "{inspector}");
5080 }
5081
5082 // Help drops first of the three addresses because `f1` still reaches it.
5083 assert_eq!(of("Settings"), Priority::Secondary);
5084 assert_eq!(of("Cloud Sync"), Priority::Secondary);
5085 assert_eq!(of("Help"), Priority::Optional);
5086
5087 // And nothing was hidden behind a width. The description names no pixels.
5088 assert!(!format!("{worth:?}").contains("900"));
5089 }
5090
5091 #[test]
5092 fn the_footer_drops_only_what_the_detail_panel_says_twice() {
5093 // The footer's own `< 1000` reflows rather than drops, so ranking its
5094 // items would delete facts the shipped app keeps. The tag badges are the
5095 // exception: they repeat what the detail panel states in full.
5096 use quasi_router::layout::Priority;
5097
5098 let shell = FakeShell {
5099 tags: vec!["drums".to_owned(), "loop".to_owned()],
5100 ..Default::default()
5101 };
5102 let worth = worths(&shown(&shell), "shell-foot");
5103
5104 let optional: Vec<&String> = worth
5105 .iter()
5106 .filter(|(_, priority)| *priority != Priority::Essential)
5107 .map(|(name, _)| name)
5108 .collect();
5109 assert_eq!(optional, ["drums", "loop"], "{worth:?}");
5110 }
5111
5112 // --- The name modals, the preflight and the loose-files warning --------------
5113
5114 /// A tree with nothing to name, for every test that is not about naming.
5115 struct Unnamed;
5116
5117 impl Naming for Unnamed {
5118 fn done(&self) {}
5119
5120 fn vault(&self, _id: i64) -> Option<String> {
5121 None
5122 }
5123
5124 fn folder(&self, _id: i64) -> Option<String> {
5125 None
5126 }
5127
5128 fn create_vault(&self, _name: &str) -> Result<String, String> {
5129 Ok(String::new())
5130 }
5131
5132 fn rename_vault(&self, _id: i64, _name: &str) -> Result<String, String> {
5133 Ok(String::new())
5134 }
5135
5136 fn create_folder(&self, _name: &str) -> Result<String, String> {
5137 Ok(String::new())
5138 }
5139
5140 fn rename_folder(&self, _id: i64, _name: &str) -> Result<String, String> {
5141 Ok(String::new())
5142 }
5143 }
5144
5145 /// Nothing is being imported, and nothing can be.
5146 ///
5147 /// [`Idle`]'s peer for the other flow, and the same argument: every method is a
5148 /// refusal, so a test of some other screen cannot start an import by accident.
5149 /// A fake that recorded the call would let one.
5150 struct NoImport;
5151
5152 impl Importing for NoImport {
5153 fn waiting(&self) -> Option<Preflight> {
5154 None
5155 }
5156 fn stage(&self) -> Stage {
5157 Stage::Idle
5158 }
5159 fn sweeping(&self) -> Option<Sweep> {
5160 None
5161 }
5162
5163 fn accept(&self, _again: bool) {}
5164 fn cancel(&self) {}
5165 fn open_folder(&self) {}
5166 fn open_quickly(&self) {}
5167 fn open_files(&self) {}
5168 fn change_source(&self) {}
5169 fn decide(&self, _decision: Decision, _value: &str) {}
5170 fn begin(&self) {}
5171 fn stop(&self) {}
5172 fn retry(&self) {}
5173 fn dismiss(&self) {}
5174 fn tag_folder(&self, _at: usize, _typed: &str) {}
5175 fn tag_every_folder(&self, _typed: &str) {}
5176 fn apply_folder_tags(&self) {}
5177 fn skip_folder_tags(&self) {}
5178 fn measure(&self, _measure: Measure, _wanted: bool) {}
5179 fn analyse(&self) {}
5180 fn back_to_tagging(&self) {}
5181 fn skip_analysis(&self) {}
5182 fn stop_analysis(&self) {}
5183 fn retry_analysis(&self) {}
5184 fn order(&self, _order: Order) {}
5185 fn read(&self, _at: usize) {}
5186 fn judge(&self, _at: usize, _tag: &str, _accepted: bool) {}
5187 fn judge_all(&self, _accepted: bool) {}
5188 fn apply_suggestions(&self) {}
5189 fn discard_suggestions(&self) {}
5190 fn keep_failed(&self) {}
5191 fn purge_failed(&self, _at: Option<usize>) {}
5192 fn stop_sweep(&self) {}
5193 }
5194
5195 /// Every file is where it should be.
5196 struct Sound;
5197
5198 impl Integrity for Sound {
5199 fn missing(&self) -> usize {
5200 0
5201 }
5202
5203 fn dismiss(&self) {}
5204 fn locate(&self) {}
5205 fn purge(&self) {}
5206 }
5207
5208 /// A namer in memory, recording what was asked of it and refusing on demand.
5209 #[derive(Default)]
5210 struct FakeNaming {
5211 vaults: Vec<(i64, String)>,
5212 folders: Vec<(i64, String)>,
5213 refusing: Option<String>,
5214 asked: RefCell<Vec<String>>,
5215 /// Whether the modal was told it is finished with.
5216 ///
5217 /// Its own field rather than a row in `asked`, because it is not a naming
5218 /// operation: `asked` answers "what did this screen do to the tree", and
5219 /// telling the host to put a window away does nothing to the tree.
5220 finished: std::cell::Cell<bool>,
5221 }
5222
5223 impl FakeNaming {
5224 fn stocked() -> Self {
5225 Self {
5226 vaults: vec![(1, "Drums".to_owned())],
5227 folders: vec![(7, "kicks".to_owned())],
5228 ..Self::default()
5229 }
5230 }
5231
5232 fn refusing(why: &str) -> Self {
5233 Self {
5234 refusing: Some(why.to_owned()),
5235 ..Self::stocked()
5236 }
5237 }
5238
5239 fn asked(&self) -> Vec<String> {
5240 self.asked.borrow().clone()
5241 }
5242
5243 fn finished(&self) -> bool {
5244 self.finished.get()
5245 }
5246
5247 fn did(&self, what: String) -> Result<String, String> {
5248 self.asked.borrow_mut().push(what.clone());
5249 match &self.refusing {
5250 Some(why) => Err(why.clone()),
5251 None => Ok(what),
5252 }
5253 }
5254 }
5255
5256 impl Naming for FakeNaming {
5257 fn done(&self) {
5258 self.finished.set(true);
5259 }
5260
5261 fn vault(&self, id: i64) -> Option<String> {
5262 self.vaults
5263 .iter()
5264 .find(|(at, _)| *at == id)
5265 .map(|(_, name)| name.clone())
5266 }
5267
5268 fn folder(&self, id: i64) -> Option<String> {
5269 self.folders
5270 .iter()
5271 .find(|(at, _)| *at == id)
5272 .map(|(_, name)| name.clone())
5273 }
5274
5275 fn create_vault(&self, name: &str) -> Result<String, String> {
5276 self.did(format!("create vault {name}"))
5277 }
5278
5279 fn rename_vault(&self, id: i64, name: &str) -> Result<String, String> {
5280 self.did(format!("rename vault {id} to {name}"))
5281 }
5282
5283 fn create_folder(&self, name: &str) -> Result<String, String> {
5284 self.did(format!("create folder {name}"))
5285 }
5286
5287 fn rename_folder(&self, id: i64, name: &str) -> Result<String, String> {
5288 self.did(format!("rename folder {id} to {name}"))
5289 }
5290 }
5291
5292 /// A post carrying what a form submitted.
5293 ///
5294 /// The captures are the router's to fill from the path; what a test supplies is
5295 /// the payload, which is the half a form sends.
5296 fn posting(path: &str, payload: Params) -> Request {
5297 Request {
5298 method: Method::Post,
5299 path: path.to_owned(),
5300 captures: Params::new(),
5301 payload,
5302 carried: Params::new(),
5303 }
5304 }
5305
5306 /// A router call against this namer.
5307 fn naming(naming: &FakeNaming, request: Request) -> Result<Response, quasi_router::RouteError> {
5308 let store = Store::default();
5309 let sync = Offline;
5310 let files = FakeFiles::default();
5311 let themes = themes();
5312 let state = Panels {
5313 config: &store,
5314 sync: &sync,
5315 files: &files,
5316 export: &Idle,
5317 detail: &Unfocused,
5318 bulk: &Unchosen,
5319 shell: &Quiet,
5320 library: &Empty,
5321 bar: &Still,
5322 naming,
5323 importing: &NoImport,
5324 integrity: &Sound,
5325 editor: &Unedited,
5326 forge: &Unforged,
5327 queue: &Unqueued,
5328 filters: &Unfiltered,
5329 themes: &themes,
5330 };
5331 router().handle(&state, request)
5332 }
5333
5334 /// The one field on whatever modal answered.
5335 fn only_field(response: &Response) -> quasi_router::Field {
5336 let screen = screen_of(response);
5337 let mut found = Vec::new();
5338 fn walk(body: &[quasi_router::Ranked], found: &mut Vec<quasi_router::Field>) {
5339 for placed in body {
5340 match &placed.node {
5341 Node::Field(field) => found.push((**field).clone()),
5342 Node::Form { fields, .. } => found.extend(fields.iter().cloned()),
5343 Node::Region(slot) => walk(&slot.body, found),
5344 _ => {}
5345 }
5346 }
5347 }
5348 for slot in &screen.slots {
5349 walk(&slot.body, &mut found);
5350 }
5351 assert_eq!(found.len(), 1, "{found:?}");
5352 found.remove(0)
5353 }
5354
5355 #[test]
5356 fn a_rename_modal_opens_holding_the_name_it_is_about_to_change() {
5357 let namer = FakeNaming::stocked();
5358
5359 let vault = naming(&namer, Request::get("/vaults/1/rename")).unwrap();
5360 assert!(matches!(vault.outcome, Outcome::Over(_)), "{vault:?}");
5361 assert_eq!(only_field(&vault).value.as_deref(), Some("Drums"));
5362
5363 let folder = naming(&namer, Request::get("/folders/7/rename")).unwrap();
5364 assert_eq!(only_field(&folder).value.as_deref(), Some("kicks"));
5365
5366 // Nothing was renamed by looking at it.
5367 assert!(namer.asked().is_empty());
5368 }
5369
5370 #[test]
5371 fn a_modal_is_drawn_over_what_it_was_opened_from() {
5372 // All four, because `Outcome::Over` is what makes them modals rather than
5373 // places, and a screen answered here would clear the layer underneath.
5374 let namer = FakeNaming::stocked();
5375 for address in [
5376 "/vaults/new",
5377 "/vaults/1/rename",
5378 "/folders/new",
5379 "/folders/7/rename",
5380 ] {
5381 let response = naming(&namer, Request::get(address)).unwrap();
5382 assert!(
5383 matches!(response.outcome, Outcome::Over(_)),
5384 "{address}: {response:?}"
5385 );
5386 }
5387 }
5388
5389 #[test]
5390 fn naming_something_that_is_not_there_is_a_refusal_rather_than_an_empty_modal() {
5391 let namer = FakeNaming::stocked();
5392 assert!(naming(&namer, Request::get("/vaults/99/rename")).is_err());
5393 assert!(naming(&namer, Request::get("/folders/99/rename")).is_err());
5394 // An address is reachable by typing, so the id is checked rather than
5395 // trusted.
5396 assert!(naming(&namer, Request::get("/vaults/not-a-number/rename")).is_err());
5397 }
5398
5399 #[test]
5400 fn a_name_the_store_refuses_comes_back_on_the_field_it_was_typed_into() {
5401 // The whole reason this port's writes happen in the route: an intent
5402 // applied after the answer was built could not carry the refusal, so the
5403 // modal would close and the typed name would be gone. C-3, kept.
5404 let namer = FakeNaming::refusing("A vault called that already exists");
5405 let response = naming(
5406 &namer,
5407 posting("/vaults/new", Params::new().with("name", "Drums")),
5408 )
5409 .unwrap();
5410
5411 let Outcome::Fragment { region, node } = &response.outcome else {
5412 panic!("{response:?}");
5413 };
5414 // A fragment rather than a second `Over`, which would be two modals. See
5415 // `bulk`'s finding 2.
5416 assert_eq!(region, "naming-form");
5417
5418 let Node::Region(slot) = node else {
5419 panic!("{node:?}");
5420 };
5421 let Some(Node::Form { fields, .. }) = slot.body.first().map(|placed| &placed.node) else {
5422 panic!("{slot:?}");
5423 };
5424 assert_eq!(
5425 fields[0].error.as_deref(),
5426 Some("A vault called that already exists")
5427 );
5428 // And it still holds what was typed.
5429 assert_eq!(fields[0].value.as_deref(), Some("Drums"));
5430 }
5431
5432 #[test]
5433 fn an_empty_submit_closes_the_modal_and_names_nothing() {
5434 // The shipped rule, and the reason none of these fields is `required`: the
5435 // marker would claim a refusal that never happens.
5436 let namer = FakeNaming::stocked();
5437 let response = naming(
5438 &namer,
5439 posting("/folders/new", Params::new().with("name", " ")),
5440 )
5441 .unwrap();
5442
5443 assert!(matches!(response.outcome, Outcome::Goto(_)), "{response:?}");
5444 assert!(namer.asked().is_empty());
5445 // Leaving the address is not leaving the screen: the host's own flag is
5446 // what keeps a name modal up, so every exit says so. See `naming`'s `DONE`.
5447 assert!(namer.finished());
5448 }
5449
5450 #[test]
5451 fn a_named_thing_is_created_once_and_the_modal_leaves() {
5452 let namer = FakeNaming::stocked();
5453 let response = naming(
5454 &namer,
5455 posting("/vaults/new", Params::new().with("name", " Synths ")),
5456 )
5457 .unwrap();
5458
5459 // Trimmed, which is what the shipped modal submits.
5460 assert_eq!(namer.asked(), ["create vault Synths"]);
5461 assert!(matches!(response.outcome, Outcome::Goto(_)), "{response:?}");
5462 assert!(response.notice.is_some());
5463 }
5464
5465 /// A router call against this waiting import.
5466 fn importing(
5467 importing: &dyn Importing,
5468 request: Request,
5469 ) -> Result<Response, quasi_router::RouteError> {
5470 let store = Store::default();
5471 let sync = Offline;
5472 let files = FakeFiles::default();
5473 let themes = themes();
5474 let state = Panels {
5475 config: &store,
5476 sync: &sync,
5477 files: &files,
5478 export: &Idle,
5479 detail: &Unfocused,
5480 bulk: &Unchosen,
5481 shell: &Quiet,
5482 library: &Empty,
5483 bar: &Still,
5484 naming: &Unnamed,
5485 importing,
5486 integrity: &Sound,
5487 editor: &Unedited,
5488 forge: &Unforged,
5489 queue: &Unqueued,
5490 filters: &Unfiltered,
5491 themes: &themes,
5492 };
5493 router().handle(&state, request)
5494 }
5495
5496 /// Importing in memory, recording what was asked of it.
5497 ///
5498 /// One fixture for the preflight and the flow, because they are one capability:
5499 /// a test names the stage it is about and leaves the other half at rest. The
5500 /// stage is fixed per test rather than advancing, which is [`FakeExport`]'s
5501 /// honest shape for the same reason — what moves a stage is the app applying an
5502 /// intent, and these are tests of the description.
5503 struct FakeImport {
5504 waiting: Option<Preflight>,
5505 stage: Stage,
5506 sweep: Option<Sweep>,
5507 answered: RefCell<Vec<String>>,
5508 }
5509
5510 impl Default for FakeImport {
5511 fn default() -> Self {
5512 Self {
5513 waiting: None,
5514 stage: Stage::Idle,
5515 sweep: None,
5516 answered: RefCell::new(Vec::new()),
5517 }
5518 }
5519 }
5520
5521 impl FakeImport {
5522 fn waiting() -> Self {
5523 Self {
5524 waiting: Some(Preflight {
5525 source: "/home/max/Downloads/packs".to_owned(),
5526 files: 412,
5527 size: "3.1 GB".to_owned(),
5528 }),
5529 ..Self::default()
5530 }
5531 }
5532
5533 fn at(stage: Stage) -> Self {
5534 Self {
5535 stage,
5536 ..Self::default()
5537 }
5538 }
5539
5540 fn sweeping(sweep: Sweep) -> Self {
5541 Self {
5542 sweep: Some(sweep),
5543 ..Self::default()
5544 }
5545 }
5546
5547 fn answered(&self) -> Vec<String> {
5548 self.answered.borrow().clone()
5549 }
5550
5551 fn say(&self, said: impl Into<String>) {
5552 self.answered.borrow_mut().push(said.into());
5553 }
5554 }
5555
5556 impl Importing for FakeImport {
5557 fn waiting(&self) -> Option<Preflight> {
5558 self.waiting.clone()
5559 }
5560
5561 fn stage(&self) -> Stage {
5562 self.stage.clone()
5563 }
5564
5565 fn sweeping(&self) -> Option<Sweep> {
5566 self.sweep.clone()
5567 }
5568
5569 fn accept(&self, again: bool) {
5570 self.say(format!("accept, ask again: {again}"));
5571 }
5572
5573 fn cancel(&self) {
5574 self.say("cancel");
5575 }
5576
5577 fn open_folder(&self) {
5578 self.say("open:folder");
5579 }
5580
5581 fn open_quickly(&self) {
5582 self.say("open:quick");
5583 }
5584
5585 fn open_files(&self) {
5586 self.say("open:files");
5587 }
5588
5589 fn change_source(&self) {
5590 self.say("open:source");
5591 }
5592
5593 fn decide(&self, decision: Decision, value: &str) {
5594 self.say(format!("set:{}={value}", decision.as_str()));
5595 }
5596
5597 fn begin(&self) {
5598 self.say("begin");
5599 }
5600
5601 fn stop(&self) {
5602 self.say("stop");
5603 }
5604
5605 fn retry(&self) {
5606 self.say("retry");
5607 }
5608
5609 fn dismiss(&self) {
5610 self.say("dismiss");
5611 }
5612
5613 fn tag_folder(&self, at: usize, typed: &str) {
5614 self.say(format!("tag:{at}={typed}"));
5615 }
5616
5617 fn tag_every_folder(&self, typed: &str) {
5618 self.say(format!("tag:all={typed}"));
5619 }
5620
5621 fn apply_folder_tags(&self) {
5622 self.say("tags:apply");
5623 }
5624
5625 fn skip_folder_tags(&self) {
5626 self.say("tags:skip");
5627 }
5628
5629 fn measure(&self, measure: Measure, wanted: bool) {
5630 self.say(format!("measure:{}={wanted}", measure.as_str()));
5631 }
5632
5633 fn analyse(&self) {
5634 self.say("analyse");
5635 }
5636
5637 fn back_to_tagging(&self) {
5638 self.say("analyse:back");
5639 }
5640
5641 fn skip_analysis(&self) {
5642 self.say("analyse:skip");
5643 }
5644
5645 fn stop_analysis(&self) {
5646 self.say("analyse:stop");
5647 }
5648
5649 fn retry_analysis(&self) {
5650 self.say("analyse:retry");
5651 }
5652
5653 fn order(&self, order: Order) {
5654 self.say(format!("order:{}", order.as_str()));
5655 }
5656
5657 fn read(&self, at: usize) {
5658 self.say(format!("read:{at}"));
5659 }
5660
5661 fn judge(&self, at: usize, tag: &str, accepted: bool) {
5662 self.say(format!("judge:{at}:{tag}={accepted}"));
5663 }
5664
5665 fn judge_all(&self, accepted: bool) {
5666 self.say(format!("judge:all={accepted}"));
5667 }
5668
5669 fn apply_suggestions(&self) {
5670 self.say("review:apply");
5671 }
5672
5673 fn discard_suggestions(&self) {
5674 self.say("review:discard");
5675 }
5676
5677 fn keep_failed(&self) {
5678 self.say("failed:keep");
5679 }
5680
5681 fn purge_failed(&self, at: Option<usize>) {
5682 self.say(match at {
5683 Some(at) => format!("failed:purge:{at}"),
5684 None => "failed:purge:all".to_owned(),
5685 });
5686 }
5687
5688 fn stop_sweep(&self) {
5689 self.say("sweep:stop");
5690 }
5691 }
5692
5693 #[test]
5694 fn the_preflight_says_what_is_about_to_happen_and_where_it_is_coming_from() {
5695 let import = FakeImport::waiting();
5696 let response = importing(&import, Request::get("/import/preflight")).unwrap();
5697
5698 assert!(matches!(response.outcome, Outcome::Over(_)), "{response:?}");
5699 let said = said(screen_of(&response));
5700 assert!(said.contains("412 audio files"), "{said}");
5701 assert!(said.contains("3.1 GB"), "{said}");
5702 assert!(said.contains("/home/max/Downloads/packs"), "{said}");
5703 // The reassurance is a fact about the operation, so it is on the screen
5704 // rather than on either answer.
5705 assert!(said.contains("Files stay where they are"), "{said}");
5706 }
5707
5708 #[test]
5709 fn dont_ask_again_travels_with_the_answer_it_qualifies() {
5710 // The shipped modal keeps this on `BrowserState` and resets it on both
5711 // exits. Here it is submitted with the answer and there is nothing to
5712 // reset.
5713 let import = FakeImport::waiting();
5714 importing(
5715 &import,
5716 posting("/import/preflight", Params::new().with("again", "on")),
5717 )
5718 .unwrap();
5719 assert_eq!(import.answered(), ["accept, ask again: false"]);
5720
5721 let plain = FakeImport::waiting();
5722 importing(&plain, Request::post("/import/preflight")).unwrap();
5723 assert_eq!(plain.answered(), ["accept, ask again: true"]);
5724 }
5725
5726 #[test]
5727 fn there_is_no_preflight_screen_when_no_import_is_waiting() {
5728 // Rather than an empty modal. The address is reachable by typing and there
5729 // is no honest screen for it.
5730 assert!(importing(&NoImport, Request::get("/import/preflight")).is_err());
5731 assert!(importing(&NoImport, Request::post("/import/preflight")).is_err());
5732 }
5733
5734 /// A vault with missing files, recording what was asked of it.
5735 struct FakeIntegrity {
5736 missing: usize,
5737 asked: RefCell<Vec<String>>,
5738 }
5739
5740 impl FakeIntegrity {
5741 fn missing(count: usize) -> Self {
5742 Self {
5743 missing: count,
5744 asked: RefCell::new(Vec::new()),
5745 }
5746 }
5747
5748 fn asked(&self) -> Vec<String> {
5749 self.asked.borrow().clone()
5750 }
5751 }
5752
5753 impl Integrity for FakeIntegrity {
5754 fn missing(&self) -> usize {
5755 self.missing
5756 }
5757
5758 fn dismiss(&self) {
5759 self.asked.borrow_mut().push("dismiss".to_owned());
5760 }
5761
5762 fn locate(&self) {
5763 self.asked.borrow_mut().push("locate".to_owned());
5764 }
5765
5766 fn purge(&self) {
5767 self.asked.borrow_mut().push("purge".to_owned());
5768 }
5769 }
5770
5771 /// A router call against this vault's health.
5772 fn checking(
5773 integrity: &dyn Integrity,
5774 request: Request,
5775 ) -> Result<Response, quasi_router::RouteError> {
5776 let store = Store::default();
5777 let sync = Offline;
5778 let files = FakeFiles::default();
5779 let themes = themes();
5780 let state = Panels {
5781 config: &store,
5782 sync: &sync,
5783 files: &files,
5784 export: &Idle,
5785 detail: &Unfocused,
5786 bulk: &Unchosen,
5787 shell: &Quiet,
5788 library: &Empty,
5789 bar: &Still,
5790 naming: &Unnamed,
5791 importing: &NoImport,
5792 integrity,
5793 editor: &Unedited,
5794 forge: &Unforged,
5795 queue: &Unqueued,
5796 filters: &Unfiltered,
5797 themes: &themes,
5798 };
5799 router().handle(&state, request)
5800 }
5801
5802 #[test]
5803 fn purge_carries_what_it_takes_on_the_control_that_does_it() {
5804 // The shipped modal draws the blast radius as a warning line near the
5805 // button. `Act::confirm` puts it on the button, which is the third
5806 // `ConfirmAction`-shaped thing this port has replaced with a method.
5807 let vault = FakeIntegrity::missing(3);
5808 let response = checking(&vault, Request::get("/library/loose-files")).unwrap();
5809 let screen = screen_of(&response);
5810
5811 let purge = nodes(screen)
5812 .iter()
5813 .find_map(|node| match node {
5814 Node::Act(act) if act.label == "Purge" => Some((*act).clone()),
5815 _ => None,
5816 })
5817 .expect("a Purge act");
5818
5819 let question = purge.confirm.expect("Purge asks first");
5820 assert!(
5821 question.contains("Tags, analysis results, and history"),
5822 "{question}"
5823 );
5824 assert!(question.contains("permanently deleted"), "{question}");
5825 }
5826
5827 #[test]
5828 fn each_of_the_three_answers_does_one_thing_and_leaves() {
5829 for (address, expected) in [
5830 ("/library/loose-files/dismiss", "dismiss"),
5831 ("/library/loose-files/locate", "locate"),
5832 ("/library/loose-files/purge", "purge"),
5833 ] {
5834 let vault = FakeIntegrity::missing(3);
5835 let response = checking(&vault, Request::post(address)).unwrap();
5836 assert_eq!(vault.asked(), [expected], "{address}");
5837 assert!(
5838 matches!(response.outcome, Outcome::Goto(_)),
5839 "{address}: {response:?}"
5840 );
5841 }
5842 }
5843
5844 #[test]
5845 fn a_healthy_vault_has_no_warning_to_open() {
5846 assert!(checking(&Sound, Request::get("/library/loose-files")).is_err());
5847 // And purging nothing is refused rather than performed on an empty set.
5848 assert!(checking(&Sound, Request::post("/library/loose-files/purge")).is_err());
5849 }
5850
5851 #[test]
5852 fn the_band_says_what_is_missing_because_no_description_can_raise_the_overlay() {
5853 // The finding this pass filed, seen from the consumer's side: the shipped
5854 // app puts the warning up by itself after a vault load, and nothing a route
5855 // answers can do that. So the fact is in the band and the modal is one act
5856 // away. `quasi:vocabulary:unprompted-overlay`.
5857 let store = Store::default();
5858 let sync = Offline;
5859 let files = FakeFiles::default();
5860 let themes = themes();
5861 let vault = FakeIntegrity::missing(4);
5862 let shell = FakeShell::default();
5863 let state = Panels {
5864 config: &store,
5865 sync: &sync,
5866 files: &files,
5867 export: &Idle,
5868 detail: &Unfocused,
5869 bulk: &Unchosen,
5870 shell: &shell,
5871 library: &Empty,
5872 bar: &Still,
5873 naming: &Unnamed,
5874 importing: &NoImport,
5875 integrity: &vault,
5876 editor: &Unedited,
5877 forge: &Unforged,
5878 queue: &Unqueued,
5879 filters: &Unfiltered,
5880 themes: &themes,
5881 };
5882 let response = router().handle(&state, Request::get("/")).unwrap();
5883 let screen = screen_of(&response);
5884
5885 let said = said(screen);
5886 assert!(said.contains("4 samples cannot find their file"), "{said}");
5887 assert!(
5888 acts(screen).contains(&"What is missing".to_owned()),
5889 "{:?}",
5890 acts(screen)
5891 );
5892 }
5893
5894 // --- The sample editor -------------------------------------------------------
5895
5896 /// Nothing is being edited, for every test that is not about editing.
5897 struct Unedited;
5898
5899 impl super::Edit for Unedited {
5900 fn subject(&self) -> Option<Editing> {
5901 None
5902 }
5903
5904 fn trim(&self, _start: f32, _end: f32) {}
5905 fn gain(&self, _db: f64) {}
5906 fn normalize(&self, _peak: bool, _target: f64) {}
5907 fn reverse(&self) {}
5908 fn fade(&self, _fading_in: bool, _ms: f64, _curve: &str) {}
5909 fn insert_silence(&self, _at: f64, _ms: f64) {}
5910 fn remove_range(&self, _from: f64, _to: f64) {}
5911 fn cancel(&self) {}
5912 fn play(&self) {}
5913 fn stop(&self) {}
5914 fn remember(&self, _mode: &str) {}
5915 fn choose(&self, _mode: &str, _remember: bool) {}
5916 fn discard(&self) {}
5917 fn undo(&self) {}
5918 fn batch_normalize(&self, _peak: bool, _target: f64) {}
5919 fn batch_gain(&self, _db: f64) {}
5920 fn batch_reverse(&self) {}
5921 }
5922
5923 /// An editor in memory, recording what was asked of it.
5924 struct FakeEditor {
5925 subject: Option<Editing>,
5926 asked: RefCell<Vec<String>>,
5927 }
5928
5929 impl FakeEditor {
5930 fn editing() -> Self {
5931 Self {
5932 subject: Some(Editing {
5933 name: "kick.wav".to_owned(),
5934 sample_rate: 44_100,
5935 duration: Some(1.5),
5936 peak_db: Some(-2.0),
5937 playing: false,
5938 working: false,
5939 asking: false,
5940 result: None,
5941 chosen: 1,
5942 undoing: None,
5943 }),
5944 asked: RefCell::new(Vec::new()),
5945 }
5946 }
5947
5948 fn with(mut self, change: impl FnOnce(&mut Editing)) -> Self {
5949 if let Some(subject) = self.subject.as_mut() {
5950 change(subject);
5951 }
5952 self
5953 }
5954
5955 fn asked(&self) -> Vec<String> {
5956 self.asked.borrow().clone()
5957 }
5958
5959 fn note(&self, what: String) {
5960 self.asked.borrow_mut().push(what);
5961 }
5962 }
5963
5964 impl super::Edit for FakeEditor {
5965 fn subject(&self) -> Option<Editing> {
5966 self.subject.clone()
5967 }
5968
5969 fn trim(&self, start: f32, end: f32) {
5970 self.note(format!("trim {start} {end}"));
5971 }
5972
5973 fn gain(&self, db: f64) {
5974 self.note(format!("gain {db}"));
5975 }
5976
5977 fn normalize(&self, peak: bool, target: f64) {
5978 self.note(format!("normalize peak={peak} {target}"));
5979 }
5980
5981 fn reverse(&self) {
5982 self.note("reverse".to_owned());
5983 }
5984
5985 fn fade(&self, fading_in: bool, ms: f64, curve: &str) {
5986 self.note(format!("fade in={fading_in} {ms} {curve}"));
5987 }
5988
5989 fn insert_silence(&self, at: f64, ms: f64) {
5990 self.note(format!("insert {at} {ms}"));
5991 }
5992
5993 fn remove_range(&self, from: f64, to: f64) {
5994 self.note(format!("remove {from} {to}"));
5995 }
5996
5997 fn cancel(&self) {
5998 self.note("cancel".to_owned());
5999 }
6000
6001 fn play(&self) {
6002 self.note("play".to_owned());
6003 }
6004
6005 fn stop(&self) {
6006 self.note("stop".to_owned());
6007 }
6008
6009 fn remember(&self, mode: &str) {
6010 self.note(format!("remember {mode}"));
6011 }
6012
6013 fn choose(&self, mode: &str, remember: bool) {
6014 self.note(format!("choose {mode} remember={remember}"));
6015 }
6016
6017 fn discard(&self) {
6018 self.note("discard".to_owned());
6019 }
6020
6021 fn undo(&self) {
6022 self.note("undo".to_owned());
6023 }
6024
6025 fn batch_normalize(&self, peak: bool, target: f64) {
6026 self.note(format!("batch normalize peak={peak} {target}"));
6027 }
6028
6029 fn batch_gain(&self, db: f64) {
6030 self.note(format!("batch gain {db}"));
6031 }
6032
6033 fn batch_reverse(&self) {
6034 self.note("batch reverse".to_owned());
6035 }
6036 }
6037
6038 /// A router call against this editor.
6039 fn editing(editor: &FakeEditor, request: Request) -> Result<Response, quasi_router::RouteError> {
6040 let store = Store::default();
6041 let sync = Offline;
6042 let files = FakeFiles::default();
6043 let themes = themes();
6044 let state = Panels {
6045 config: &store,
6046 sync: &sync,
6047 files: &files,
6048 export: &Idle,
6049 detail: &Unfocused,
6050 bulk: &Unchosen,
6051 shell: &Quiet,
6052 library: &Empty,
6053 bar: &Still,
6054 naming: &Unnamed,
6055 importing: &NoImport,
6056 integrity: &Sound,
6057 editor,
6058 forge: &Unforged,
6059 queue: &Unqueued,
6060 filters: &Unfiltered,
6061 themes: &themes,
6062 };
6063 router().handle(&state, request)
6064 }
6065
6066 /// The editor screen.
6067 fn edited(editor: &FakeEditor) -> Screen {
6068 screen_of(&editing(editor, Request::get("/edit")).unwrap()).clone()
6069 }
6070
6071 #[test]
6072 fn the_editor_refuses_to_exist_with_nothing_to_edit() {
6073 // The shipped window is only open because something is being edited, so a
6074 // screen for "no sample" would be a screen the app does not have.
6075 assert!(
6076 editing(
6077 &FakeEditor {
6078 subject: None,
6079 asked: RefCell::new(Vec::new())
6080 },
6081 Request::get("/edit")
6082 )
6083 .is_err()
6084 );
6085 }
6086
6087 #[test]
6088 fn a_finished_edit_asks_at_the_same_address_it_was_started_from() {
6089 // One route, two shapes: the prompt is a state the user arrived at, not a
6090 // place they went. `sync`, `export` and `detail` settled this.
6091 let quiet = FakeEditor::editing();
6092 assert!(said(&edited(&quiet)).contains("kick.wav"));
6093
6094 let asking = FakeEditor::editing().with(|subject| subject.asking = true);
6095 let screen = edited(&asking);
6096 let said = said(&screen);
6097 assert!(
6098 said.contains("How should the edited sample be handled?"),
6099 "{said}"
6100 );
6101 // And the editor's own controls are gone, which is what the shipped panel's
6102 // early return does.
6103 assert!(
6104 !acts(&screen).iter().any(|act| act == "Reverse"),
6105 "{:?}",
6106 acts(&screen)
6107 );
6108 }
6109
6110 #[test]
6111 fn trim_refuses_a_span_that_ends_before_it_starts() {
6112 // The pair the description cannot state. The shipped panel keeps it true by
6113 // writing one of the two every frame; an address reachable by typing needs
6114 // the refusal as well. `91114ff1`, second consumer.
6115 let editor = FakeEditor::editing();
6116 assert!(
6117 editing(
6118 &editor,
6119 posting(
6120 "/edit/trim",
6121 Params::new().with("start", "0.8").with("end", "0.2")
6122 ),
6123 )
6124 .is_err()
6125 );
6126 assert!(editor.asked().is_empty());
6127
6128 editing(
6129 &editor,
6130 posting(
6131 "/edit/trim",
6132 Params::new().with("start", "0.1").with("end", "0.9"),
6133 ),
6134 )
6135 .unwrap();
6136 assert_eq!(editor.asked(), ["trim 0.1 0.9"]);
6137 }
6138
6139 #[test]
6140 fn a_position_outside_the_sample_is_refused() {
6141 let editor = FakeEditor::editing();
6142 for (start, end) in [("-0.5", "0.9"), ("0.1", "1.5"), ("nope", "0.9")] {
6143 assert!(
6144 editing(
6145 &editor,
6146 posting(
6147 "/edit/trim",
6148 Params::new().with("start", start).with("end", end)
6149 ),
6150 )
6151 .is_err(),
6152 "{start} {end}"
6153 );
6154 }
6155 assert!(editor.asked().is_empty());
6156 }
6157
6158 #[test]
6159 fn the_clipping_warning_moves_with_the_gain_and_leaves_the_screen_standing() {
6160 // `5672cad4`, third consumer: a valid answer that costs something has no
6161 // slot on the field, so it is a fragment beside it.
6162 let editor = FakeEditor::editing();
6163 let quiet = editing(
6164 &editor,
6165 posting("/edit/gain/preview", Params::new().with("gain", "1.0")),
6166 )
6167 .unwrap();
6168
6169 let Outcome::Fragment { region, node } = &quiet.outcome else {
6170 panic!("{quiet:?}");
6171 };
6172 assert_eq!(region, "edit-clipping");
6173 // -2.0 + 1.0 is still under the ceiling, so it is a fact rather than a
6174 // warning.
6175 assert!(matches!(node, Node::Text { .. }), "{node:?}");
6176
6177 let loud = editing(
6178 &editor,
6179 posting("/edit/gain/preview", Params::new().with("gain", "6.0")),
6180 )
6181 .unwrap();
6182 let Outcome::Fragment { node, .. } = &loud.outcome else {
6183 panic!("{loud:?}");
6184 };
6185 let Node::Notice { tone, text, .. } = node else {
6186 panic!("{node:?}");
6187 };
6188 assert_eq!(*tone, quasi_router::layout::Tone::Danger);
6189 assert!(text.contains("clips!"), "{text}");
6190
6191 // A control mid-drag can send half a number, and that is not an error the
6192 // user should see.
6193 assert!(
6194 editing(
6195 &editor,
6196 posting("/edit/gain/preview", Params::new().with("gain", "-")),
6197 )
6198 .is_ok()
6199 );
6200 // None of it applied anything.
6201 assert!(editor.asked().is_empty());
6202 }
6203
6204 #[test]
6205 fn a_normalize_target_is_checked_against_the_mode_it_was_chosen_for() {
6206 // Peak runs to 0 dBFS and loudness stops at -6 LUFS. The description
6207 // carries the wider of the two ranges, so the route holds the narrower.
6208 let editor = FakeEditor::editing();
6209
6210 editing(
6211 &editor,
6212 posting(
6213 "/edit/normalize",
6214 Params::new().with("mode", "peak").with("target", "-1"),
6215 ),
6216 )
6217 .unwrap();
6218 assert_eq!(editor.asked(), ["normalize peak=true -1"]);
6219
6220 assert!(
6221 editing(
6222 &editor,
6223 posting(
6224 "/edit/normalize",
6225 Params::new().with("mode", "lufs").with("target", "-1"),
6226 ),
6227 )
6228 .is_err()
6229 );
6230 }
6231
6232 #[test]
6233 fn a_fade_curve_the_app_cannot_read_back_is_refused() {
6234 // The pairing `FadeCurve::as_value`/`from_value` exists so the value a
6235 // control submits is the variant the audio pipeline matches on.
6236 let editor = FakeEditor::editing();
6237 assert!(
6238 editing(
6239 &editor,
6240 posting(
6241 "/edit/fade",
6242 Params::new()
6243 .with("in", "out")
6244 .with("length", "250")
6245 .with("curve", "exponential"),
6246 ),
6247 )
6248 .is_err()
6249 );
6250
6251 editing(
6252 &editor,
6253 posting(
6254 "/edit/fade",
6255 Params::new()
6256 .with("in", "out")
6257 .with("length", "250")
6258 .with("curve", "s-curve"),
6259 ),
6260 )
6261 .unwrap();
6262 assert_eq!(editor.asked(), ["fade in=false 250 s-curve"]);
6263 }
6264
6265 #[test]
6266 fn the_batch_section_appears_with_a_second_sample_and_carries_its_own_values() {
6267 // The shipped batch buttons read the single-sample sliders, which was
6268 // caught once (M-14) and answered by baking the number into the label.
6269 // Forms of their own delete the piggyback rather than labelling it.
6270 let alone = FakeEditor::editing();
6271 assert!(!said(&edited(&alone)).contains("Batch"));
6272
6273 let several = FakeEditor::editing().with(|subject| subject.chosen = 12);
6274 assert!(said(&edited(&several)).contains("Batch: 12 samples"));
6275
6276 editing(
6277 &several,
6278 posting("/edit/batch/gain", Params::new().with("gain", "3.5")),
6279 )
6280 .unwrap();
6281 assert_eq!(several.asked(), ["batch gain 3.5"]);
6282
6283 // And the address refuses when the selection is not a batch.
6284 assert!(
6285 editing(
6286 &alone,
6287 posting("/edit/batch/gain", Params::new().with("gain", "3.5")),
6288 )
6289 .is_err()
6290 );
6291 }
6292
6293 #[test]
6294 fn reversing_more_than_ten_asks_first() {
6295 // `ConfirmAction::ReverseSamples` and the 140-line match behind it, as one
6296 // builder method. The fourth variant this port has replaced.
6297 let asks = |chosen: usize| {
6298 let editor = FakeEditor::editing().with(|subject| subject.chosen = chosen);
6299 nodes(&edited(&editor)).iter().find_map(|node| match node {
6300 Node::Act(act) if act.label.starts_with("Reverse ") => act.confirm.clone(),
6301 _ => None,
6302 })
6303 };
6304
6305 assert!(asks(3).is_none());
6306 let question = asks(40).expect("a large batch asks");
6307 assert!(question.contains("40"), "{question}");
6308 }
6309
6310 #[test]
6311 fn the_undo_is_offered_only_while_there_is_something_to_take_back() {
6312 // See the module header: this is an act rather than `Response::undoable`,
6313 // because the edit finishes on a worker and no answer is being made then.
6314 let done = FakeEditor::editing().with(|subject| subject.undoing = Some("Trim".to_owned()));
6315 let offered = said(&edited(&done));
6316 assert!(offered.contains("Last edit: Trim"), "{offered}");
6317
6318 editing(&done, Request::post("/edit/undo")).unwrap();
6319 assert_eq!(done.asked(), ["undo"]);
6320
6321 let fresh = FakeEditor::editing();
6322 assert!(!said(&edited(&fresh)).contains("Last edit"));
6323 // The act is an affordance, so the address refuses on its own.
6324 assert!(editing(&fresh, Request::post("/edit/undo")).is_err());
6325 }
6326
6327 #[test]
6328 fn replace_mode_says_what_it_costs() {
6329 let replacing =
6330 FakeEditor::editing().with(|subject| subject.result = Some("replace".to_owned()));
6331 let warned = said(&edited(&replacing));
6332 assert!(
6333 warned.contains("the original is removed from this vault"),
6334 "{warned}"
6335 );
6336
6337 let sibling = FakeEditor::editing().with(|subject| subject.result = Some("sibling".to_owned()));
6338 assert!(!said(&edited(&sibling)).contains("removed from this vault"));
6339 }
6340
6341 #[test]
6342 fn answering_the_prompt_carries_whether_to_remember_it() {
6343 // The reason the prompt is a form and not the shipped three buttons: an act
6344 // cannot carry the value a control beside it is holding. makeover-layout
6345 // `28a777df`.
6346 let editor = FakeEditor::editing().with(|subject| subject.asking = true);
6347 editing(
6348 &editor,
6349 posting(
6350 "/edit/result/choose",
6351 Params::new()
6352 .with("result", "replace")
6353 .with("remember", "on"),
6354 ),
6355 )
6356 .unwrap();
6357 assert_eq!(editor.asked(), ["choose replace remember=true"]);
6358
6359 let once = FakeEditor::editing().with(|subject| subject.asking = true);
6360 editing(
6361 &once,
6362 posting(
6363 "/edit/result/choose",
6364 Params::new().with("result", "sibling"),
6365 ),
6366 )
6367 .unwrap();
6368 assert_eq!(once.asked(), ["choose sibling remember=false"]);
6369 }
6370
6371 #[test]
6372 fn every_operation_answers_the_editor_rather_than_going_anywhere() {
6373 // An edit is something done to what is on screen, and the shipped panel
6374 // stays open through all of them.
6375 let editor = FakeEditor::editing().with(|subject| subject.chosen = 4);
6376 for request in [
6377 posting(
6378 "/edit/trim",
6379 Params::new().with("start", "0").with("end", "1"),
6380 ),
6381 posting("/edit/gain", Params::new().with("gain", "1")),
6382 posting(
6383 "/edit/normalize",
6384 Params::new().with("mode", "peak").with("target", "-1"),
6385 ),
6386 Request::post("/edit/reverse"),
6387 posting(
6388 "/edit/fade",
6389 Params::new()
6390 .with("in", "in")
6391 .with("length", "100")
6392 .with("curve", "linear"),
6393 ),
6394 posting(
6395 "/edit/silence/insert",
6396 Params::new().with("at", "0").with("length", "100"),
6397 ),
6398 posting(
6399 "/edit/silence/remove",
6400 Params::new().with("from", "0").with("to", "50"),
6401 ),
6402 Request::post("/edit/batch/reverse"),
6403 ] {
6404 let path = request.path.clone();
6405 let response = editing(&editor, request).unwrap();
6406 assert!(
6407 matches!(response.outcome, Outcome::Screen(_)),
6408 "{path}: {response:?}"
6409 );
6410 assert!(response.notice.is_some(), "{path} said nothing");
6411 }
6412 }
6413
6414 // --- the import flow ---
6415
6416 /// The screen the flow answers, at whatever stage it is at.
6417 fn imported(import: &FakeImport) -> Screen {
6418 screen_of(&importing(import, Request::get("/import")).unwrap()).clone()
6419 }
6420
6421 /// Every node on a screen, descending into regions.
6422 ///
6423 /// [`nodes`] walks the screen's own slots and stops. The flow's tagging stage
6424 /// puts a field inside a group per folder and its review stage puts two panes
6425 /// inside a split, so a test of either has to go down.
6426 fn deep_nodes(screen: &Screen) -> Vec<Node> {
6427 fn walk(body: &[quasi_router::Ranked], into: &mut Vec<Node>) {
6428 for placed in body {
6429 into.push(placed.node.clone());
6430 if let Node::Region(slot) = &placed.node {
6431 walk(&slot.body, into);
6432 }
6433 }
6434 }
6435
6436 let mut found = Vec::new();
6437 for slot in &screen.slots {
6438 walk(&slot.body, &mut found);
6439 }
6440 found
6441 }
6442
6443 /// Every act on a screen, by label, descending into regions.
6444 fn deep_acts(screen: &Screen) -> Vec<quasi_router::Act> {
6445 deep_nodes(screen)
6446 .into_iter()
6447 .filter_map(|node| match node {
6448 Node::Act(act) => Some(act),
6449 _ => None,
6450 })
6451 .collect()
6452 }
6453
6454 /// The labels of every act on a screen, descending into regions.
6455 fn deep_labels(screen: &Screen) -> Vec<String> {
6456 deep_acts(screen).into_iter().map(|act| act.label).collect()
6457 }
6458
6459 /// Every field on a screen, by name, descending into regions.
6460 fn deep_fields(screen: &Screen) -> Vec<quasi_router::Field> {
6461 deep_nodes(screen)
6462 .into_iter()
6463 .flat_map(|node| match node {
6464 Node::Field(field) => vec![*field],
6465 Node::Form { fields, .. } => fields,
6466 _ => Vec::new(),
6467 })
6468 .collect()
6469 }
6470
6471 /// What one part of a row says.
6472 fn said_in(row: &quasi_router::Row, part: quasi_router::layout::RowPart) -> String {
6473 row.role(part)
6474 .filter_map(|node| match node {
6475 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
6476 _ => None,
6477 })
6478 .collect::<Vec<_>>()
6479 .join(" ")
6480 }
6481
6482 /// Every row of every list on a screen, descending into regions.
6483 fn deep_rows(screen: &Screen) -> Vec<quasi_router::Row> {
6484 deep_nodes(screen)
6485 .into_iter()
6486 .flat_map(|node| match node {
6487 Node::List { rows, .. } => rows,
6488 _ => Vec::new(),
6489 })
6490 .collect()
6491 }
6492
6493 /// Everything a screen says, descending into regions.
6494 fn deep_said(screen: &Screen) -> String {
6495 deep_nodes(screen)
6496 .iter()
6497 .filter_map(|node| match node {
6498 Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => {
6499 Some(text.clone())
6500 }
6501 Node::StandIn { message, .. } => Some(message.clone()),
6502 _ => None,
6503 })
6504 .collect::<Vec<_>>()
6505 .join(" | ")
6506 }
6507
6508 /// An import being configured, with whatever answers a test wants.
6509 fn configuring(strategy: Strategy, vault_name: &str, vaults: &[&str]) -> Stage {
6510 Stage::Configuring {
6511 source: "/home/max/Downloads/packs".to_owned(),
6512 files: 412,
6513 strategy,
6514 vault_name: vault_name.to_owned(),
6515 vaults: vaults
6516 .iter()
6517 .map(|name| VaultChoice {
6518 name: (*name).to_owned(),
6519 })
6520 .collect(),
6521 merging_into: 0,
6522 }
6523 }
6524
6525 /// One reviewed sample with the suggestions a test names.
6526 fn reviewed(name: &str, suggestions: &[(&str, f32, bool)]) -> Reviewed {
6527 Reviewed {
6528 name: name.to_owned(),
6529 duration: 1.25,
6530 sample_rate: 48_000,
6531 peak_db: Some(-3.2),
6532 bpm: Some(128.0),
6533 musical_key: Some("Am".to_owned()),
6534 suggestions: suggestions
6535 .iter()
6536 .map(|(tag, confidence, accepted)| Suggestion {
6537 tag: (*tag).to_owned(),
6538 confidence: *confidence,
6539 reason: format!("because of {tag}"),
6540 accepted: *accepted,
6541 })
6542 .collect(),
6543 }
6544 }
6545
6546 #[test]
6547 fn nine_stages_answer_one_address_because_none_of_them_is_a_place() {
6548 // `export`'s rule at three times the size. A user does not navigate to
6549 // "files are being copied"; they arrive there because they pressed Import.
6550 let stages = [
6551 (Stage::Idle, "Nothing is being imported"),
6552 (configuring(Strategy::Flat, "", &[]), "Import Folder"),
6553 (
6554 Stage::Scanning {
6555 found: 40,
6556 size: Some("1.2 GB".to_owned()),
6557 },
6558 "Scanning for audio files",
6559 ),
6560 (
6561 Stage::Copying {
6562 done: 3,
6563 total: 9,
6564 current: "kick.wav".to_owned(),
6565 size: None,
6566 in_place: false,
6567 failures: Vec::new(),
6568 },
6569 "Importing: kick.wav",
6570 ),
6571 (
6572 Stage::Tagging {
6573 folders: Vec::new(),
6574 },
6575 "Tag Imported Folders",
6576 ),
6577 (
6578 Stage::Choosing {
6579 samples: 12,
6580 measures: every_measure(),
6581 resumable: true,
6582 },
6583 "12 samples to analyze",
6584 ),
6585 (
6586 Stage::Analysing {
6587 done: 1,
6588 total: 4,
6589 current: "snare.wav".to_owned(),
6590 failures: Vec::new(),
6591 },
6592 "Analysing: snare.wav",
6593 ),
6594 (
6595 Stage::Reviewing {
6596 items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])],
6597 at: 0,
6598 order: Order::Arrival,
6599 },
6600 "Review Tag Suggestions",
6601 ),
6602 (
6603 Stage::Summary {
6604 rejected: Vec::new(),
6605 unanalysed: Vec::new(),
6606 },
6607 "Import Summary",
6608 ),
6609 ];
6610
6611 for (stage, expected) in stages {
6612 let import = FakeImport::at(stage);
6613 let screen = imported(&import);
6614 let said = deep_said(&screen);
6615 assert!(said.contains(expected), "{expected} missing from: {said}");
6616 }
6617 }
6618
6619 /// Everything ticked, which is what the app defaults an analysis run to.
6620 fn every_measure() -> Measures {
6621 Measures {
6622 loudness: true,
6623 bpm: true,
6624 key: true,
6625 spectral: true,
6626 loops: true,
6627 suggestions: true,
6628 fingerprint: true,
6629 smart_skip: true,
6630 }
6631 }
6632
6633 #[test]
6634 fn the_stage_rail_survives_as_prose_because_the_vocabulary_cannot_say_who_chose() {
6635 // `Slot::showing_one` carries the names and the position and also says the
6636 // reader may change which child is up, which a wizard's stage is not. See
6637 // the module header: `quasi:vocabulary:unchosen-stage`.
6638 let railed = [
6639 (
6640 configuring(Strategy::Flat, "", &[]),
6641 "Step 1 of 4: Configure",
6642 ),
6643 (
6644 Stage::Tagging {
6645 folders: Vec::new(),
6646 },
6647 "Step 2 of 4: Tag folders",
6648 ),
6649 (
6650 Stage::Choosing {
6651 samples: 1,
6652 measures: every_measure(),
6653 resumable: false,
6654 },
6655 "Step 3 of 4: Analyze",
6656 ),
6657 (
6658 Stage::Reviewing {
6659 items: Vec::new(),
6660 at: 0,
6661 order: Order::Arrival,
6662 },
6663 "Step 4 of 4: Review",
6664 ),
6665 ];
6666
6667 for (stage, expected) in railed {
6668 let import = FakeImport::at(stage);
6669 assert!(
6670 deep_said(&imported(&import)).contains(expected),
6671 "{expected} missing"
6672 );
6673 }
6674
6675 // And the two stages the shipped screen rails nothing on do not invent one.
6676 let stopped = FakeImport::at(Stage::Stopped {
6677 what: Halted::Import,
6678 done: 2,
6679 total: 9,
6680 });
6681 assert!(!deep_said(&imported(&stopped)).contains("Step "));
6682 }
6683
6684 #[test]
6685 fn the_configure_screen_opens_only_the_follow_up_its_strategy_needs() {
6686 // A control that cannot be used is worse than one that is not there, which
6687 // is the settings screen's line and the shipped screen's own arrangement.
6688 let flat = FakeImport::at(configuring(Strategy::Flat, "", &["Drums"]));
6689 let named: Vec<String> = deep_fields(&imported(&flat))
6690 .into_iter()
6691 .map(|field| field.name)
6692 .collect();
6693 assert_eq!(named, [Decision::Strategy.as_str()]);
6694
6695 let new = FakeImport::at(configuring(Strategy::NewVault, "Kits", &["Drums"]));
6696 let named: Vec<String> = deep_fields(&imported(&new))
6697 .into_iter()
6698 .map(|field| field.name)
6699 .collect();
6700 assert_eq!(
6701 named,
6702 [Decision::Strategy.as_str(), Decision::VaultName.as_str()]
6703 );
6704
6705 let merge = FakeImport::at(configuring(Strategy::Merge, "", &["Drums", "Synths"]));
6706 let named: Vec<String> = deep_fields(&imported(&merge))
6707 .into_iter()
6708 .map(|field| field.name)
6709 .collect();
6710 assert_eq!(
6711 named,
6712 [Decision::Strategy.as_str(), Decision::MergeVault.as_str()]
6713 );
6714 }
6715
6716 #[test]
6717 fn a_new_vault_with_no_name_says_so_on_the_field_and_the_route_agrees() {
6718 // The state the shipped screen left to a hover: Import disabled itself and
6719 // told only the pointer why.
6720 let import = FakeImport::at(configuring(Strategy::NewVault, " ", &[]));
6721 let screen = imported(&import);
6722
6723 let field = deep_fields(&screen)
6724 .into_iter()
6725 .find(|field| field.name == Decision::VaultName.as_str())
6726 .expect("the vault name is asked for");
6727 assert_eq!(
6728 field.error.as_deref(),
6729 Some("Enter a name for the new vault.")
6730 );
6731
6732 let go = deep_acts(&screen)
6733 .into_iter()
6734 .find(|act| act.label == "Import")
6735 .expect("Import is offered");
6736 assert!(!go.interactive());
6737
6738 // And the address refuses, because a disabled control the reader can still
6739 // reach by typing is not disabled.
6740 assert!(importing(&import, Request::post("/import/start")).is_err());
6741 }
6742
6743 #[test]
6744 fn merging_with_nowhere_to_merge_is_refused_on_the_choice_that_says_why() {
6745 // `Choice::unless` is the member `Act::disabled` is missing: not pickable
6746 // yet, and why. See the module header's second finding.
6747 let import = FakeImport::at(configuring(Strategy::Flat, "", &[]));
6748 let screen = imported(&import);
6749 let strategy = deep_fields(&screen)
6750 .into_iter()
6751 .find(|field| field.name == Decision::Strategy.as_str())
6752 .expect("the strategy is asked for");
6753
6754 let merge = strategy
6755 .options
6756 .iter()
6757 .find(|choice| choice.value == Strategy::Merge.as_str())
6758 .expect("merging is on offer");
6759 assert!(!merge.available());
6760 assert_eq!(
6761 merge.unavailable.as_deref(),
6762 Some("No existing vaults to merge into.")
6763 );
6764
6765 // The other two stay pickable, because they are.
6766 for value in [Strategy::Flat.as_str(), Strategy::NewVault.as_str()] {
6767 let choice = strategy
6768 .options
6769 .iter()
6770 .find(|choice| choice.value == value)
6771 .expect("on offer");
6772 assert!(choice.available(), "{value} should be pickable");
6773 }
6774 }
6775
6776 #[test]
6777 fn the_one_way_edge_is_said_before_the_control_that_crosses_it() {
6778 // Configure to Importing is the only transition here that cannot be walked
6779 // back: cancelling mid-copy keeps what landed. So Import is a commit, and
6780 // the sentence is what stops it reading as a preview.
6781 let import = FakeImport::at(configuring(Strategy::Flat, "", &[]));
6782 assert!(
6783 deep_said(&imported(&import)).contains("copies already made will stay in the library"),
6784 "the commit is not said"
6785 );
6786 }
6787
6788 #[test]
6789 fn the_walk_is_pending_rather_than_a_meter_of_nothing() {
6790 // There is no total until the walk lands, and a meter of 0/0 draws as
6791 // finished. `export`'s reading, and here it is a whole stage rather than a
6792 // branch because the shipped screen answers it with a different body.
6793 let import = FakeImport::at(Stage::Scanning {
6794 found: 0,
6795 size: None,
6796 });
6797 let screen = imported(&import);
6798
6799 assert!(deep_nodes(&screen).iter().any(|node| matches!(
6800 node,
6801 Node::StandIn {
6802 state: quasi_router::layout::Readiness::Pending,
6803 ..
6804 }
6805 )));
6806 assert!(
6807 !deep_nodes(&screen)
6808 .iter()
6809 .any(|node| matches!(node, Node::Meter(_)))
6810 );
6811
6812 // Cancel is present and dead, and the reason is a line of its own because
6813 // `Act::disabled` cannot carry one.
6814 let cancel = deep_acts(&screen)
6815 .into_iter()
6816 .find(|act| act.label == "Cancel")
6817 .expect("Cancel is offered");
6818 assert!(!cancel.interactive());
6819 assert!(deep_said(&screen).contains("once the scan completes"));
6820 }
6821
6822 #[test]
6823 fn copying_says_whether_the_files_are_being_duplicated() {
6824 // The whole point of the line: referencing files where they sit costs no
6825 // disk and copying them costs this much.
6826 let copied = FakeImport::at(Stage::Copying {
6827 done: 1,
6828 total: 9,
6829 current: String::new(),
6830 size: Some("1.2 GB".to_owned()),
6831 in_place: false,
6832 failures: Vec::new(),
6833 });
6834 assert!(deep_said(&imported(&copied)).contains("~1.2 GB will be duplicated into vault"));
6835
6836 let referenced = FakeImport::at(Stage::Copying {
6837 done: 1,
6838 total: 9,
6839 current: String::new(),
6840 size: Some("1.2 GB".to_owned()),
6841 in_place: true,
6842 failures: Vec::new(),
6843 });
6844 assert!(deep_said(&imported(&referenced)).contains("referenced in place, no copies"));
6845 }
6846
6847 #[test]
6848 fn a_running_screen_reports_how_much_is_going_wrong_rather_than_where() {
6849 // One list across both halves of the run, which is what `draw_error_log`
6850 // does: at which stage a file failed is a fact for the summary.
6851 let import = FakeImport::at(Stage::Copying {
6852 done: 2,
6853 total: 9,
6854 current: String::new(),
6855 size: None,
6856 in_place: false,
6857 failures: vec![
6858 Failure {
6859 name: "/packs/broken.wav".to_owned(),
6860 error: "unsupported codec".to_owned(),
6861 },
6862 Failure {
6863 name: "hiss.aif".to_owned(),
6864 error: "decode failed".to_owned(),
6865 },
6866 ],
6867 });
6868 let screen = imported(&import);
6869
6870 assert!(deep_said(&screen).contains("2 errors"));
6871 assert_eq!(deep_rows(&screen).len(), 2);
6872 // Retry appears only because something failed.
6873 assert!(deep_labels(&screen).contains(&"Retry".to_owned()));
6874
6875 let clean = FakeImport::at(Stage::Copying {
6876 done: 2,
6877 total: 9,
6878 current: String::new(),
6879 size: None,
6880 in_place: false,
6881 failures: Vec::new(),
6882 });
6883 assert!(!deep_labels(&imported(&clean)).contains(&"Retry".to_owned()));
6884 }
6885
6886 #[test]
6887 fn applying_no_tags_is_refused_because_skip_is_the_discard_path() {
6888 // The shipped button's stated reason: Apply Tags stopped doubling as a
6889 // no-op Skip.
6890 let empty = FakeImport::at(Stage::Tagging {
6891 folders: vec![FolderTags {
6892 name: "kicks".to_owned(),
6893 samples: 12,
6894 typed: " ".to_owned(),
6895 invalid: Vec::new(),
6896 }],
6897 });
6898 let screen = imported(&empty);
6899 let apply = deep_acts(&screen)
6900 .into_iter()
6901 .find(|act| act.label == "Apply Tags")
6902 .expect("Apply Tags is offered");
6903 assert!(!apply.interactive());
6904 assert!(deep_said(&screen).contains("Add at least one tag, or use Skip."));
6905 assert!(importing(&empty, Request::post("/import/folders/apply")).is_err());
6906
6907 // Skip is never refused: it is the explicit discard.
6908 importing(&empty, Request::post("/import/folders/skip")).unwrap();
6909 assert_eq!(empty.answered(), ["tags:skip"]);
6910 }
6911
6912 #[test]
6913 fn an_invalid_tag_is_named_beside_the_folder_it_was_typed_against() {
6914 // Validated by the app's own rule rather than by a second copy of it here.
6915 let import = FakeImport::at(Stage::Tagging {
6916 folders: vec![FolderTags {
6917 name: "kicks".to_owned(),
6918 samples: 12,
6919 typed: "drums, NOT A TAG".to_owned(),
6920 invalid: vec!["NOT A TAG".to_owned()],
6921 }],
6922 });
6923 let screen = imported(&import);
6924 assert!(deep_said(&screen).contains("Invalid: NOT A TAG"));
6925 // And Apply is live, because something valid was typed too.
6926 let apply = deep_acts(&screen)
6927 .into_iter()
6928 .find(|act| act.label == "Apply Tags")
6929 .expect("Apply Tags is offered");
6930 assert!(apply.interactive());
6931 }
6932
6933 #[test]
6934 fn broadcasting_a_tag_set_is_a_form_rather_than_a_field() {
6935 // A `changes` on it would copy a half-typed tag into every input on the way
6936 // to the whole one.
6937 let import = FakeImport::at(Stage::Tagging {
6938 folders: vec![FolderTags {
6939 name: "kicks".to_owned(),
6940 samples: 12,
6941 typed: String::new(),
6942 invalid: Vec::new(),
6943 }],
6944 });
6945 let submits = forms(&imported(&import));
6946 assert_eq!(
6947 submits,
6948 [(
6949 "Apply to all".to_owned(),
6950 "/import/folders/all".to_owned(),
6951 vec!["tags".to_owned()]
6952 )]
6953 );
6954
6955 importing(
6956 &import,
6957 posting(
6958 "/import/folders/all",
6959 Params::new().with("tags", "one-shots"),
6960 ),
6961 )
6962 .unwrap();
6963 assert_eq!(import.answered(), ["tag:all=one-shots"]);
6964
6965 // And nothing is broadcast when nothing was typed.
6966 let blank = FakeImport::at(Stage::Tagging {
6967 folders: Vec::new(),
6968 });
6969 assert!(
6970 importing(
6971 &blank,
6972 posting("/import/folders/all", Params::new().with("tags", " "))
6973 )
6974 .is_err()
6975 );
6976 }
6977
6978 #[test]
6979 fn every_measure_is_a_control_and_an_address_and_the_set_is_closed() {
6980 let import = FakeImport::at(Stage::Choosing {
6981 samples: 12,
6982 measures: Measures {
6983 bpm: false,
6984 ..every_measure()
6985 },
6986 resumable: true,
6987 });
6988 let screen = imported(&import);
6989
6990 let named: Vec<String> = deep_fields(&screen)
6991 .into_iter()
6992 .map(|field| field.name)
6993 .collect();
6994 let expected: Vec<String> = Measure::ALL
6995 .into_iter()
6996 .map(|measure| measure.as_str().to_owned())
6997 .collect();
6998 assert_eq!(named, expected);
6999
7000 // What is off reads as off.
7001 let bpm = deep_fields(&screen)
7002 .into_iter()
7003 .find(|field| field.name == Measure::Bpm.as_str())
7004 .expect("BPM is asked about");
7005 assert_eq!(bpm.value.as_deref(), Some(""));
7006
7007 importing(
7008 &import,
7009 posting(
7010 "/import/measure/bpm",
7011 Params::new().with(Measure::Bpm.as_str(), "on"),
7012 ),
7013 )
7014 .unwrap();
7015 assert_eq!(import.answered(), ["measure:bpm=true"]);
7016
7017 // A name the description does not know is a refusal rather than a no-op:
7018 // the address is reachable by typing.
7019 assert!(importing(&import, Request::post("/import/measure/vibes")).is_err());
7020 }
7021
7022 #[test]
7023 fn going_back_is_refused_where_there_is_no_tagging_step_behind_it() {
7024 // What the shipped Back button is disabled on: the flow was entered
7025 // somewhere other than a folder import, so nothing was stashed.
7026 let stranded = FakeImport::at(Stage::Choosing {
7027 samples: 12,
7028 measures: every_measure(),
7029 resumable: false,
7030 });
7031 let screen = imported(&stranded);
7032 let back = deep_acts(&screen)
7033 .into_iter()
7034 .find(|act| act.label == "Back")
7035 .expect("Back is offered");
7036 assert!(!back.interactive());
7037 assert!(importing(&stranded, Request::post("/import/analyse/back")).is_err());
7038
7039 let resumable = FakeImport::at(Stage::Choosing {
7040 samples: 12,
7041 measures: every_measure(),
7042 resumable: true,
7043 });
7044 importing(&resumable, Request::post("/import/analyse/back")).unwrap();
7045 assert_eq!(resumable.answered(), ["analyse:back"]);
7046 }
7047
7048 #[test]
7049 fn suggestions_are_read_best_first_and_the_route_does_not_sort_to_get_there() {
7050 // The shipped screen sorts `item.suggestions` in place every frame, which a
7051 // handler holding `&S` cannot. The adapter sorts the copy, so the order is
7052 // a fact the description arrives carrying.
7053 let import = FakeImport::at(Stage::Reviewing {
7054 items: vec![reviewed(
7055 "kick.wav",
7056 &[("drums/kick", 0.91, true), ("percussion", 0.42, false)],
7057 )],
7058 at: 0,
7059 order: Order::Arrival,
7060 });
7061 let screen = imported(&import);
7062
7063 let tagged: Vec<String> = deep_rows(&screen)
7064 .into_iter()
7065 .filter_map(|row| row.parts.first().map(|_| row.primary()))
7066 .collect();
7067 assert!(
7068 tagged.iter().any(|said| said.contains("drums/kick")),
7069 "{tagged:?}"
7070 );
7071
7072 // The confidence rides as a trailing fact rather than as a colour: what is
7073 // described is how sure the analysis is.
7074 assert!(
7075 deep_rows(&screen)
7076 .iter()
7077 .any(|row| said_in(row, quasi_router::layout::RowPart::Meta) == "91%")
7078 );
7079 }
7080
7081 #[test]
7082 fn judging_flips_rather_than_sets_because_a_tick_submits_no_state() {
7083 // `Row::toggling` says the tick *is* the write, and a renderer fires it
7084 // with no value of its own. So the route reads what is true and answers
7085 // with the other one.
7086 let import = FakeImport::at(Stage::Reviewing {
7087 items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])],
7088 at: 0,
7089 order: Order::Arrival,
7090 });
7091 importing(
7092 &import,
7093 posting(
7094 "/import/review/0/judge",
7095 Params::new().with("tag", "drums/kick"),
7096 ),
7097 )
7098 .unwrap();
7099 assert_eq!(import.answered(), ["judge:0:drums/kick=true"]);
7100
7101 let accepted = FakeImport::at(Stage::Reviewing {
7102 items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, true)])],
7103 at: 0,
7104 order: Order::Arrival,
7105 });
7106 importing(
7107 &accepted,
7108 posting(
7109 "/import/review/0/judge",
7110 Params::new().with("tag", "drums/kick"),
7111 ),
7112 )
7113 .unwrap();
7114 assert_eq!(accepted.answered(), ["judge:0:drums/kick=false"]);
7115
7116 // A tag nothing suggested is a refusal, not a write.
7117 assert!(
7118 importing(
7119 &import,
7120 posting(
7121 "/import/review/0/judge",
7122 Params::new().with("tag", "invented")
7123 )
7124 )
7125 .is_err()
7126 );
7127 }
7128
7129 #[test]
7130 fn applying_nothing_is_refused_because_a_zero_commit_is_a_control_that_lies() {
7131 let none = FakeImport::at(Stage::Reviewing {
7132 items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])],
7133 at: 0,
7134 order: Order::Arrival,
7135 });
7136 let screen = imported(&none);
7137 let apply = deep_acts(&screen)
7138 .into_iter()
7139 .find(|act| act.label.starts_with("Apply "))
7140 .expect("Apply is offered");
7141 assert_eq!(apply.label, "Apply 0 Tags");
7142 assert!(!apply.interactive());
7143 assert!(importing(&none, Request::post("/import/review/apply")).is_err());
7144
7145 let one = FakeImport::at(Stage::Reviewing {
7146 items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, true)])],
7147 at: 0,
7148 order: Order::Arrival,
7149 });
7150 let screen = imported(&one);
7151 let apply = deep_acts(&screen)
7152 .into_iter()
7153 .find(|act| act.label.starts_with("Apply "))
7154 .expect("Apply is offered");
7155 // The count is on the control, so the blast radius is read before the press.
7156 assert_eq!(apply.label, "Apply 1 Tag");
7157 assert!(apply.interactive());
7158 }
7159
7160 #[test]
7161 fn the_batch_is_summarised_so_the_analysis_can_be_eyeballed_before_it_commits() {
7162 let import = FakeImport::at(Stage::Reviewing {
7163 items: vec![
7164 Reviewed {
7165 bpm: Some(90.0),
7166 musical_key: Some("Am".to_owned()),
7167 ..reviewed("kick.wav", &[("drums/kick", 0.9, false)])
7168 },
7169 Reviewed {
7170 bpm: Some(174.0),
7171 musical_key: Some("Am".to_owned()),
7172 ..reviewed("snare.wav", &[])
7173 },
7174 ],
7175 at: 0,
7176 order: Order::Arrival,
7177 });
7178 let figures: Vec<(String, String)> = deep_nodes(&imported(&import))
7179 .into_iter()
7180 .flat_map(|node| match node {
7181 Node::Stats { figures } => figures,
7182 _ => Vec::new(),
7183 })
7184 .map(|(figure, _)| (figure.value, figure.caption))
7185 .collect();
7186
7187 assert!(
7188 figures.contains(&("90 - 174".to_owned(), "BPM".to_owned())),
7189 "{figures:?}"
7190 );
7191 assert!(
7192 figures.contains(&("2".to_owned(), "Am".to_owned())),
7193 "{figures:?}"
7194 );
7195 }
7196
7197 #[test]
7198 fn the_summary_keeps_the_two_kinds_of_failure_apart() {
7199 // One is remediable from this screen and one is not, which is what the
7200 // shipped copy says and why they are not the single list the progress
7201 // screens show.
7202 let import = FakeImport::at(Stage::Summary {
7203 rejected: vec![Failure {
7204 name: "/packs/broken.wav".to_owned(),
7205 error: "unsupported codec".to_owned(),
7206 }],
7207 unanalysed: vec![Failure {
7208 name: "hiss.wav".to_owned(),
7209 error: "decode failed".to_owned(),
7210 }],
7211 });
7212 let screen = imported(&import);
7213 let said = deep_said(&screen);
7214
7215 assert!(said.contains("1 file failed analysis"), "{said}");
7216 assert!(said.contains("1 file failed to import"), "{said}");
7217 assert!(said.contains("couldn't be analysed"), "{said}");
7218 assert!(said.contains("Re-running the import"), "{said}");
7219
7220 // Only the analysis failures can be removed from here.
7221 let removable: Vec<String> = deep_rows(&screen)
7222 .into_iter()
7223 .flat_map(|row| row.menu.into_iter().map(|act| act.label))
7224 .collect();
7225 assert_eq!(removable, ["Remove"]);
7226 }
7227
7228 #[test]
7229 fn removing_failed_samples_asks_on_the_control_that_does_it() {
7230 // `Act::confirm`, which is what `ConfirmAction`'s ten variants were.
7231 let import = FakeImport::at(Stage::Summary {
7232 rejected: Vec::new(),
7233 unanalysed: vec![
7234 Failure {
7235 name: "hiss.wav".to_owned(),
7236 error: "decode failed".to_owned(),
7237 },
7238 Failure {
7239 name: "click.wav".to_owned(),
7240 error: "decode failed".to_owned(),
7241 },
7242 ],
7243 });
7244 let screen = imported(&import);
7245
7246 let all = deep_acts(&screen)
7247 .into_iter()
7248 .find(|act| act.label == "Remove All Failed")
7249 .expect("Remove All Failed is offered");
7250 assert_eq!(all.tone, quasi_router::layout::Tone::Danger);
7251 assert!(
7252 all.confirm
7253 .as_deref()
7254 .is_some_and(|asked| asked.contains("2 samples")),
7255 "{:?}",
7256 all.confirm
7257 );
7258
7259 let one = deep_rows(&screen)
7260 .into_iter()
7261 .flat_map(|row| row.menu)
7262 .next()
7263 .expect("a row offers Remove");
7264 assert!(
7265 one.confirm
7266 .as_deref()
7267 .is_some_and(|asked| asked.contains("hiss.wav")),
7268 "{:?}",
7269 one.confirm
7270 );
7271
7272 importing(&import, Request::post("/import/summary/1/purge")).unwrap();
7273 assert_eq!(import.answered(), ["failed:purge:1"]);
7274 // Past the end is a refusal rather than a delete of whatever is there.
7275 assert!(importing(&import, Request::post("/import/summary/9/purge")).is_err());
7276 }
7277
7278 #[test]
7279 fn the_doors_hand_off_to_the_host_and_say_where_the_answer_will_be() {
7280 // No outcome means "nothing here changed", so they answer the flow, which
7281 // is right by the time the picker returns. See the module header.
7282 for (address, expected) in [
7283 ("/import/open/folder", "open:folder"),
7284 ("/import/open/quick", "open:quick"),
7285 ("/import/open/files", "open:files"),
7286 ] {
7287 let import = FakeImport::default();
7288 let response = importing(&import, Request::post(address)).unwrap();
7289 assert!(
7290 matches!(&response.outcome, Outcome::Goto(action) if action.destination.as_str() == "/import"),
7291 "{address} answered {:?}",
7292 response.outcome
7293 );
7294 assert_eq!(import.answered(), [expected]);
7295 }
7296 }
7297
7298 #[test]
7299 fn the_source_can_only_be_changed_while_there_is_one_being_configured() {
7300 let configuring = FakeImport::at(configuring(Strategy::Flat, "", &[]));
7301 importing(&configuring, Request::post("/import/source")).unwrap();
7302 assert_eq!(configuring.answered(), ["open:source"]);
7303
7304 let running = FakeImport::at(Stage::Copying {
7305 done: 1,
7306 total: 9,
7307 current: String::new(),
7308 size: None,
7309 in_place: false,
7310 failures: Vec::new(),
7311 });
7312 assert!(importing(&running, Request::post("/import/source")).is_err());
7313 }
7314
7315 #[test]
7316 fn the_import_menu_is_an_overlay_holding_the_three_doors() {
7317 // The shipped control is a popup of three choices anchored to a button.
7318 // Described as an overlay, which is near enough and not exact -- the second
7319 // consumer of the note `toolbar` left on anchoring.
7320 let import = FakeImport::default();
7321 let response = importing(&import, Request::get("/import/open")).unwrap();
7322 assert!(matches!(response.outcome, Outcome::Over(_)), "{response:?}");
7323
7324 let labels = deep_labels(screen_of(&response));
7325 assert!(
7326 labels.contains(&"Import folder...".to_owned()),
7327 "{labels:?}"
7328 );
7329 assert!(
7330 labels.contains(&"Quick import folder...".to_owned()),
7331 "{labels:?}"
7332 );
7333 assert!(labels.contains(&"Import files...".to_owned()), "{labels:?}");
7334
7335 // Each says what it does, which is the correction the shipped popup made to
7336 // itself: the two folder entries differ in commit semantics, not in name.
7337 let said = deep_said(screen_of(&response));
7338 assert!(said.contains("no strategy or tagging review"), "{said}");
7339 }
7340
7341 #[test]
7342 fn an_idle_flow_offers_the_way_in_rather_than_only_saying_it_is_empty() {
7343 // Where this differs from `export`'s idle: an export is entered by choosing
7344 // samples on another screen and there is nothing honest to offer, and an
7345 // import is entered by choosing a folder, which is a control.
7346 let import = FakeImport::default();
7347 let screen = imported(&import);
7348 let offered: Vec<String> = deep_nodes(&screen)
7349 .into_iter()
7350 .flat_map(|node| match node {
7351 Node::StandIn { act, .. } => act.map(|act| act.label).into_iter().collect::<Vec<_>>(),
7352 _ => Vec::new(),
7353 })
7354 .collect();
7355 assert_eq!(offered, ["Import..."]);
7356 }
7357
7358 #[test]
7359 fn the_sweep_is_not_a_stage_of_the_flow_and_answers_its_own_address() {
7360 // It shares a shipped file and an app enum with four screens that are part
7361 // of the flow, and nothing else. See the module header.
7362 let idle = FakeImport::default();
7363 assert!(importing(&idle, Request::get("/cleanup")).is_err());
7364
7365 let sweeping = FakeImport::sweeping(Sweep {
7366 done: 4,
7367 total: 20,
7368 current: "orphan.wav".to_owned(),
7369 });
7370 let screen = screen_of(&importing(&sweeping, Request::get("/cleanup")).unwrap()).clone();
7371 let said = deep_said(&screen);
7372 assert!(said.contains("Cleaning Up Samples"), "{said}");
7373 assert!(said.contains("Removing: orphan.wav"), "{said}");
7374 assert!(
7375 !said.contains("Step "),
7376 "the sweep is not a wizard step: {said}"
7377 );
7378
7379 // The flow itself is idle while a sweep runs, because they are different
7380 // operations that happen to share an enum.
7381 assert!(deep_said(&imported(&sweeping)).contains("Nothing is being imported"));
7382
7383 importing(&sweeping, Request::post("/cleanup/stop")).unwrap();
7384 assert_eq!(sweeping.answered(), ["sweep:stop"]);
7385 }
7386
7387 #[test]
7388 fn a_sweep_that_has_not_counted_anything_is_pending_rather_than_finished() {
7389 let sweeping = FakeImport::sweeping(Sweep {
7390 done: 0,
7391 total: 0,
7392 current: String::new(),
7393 });
7394 let screen = screen_of(&importing(&sweeping, Request::get("/cleanup")).unwrap()).clone();
7395 assert!(deep_nodes(&screen).iter().any(|node| matches!(
7396 node,
7397 Node::StandIn {
7398 state: quasi_router::layout::Readiness::Pending,
7399 ..
7400 }
7401 )));
7402 }
7403
7404 #[test]
7405 fn the_flow_refuses_every_name_it_did_not_declare() {
7406 // Each of these addresses is reachable by typing, so an undeclared name is
7407 // a `NotFound` rather than a panic or a silent no-op.
7408 let import = FakeImport::at(configuring(Strategy::Flat, "", &[]));
7409 assert!(importing(&import, Request::post("/import/set/colour")).is_err());
7410 assert!(
7411 importing(
7412 &import,
7413 posting(
7414 "/import/set/strategy",
7415 Params::new().with("strategy", "sideways")
7416 )
7417 )
7418 .is_err()
7419 );
7420
7421 let reviewing = FakeImport::at(Stage::Reviewing {
7422 items: vec![reviewed("kick.wav", &[("drums/kick", 0.9, false)])],
7423 at: 0,
7424 order: Order::Arrival,
7425 });
7426 assert!(
7427 importing(
7428 &reviewing,
7429 posting("/import/review/order", Params::new().with("value", "vibes"))
7430 )
7431 .is_err()
7432 );
7433 assert!(importing(&reviewing, Request::post("/import/review/7/read")).is_err());
7434
7435 let tagging = FakeImport::at(Stage::Tagging {
7436 folders: Vec::new(),
7437 });
7438 assert!(
7439 importing(
7440 &tagging,
7441 posting(
7442 "/import/folders/3/tags",
7443 Params::new().with("tags", "drums")
7444 )
7445 )
7446 .is_err()
7447 );
7448 }
7449
7450 #[test]
7451 fn the_stage_a_write_lands_on_is_the_stage_it_was_asked_from() {
7452 // Every write is refused from a stage that is not about it, which is what
7453 // keeps the flow's thirty-one routes from being thirty-one ways to reach
7454 // state the screen is not showing.
7455 let copying = FakeImport::at(Stage::Copying {
7456 done: 1,
7457 total: 9,
7458 current: String::new(),
7459 size: None,
7460 in_place: false,
7461 failures: Vec::new(),
7462 });
7463 assert!(importing(&copying, Request::post("/import/start")).is_err());
7464 assert!(importing(&copying, Request::post("/import/folders/apply")).is_err());
7465 assert!(importing(&copying, Request::post("/import/review/apply")).is_err());
7466 assert!(importing(&copying, Request::post("/import/analyse/back")).is_err());
7467
7468 // And the ones that are about giving up are not: they are what a running
7469 // stage is for.
7470 importing(&copying, Request::post("/import/stop")).unwrap();
7471 importing(&copying, Request::post("/import/retry")).unwrap();
7472 importing(&copying, Request::post("/import/dismiss")).unwrap();
7473 assert_eq!(copying.answered(), ["stop", "retry", "dismiss"]);
7474 }
7475
7476 #[test]
7477 fn cancelling_says_what_landed_and_what_is_left_for_either_half() {
7478 for (what, expected) in [
7479 (Halted::Import, "duplicates will be skipped"),
7480 (Halted::Analysis, "run analysis again to complete them"),
7481 ] {
7482 let import = FakeImport::at(Stage::Stopped {
7483 what,
7484 done: 3,
7485 total: 9,
7486 });
7487 let said = deep_said(&imported(&import));
7488 assert!(said.contains("Stopped at 3 of 9"), "{said}");
7489 assert!(said.contains(expected), "{said}");
7490 }
7491 }
7492
7493 #[test]
7494 fn the_toolbar_carries_both_doors_now_that_the_flows_have_them() {
7495 // The toolbar port left these out because they "belong with the import
7496 // flow, which is its own remaining pass". This is that pass.
7497 let labels = acts(&topped(&FakeBar::at(Where::Folder { trail: Vec::new() })));
7498 assert!(labels.contains(&"Import".to_owned()), "{labels:?}");
7499 assert!(labels.contains(&"Export".to_owned()), "{labels:?}");
7500 }
7501
7502 // --- the forge ---
7503
7504 /// Nothing is in the forge, and nothing can be put there.
7505 ///
7506 /// [`Idle`] and [`NoImport`]'s third peer: every method is a refusal, so a test
7507 /// of some other screen cannot chop a sample by accident.
7508 struct Unforged;
7509
7510 impl Forge for Unforged {
7511 fn forging(&self) -> Option<Forging> {
7512 None
7513 }
7514 fn slice_by(&self, _how: Chop) {}
7515 fn turn(&self, _knob: Knob, _value: &str) {}
7516 fn preview(&self) {}
7517 fn chop(&self) {}
7518 fn choose_device(&self, _name: &str) {}
7519 fn conform(&self) {}
7520 fn trim_silence(&self) {}
7521 }
7522
7523 /// A forge in memory, recording what was asked of it.
7524 struct FakeForge {
7525 forging: Option<Forging>,
7526 asked: RefCell<Vec<String>>,
7527 }
7528
7529 impl FakeForge {
7530 fn with(forging: Forging) -> Self {
7531 Self {
7532 forging: Some(forging),
7533 asked: RefCell::new(Vec::new()),
7534 }
7535 }
7536
7537 fn empty() -> Self {
7538 Self {
7539 forging: None,
7540 asked: RefCell::new(Vec::new()),
7541 }
7542 }
7543
7544 fn asked(&self) -> Vec<String> {
7545 self.asked.borrow().clone()
7546 }
7547
7548 fn say(&self, said: impl Into<String>) {
7549 self.asked.borrow_mut().push(said.into());
7550 }
7551 }
7552
7553 impl Forge for FakeForge {
7554 fn forging(&self) -> Option<Forging> {
7555 self.forging.clone()
7556 }
7557
7558 fn slice_by(&self, how: Chop) {
7559 self.say(format!("slice:{}", how.as_str()));
7560 }
7561
7562 fn turn(&self, knob: Knob, value: &str) {
7563 self.say(format!("set:{}={value}", knob.as_str()));
7564 }
7565
7566 fn preview(&self) {
7567 self.say("preview");
7568 }
7569
7570 fn chop(&self) {
7571 self.say("chop");
7572 }
7573
7574 fn choose_device(&self, name: &str) {
7575 self.say(format!("device={name}"));
7576 }
7577
7578 fn conform(&self) {
7579 self.say("conform");
7580 }
7581
7582 fn trim_silence(&self) {
7583 self.say("trim");
7584 }
7585 }
7586
7587 /// A sample loaded into the forge, with whatever a test wants of it.
7588 fn forging() -> Forging {
7589 Forging {
7590 name: "break.wav".to_owned(),
7591 rate: 44_100,
7592 busy: false,
7593 how: Chop::Equal,
7594 sensitivity: 0.5,
7595 divisions: 8,
7596 bpm: 120.0,
7597 subdivisions: 1,
7598 slices: 0,
7599 devices: vec![
7600 DeviceChoice {
7601 name: "SP-404".to_owned(),
7602 summary: "WAV 44.1k/16".to_owned(),
7603 },
7604 DeviceChoice {
7605 name: "Digitakt".to_owned(),
7606 summary: String::new(),
7607 },
7608 ],
7609 device: None,
7610 chosen: 1,
7611 threshold_db: -60.0,
7612 }
7613 }
7614
7615 /// A router call against this forge.
7616 fn forged(forge: &FakeForge, request: Request) -> Result<Response, quasi_router::RouteError> {
7617 let store = Store::default();
7618 let sync = Offline;
7619 let files = FakeFiles::default();
7620 let themes = themes();
7621 let state = Panels {
7622 config: &store,
7623 sync: &sync,
7624 files: &files,
7625 export: &Idle,
7626 detail: &Unfocused,
7627 bulk: &Unchosen,
7628 shell: &Quiet,
7629 library: &Empty,
7630 bar: &Still,
7631 naming: &Unnamed,
7632 importing: &NoImport,
7633 integrity: &Sound,
7634 editor: &Unedited,
7635 forge,
7636 queue: &Unqueued,
7637 filters: &Unfiltered,
7638 themes: &themes,
7639 };
7640 router().handle(&state, request)
7641 }
7642
7643 /// The forge window, with whatever is loaded into it.
7644 fn forge_screen(forge: &FakeForge) -> Screen {
7645 screen_of(&forged(forge, Request::get("/forge")).unwrap()).clone()
7646 }
7647
7648 #[test]
7649 fn an_empty_forge_says_what_would_fill_it() {
7650 let forge = FakeForge::empty();
7651 let said = deep_said(&forge_screen(&forge));
7652 assert!(
7653 said.contains("Select a sample and open the forge"),
7654 "{said}"
7655 );
7656
7657 // And every write is refused, because there is nothing to write to.
7658 for address in [
7659 "/forge/preview",
7660 "/forge/chop",
7661 "/forge/conform",
7662 "/forge/trim",
7663 ] {
7664 assert!(forged(&forge, Request::post(address)).is_err(), "{address}");
7665 }
7666 }
7667
7668 #[test]
7669 fn the_forge_is_one_shape_because_busy_is_a_property_of_the_sample() {
7670 // The rule from the other side: a state a reader arrived at is a shape, and
7671 // a property of the subject is a field. Every section is still described
7672 // while a run is in flight.
7673 let busy = FakeForge::with(Forging {
7674 busy: true,
7675 chosen: 3,
7676 ..forging()
7677 });
7678 let screen = forge_screen(&busy);
7679 let said = deep_said(&screen);
7680
7681 assert!(said.contains("Working..."), "{said}");
7682 assert!(said.contains("Chop"), "{said}");
7683 assert!(said.contains("Batch"), "{said}");
7684 // The conform section's heading is the picker's own label, which is the
7685 // shipped screen's call: the question names itself and the `strong` line
7686 // above it went.
7687 assert!(
7688 deep_fields(&screen)
7689 .into_iter()
7690 .any(|field| field.label == "Conform for device")
7691 );
7692
7693 // The acts can say they are dead. The fields cannot, which is the finding.
7694 let preview = deep_acts(&screen)
7695 .into_iter()
7696 .find(|act| act.label == "Preview slices")
7697 .expect("Preview is offered");
7698 assert!(!preview.interactive());
7699 }
7700
7701 #[test]
7702 fn only_the_parameters_the_chosen_method_reads_are_described() {
7703 // The shipped window's own `match`, and the settings screen's line: a
7704 // control that cannot be used is worse than one that is not there.
7705 let transient = FakeForge::with(Forging {
7706 how: Chop::Transient,
7707 ..forging()
7708 });
7709 let named: Vec<String> = deep_fields(&forge_screen(&transient))
7710 .into_iter()
7711 .map(|field| field.name)
7712 .collect();
7713 assert!(
7714 named.contains(&Knob::Sensitivity.as_str().to_owned()),
7715 "{named:?}"
7716 );
7717 assert!(!named.contains(&Knob::Bpm.as_str().to_owned()), "{named:?}");
7718
7719 let grid = FakeForge::with(Forging {
7720 how: Chop::Bpm,
7721 ..forging()
7722 });
7723 let named: Vec<String> = deep_fields(&forge_screen(&grid))
7724 .into_iter()
7725 .map(|field| field.name)
7726 .collect();
7727 assert!(named.contains(&Knob::Bpm.as_str().to_owned()), "{named:?}");
7728 assert!(
7729 !named.contains(&Knob::Sensitivity.as_str().to_owned()),
7730 "{named:?}"
7731 );
7732
7733 // Divisions is a strip of a handful of values rather than a field, which is
7734 // what the shipped row of selectable buttons is.
7735 let equal = FakeForge::with(forging());
7736 let named: Vec<String> = deep_fields(&forge_screen(&equal))
7737 .into_iter()
7738 .map(|field| field.name)
7739 .collect();
7740 assert!(
7741 !named.contains(&Knob::Divisions.as_str().to_owned()),
7742 "{named:?}"
7743 );
7744 }
7745
7746 #[test]
7747 fn chopping_is_gated_on_a_preview_and_the_label_carries_the_count() {
7748 // AF-9: committing to an unknown slice count is what the preview exists to
7749 // stop, and the count on the label is the blast radius before the press.
7750 let unpreviewed = FakeForge::with(forging());
7751 let screen = forge_screen(&unpreviewed);
7752 let chop = deep_acts(&screen)
7753 .into_iter()
7754 .find(|act| act.label.starts_with("Chop"))
7755 .expect("Chop is offered");
7756 assert_eq!(chop.label, "Chop");
7757 assert!(!chop.interactive());
7758 // The fifth consumer of `quasi:vocabulary:disabled-reason`, degraded to a
7759 // line beside the control.
7760 assert!(deep_said(&screen).contains("Preview the slices first"));
7761 assert!(forged(&unpreviewed, Request::post("/forge/chop")).is_err());
7762
7763 let previewed = FakeForge::with(Forging {
7764 slices: 14,
7765 ..forging()
7766 });
7767 let screen = forge_screen(&previewed);
7768 let chop = deep_acts(&screen)
7769 .into_iter()
7770 .find(|act| act.label.starts_with("Chop"))
7771 .expect("Chop is offered");
7772 assert_eq!(chop.label, "Chop into 14 slices");
7773 assert!(chop.interactive());
7774 forged(&previewed, Request::post("/forge/chop")).unwrap();
7775 assert_eq!(previewed.asked(), ["chop"]);
7776 }
7777
7778 #[test]
7779 fn the_device_picker_says_what_to_do_in_its_own_ghost_text() {
7780 // The call the shipped screen already made: a select with nothing chosen
7781 // reads as an empty box, and the greyed button beside it is the wrong place
7782 // to explain that.
7783 let forge = FakeForge::with(forging());
7784 let screen = forge_screen(&forge);
7785 let picker = deep_fields(&screen)
7786 .into_iter()
7787 .find(|field| field.name == "device")
7788 .expect("the device is asked for");
7789 assert_eq!(picker.placeholder.as_deref(), Some("Select device..."));
7790 assert_eq!(picker.value.as_deref(), Some(""));
7791
7792 // The summary is part of what the option reads as, and a device with none
7793 // is just its name.
7794 let labels: Vec<String> = picker
7795 .options
7796 .iter()
7797 .map(|choice| choice.label.clone())
7798 .collect();
7799 assert_eq!(labels, ["SP-404 (WAV 44.1k/16)", "Digitakt"]);
7800
7801 // Conform is dead until one is chosen, and refused at the address too.
7802 let conform = deep_acts(&screen)
7803 .into_iter()
7804 .find(|act| act.label == "Conform")
7805 .expect("Conform is offered");
7806 assert!(!conform.interactive());
7807 assert!(forged(&forge, Request::post("/forge/conform")).is_err());
7808
7809 // A device no profile carries is a refusal: the address is reachable by
7810 // typing.
7811 assert!(
7812 forged(
7813 &forge,
7814 posting("/forge/device", Params::new().with("device", "MPC-9000"))
7815 )
7816 .is_err()
7817 );
7818 // And an empty value is "nothing chosen" rather than a device named "".
7819 forged(
7820 &forge,
7821 posting("/forge/device", Params::new().with("device", "")),
7822 )
7823 .unwrap();
7824 assert_eq!(forge.asked(), ["device="]);
7825 }
7826
7827 #[test]
7828 fn a_forge_with_no_device_profiles_says_so_instead_of_offering_an_empty_picker() {
7829 let forge = FakeForge::with(Forging {
7830 devices: Vec::new(),
7831 ..forging()
7832 });
7833 let screen = forge_screen(&forge);
7834 assert!(deep_said(&screen).contains("No device profiles available."));
7835 assert!(
7836 !deep_fields(&screen)
7837 .into_iter()
7838 .any(|field| field.name == "device")
7839 );
7840 }
7841
7842 #[test]
7843 fn the_batch_section_is_about_the_selection_rather_than_the_sample() {
7844 // Trimming a batch of one is the single-sample operation wearing the
7845 // batch's label, which is what the shipped section is hidden behind.
7846 let alone = FakeForge::with(forging());
7847 assert!(deep_said(&forge_screen(&alone)).contains("Select 2+ samples"));
7848 assert!(forged(&alone, Request::post("/forge/trim")).is_err());
7849
7850 let several = FakeForge::with(Forging {
7851 chosen: 5,
7852 ..forging()
7853 });
7854 let screen = forge_screen(&several);
7855 let trim = deep_acts(&screen)
7856 .into_iter()
7857 .find(|act| act.label.starts_with("Trim"))
7858 .expect("Trim is offered");
7859 assert_eq!(trim.label, "Trim silence on 5 samples");
7860 forged(&several, Request::post("/forge/trim")).unwrap();
7861 assert_eq!(several.asked(), ["trim"]);
7862 }
7863
7864 #[test]
7865 fn a_measured_control_names_its_unit_rather_than_hiding_it_in_the_label() {
7866 // makeover-layout 0.33.0, decided by Max 2026-08-21. The label is the
7867 // question's name and the unit is a fact about the value, so a reader of
7868 // this description gets `-96` and `dBFS` as two answers rather than one
7869 // string it would have to parse the second out of.
7870 // The batch trim appears once more than one sample is picked, which is what
7871 // carries the threshold.
7872 let forge = FakeForge::with(Forging {
7873 chosen: 3,
7874 ..forging()
7875 });
7876 let response = forged(&forge, Request::get("/forge")).unwrap();
7877
7878 let threshold = deep_fields(screen_of(&response))
7879 .into_iter()
7880 .find(|field| field.name == "threshold")
7881 .expect("the threshold is described");
7882 assert_eq!(threshold.label, "Threshold");
7883 assert_eq!(threshold.unit.as_deref(), Some("dBFS"));
7884 assert!(threshold.kind.measurable());
7885 }
7886
7887 #[test]
7888 fn one_write_route_serves_five_controls_across_two_sections() {
7889 let forge = FakeForge::with(Forging {
7890 how: Chop::Bpm,
7891 chosen: 3,
7892 ..forging()
7893 });
7894 for (address, name, value, expected) in [
7895 ("/forge/set/bpm", "bpm", "174", "set:bpm=174"),
7896 (
7897 "/forge/set/subdivisions",
7898 "subdivisions",
7899 "4",
7900 "set:subdivisions=4",
7901 ),
7902 (
7903 "/forge/set/threshold",
7904 "threshold",
7905 "-72",
7906 "set:threshold=-72",
7907 ),
7908 ] {
7909 let one = FakeForge::with(Forging {
7910 how: Chop::Bpm,
7911 chosen: 3,
7912 ..forging()
7913 });
7914 forged(&one, posting(address, Params::new().with(name, value))).unwrap();
7915 assert_eq!(one.asked(), [expected]);
7916 }
7917
7918 // A name the description does not know is a refusal rather than a no-op.
7919 assert!(forged(&forge, Request::post("/forge/set/vibes")).is_err());
7920 assert!(forged(&forge, Request::post("/forge/slice/sideways")).is_err());
7921 }
7922
7923 #[test]
7924 fn the_plugin_foreshadow_is_not_described_because_it_is_not_a_screen() {
7925 // "Plugin processing (CLAP/VST): coming soon" is copy for something that
7926 // does not exist. A description of a screen should not carry one.
7927 let forge = FakeForge::with(forging());
7928 let said = deep_said(&forge_screen(&forge));
7929 assert!(!said.contains("CLAP"), "{said}");
7930 assert!(!said.contains("coming soon"), "{said}");
7931 }
7932
7933 // --- the migration strip ---
7934
7935 #[test]
7936 fn the_strip_is_a_band_of_the_window_that_is_there_while_the_job_runs() {
7937 // No `/storage` address and no capability of its own: it is a band, which
7938 // is where the shipped strip puts itself and why.
7939 let quiet = FakeShell::default();
7940 assert!(
7941 !shown(&quiet)
7942 .slots
7943 .iter()
7944 .any(|slot| slot.id == "shell-migration")
7945 );
7946
7947 let migrating = FakeShell {
7948 migrating: Some(Migrating {
7949 done: 40,
7950 total: 200,
7951 }),
7952 ..FakeShell::default()
7953 };
7954 let screen = shown(&migrating);
7955 let strip = screen
7956 .slots
7957 .iter()
7958 .find(|slot| slot.id == "shell-migration")
7959 .expect("the strip is a region of the window");
7960 assert_eq!(strip.kind, quasi_router::RegionKind::Band);
7961
7962 let said = deep_said(&screen);
7963 assert!(said.contains("Optimising storage layout"), "{said}");
7964 }
7965
7966 #[test]
7967 fn pausing_the_migration_says_on_the_control_that_it_resumes() {
7968 // Cancelling is honest here in a way it usually is not, and that belongs on
7969 // the thing that does it rather than near it.
7970 let migrating = FakeShell {
7971 migrating: Some(Migrating {
7972 done: 40,
7973 total: 200,
7974 }),
7975 ..FakeShell::default()
7976 };
7977 let screen = shown(&migrating);
7978 let pause = deep_acts(&screen)
7979 .into_iter()
7980 .find(|act| act.label == "Pause")
7981 .expect("Pause is offered");
7982 assert!(
7983 pause
7984 .confirm
7985 .as_deref()
7986 .is_some_and(|asked| asked.contains("resumes the next time this vault opens")),
7987 "{:?}",
7988 pause.confirm
7989 );
7990
7991 showing(&migrating, Request::post("/storage/pause")).unwrap();
7992 assert_eq!(*migrating.asked.borrow(), ["pause"]);
7993
7994 // And pausing nothing is a refusal: the address is reachable by typing.
7995 let quiet = FakeShell::default();
7996 assert!(showing(&quiet, Request::post("/storage/pause")).is_err());
7997 }
7998
7999 // --- the tag queue ---
8000
8001 /// Nothing is queued, and nothing can be accepted.
8002 ///
8003 /// [`Idle`], [`NoImport`] and [`Unforged`]'s fourth peer.
8004 struct Unqueued;
8005
8006 impl Queue for Unqueued {
8007 fn queued(&self) -> Option<Queued> {
8008 None
8009 }
8010 fn read(&self, _at: usize) {}
8011 fn tick(&self, _at: usize) {}
8012 fn tick_shown(&self, _ticked: bool) {}
8013 fn accept(&self, _scope: Scope) {}
8014 fn accept_confident(&self) {}
8015 fn dismiss(&self) {}
8016 fn rescan(&self) {}
8017 fn close(&self) {}
8018 }
8019
8020 /// Nothing is filtering, and every axis is open.
8021 ///
8022 /// [`Unqueued`]'s peer, and the last of them.
8023 struct Unfiltered;
8024
8025 impl Filters for Unfiltered {
8026 fn axes(&self) -> Vec<Narrowing> {
8027 open_axes()
8028 }
8029 fn keys(&self) -> Keys {
8030 Keys {
8031 wanted: Vec::new(),
8032 compatible: false,
8033 }
8034 }
8035 fn tags(&self) -> Vec<String> {
8036 Vec::new()
8037 }
8038 fn typing(&self) -> String {
8039 String::new()
8040 }
8041 fn matched(&self) -> usize {
8042 0
8043 }
8044 fn active(&self) -> bool {
8045 false
8046 }
8047 fn describes(&self) -> String {
8048 "Filters".to_owned()
8049 }
8050 fn narrow(&self, _key: &'static str, _lower: Option<f64>, _upper: Option<f64>) {}
8051 fn set_key_mode(&self, _compatible: bool) {}
8052 fn toggle_key(&self, _key: &str) {}
8053 fn clear_keys(&self) {}
8054 fn typed(&self, _text: &str) {}
8055 fn require(&self, _tag: &str) {}
8056 fn unrequire(&self, _tag: &str) {}
8057 fn clear_tags(&self) {}
8058 fn clear_all(&self) {}
8059 fn save_collection(&self, _name: &str) {}
8060 }
8061
8062 /// The six axes with neither end wanted.
8063 ///
8064 /// The shipped geometry table read rather than a second one written, which is
8065 /// what `FromFilters::table` does and is the point of the axes being `pub`.
8066 fn open_axes() -> Vec<Narrowing> {
8067 use crate::quasi::filters as axes;
8068 [
8069 ("bpm", &axes::BPM),
8070 ("duration", &axes::DURATION),
8071 ("loudness", &axes::LOUDNESS),
8072 ("brightness", &axes::BRIGHTNESS),
8073 ("noisiness", &axes::NOISINESS),
8074 ("attack", &axes::ATTACK),
8075 ]
8076 .into_iter()
8077 .map(|(key, axis)| Narrowing {
8078 key,
8079 axis,
8080 lower: None,
8081 upper: None,
8082 })
8083 .collect()
8084 }
8085
8086 /// Filters in memory, recording what was asked of them.
8087 struct FakeFilters {
8088 axes: RefCell<Vec<Narrowing>>,
8089 keys: RefCell<Keys>,
8090 tags: RefCell<Vec<String>>,
8091 typing: RefCell<String>,
8092 asked: RefCell<Vec<String>>,
8093 }
8094
8095 impl Default for FakeFilters {
8096 fn default() -> Self {
8097 Self {
8098 axes: RefCell::new(open_axes()),
8099 keys: RefCell::new(Keys {
8100 wanted: Vec::new(),
8101 compatible: false,
8102 }),
8103 tags: RefCell::new(Vec::new()),
8104 typing: RefCell::new(String::new()),
8105 asked: RefCell::new(Vec::new()),
8106 }
8107 }
8108 }
8109
8110 impl FakeFilters {
8111 fn say(&self, said: impl Into<String>) {
8112 self.asked.borrow_mut().push(said.into());
8113 }
8114
8115 fn asked(&self) -> Vec<String> {
8116 self.asked.borrow().clone()
8117 }
8118
8119 /// Narrow an axis up front, as a screen being re-read would find it.
8120 fn holding(self, key: &str, lower: Option<f64>, upper: Option<f64>) -> Self {
8121 for axis in self.axes.borrow_mut().iter_mut() {
8122 if axis.key == key {
8123 axis.lower = lower;
8124 axis.upper = upper;
8125 }
8126 }
8127 self
8128 }
8129 }
8130
8131 impl Filters for FakeFilters {
8132 fn axes(&self) -> Vec<Narrowing> {
8133 self.axes.borrow().clone()
8134 }
8135 fn keys(&self) -> Keys {
8136 self.keys.borrow().clone()
8137 }
8138 fn tags(&self) -> Vec<String> {
8139 self.tags.borrow().clone()
8140 }
8141 fn typing(&self) -> String {
8142 self.typing.borrow().clone()
8143 }
8144 fn matched(&self) -> usize {
8145 7
8146 }
8147 fn active(&self) -> bool {
8148 self.axes
8149 .borrow()
8150 .iter()
8151 .any(|axis| axis.lower.is_some() || axis.upper.is_some())
8152 || !self.tags.borrow().is_empty()
8153 || !self.keys.borrow().wanted.is_empty()
8154 }
8155 fn describes(&self) -> String {
8156 "BPM 90-130".to_owned()
8157 }
8158 fn narrow(&self, key: &'static str, lower: Option<f64>, upper: Option<f64>) {
8159 self.say(format!("narrow:{key}={lower:?}..{upper:?}"));
8160 }
8161 fn set_key_mode(&self, compatible: bool) {
8162 self.say(format!("mode:compatible={compatible}"));
8163 }
8164 fn toggle_key(&self, key: &str) {
8165 self.say(format!("key:{key}"));
8166 }
8167 fn clear_keys(&self) {
8168 self.say("keys:clear");
8169 }
8170 fn typed(&self, text: &str) {
8171 self.say(format!("typing:{text}"));
8172 *self.typing.borrow_mut() = text.to_owned();
8173 }
8174 fn require(&self, tag: &str) {
8175 self.say(format!("require:{tag}"));
8176 self.tags.borrow_mut().push(tag.to_owned());
8177 }
8178 fn unrequire(&self, tag: &str) {
8179 self.say(format!("unrequire:{tag}"));
8180 }
8181 fn clear_tags(&self) {
8182 self.say("tags:clear");
8183 }
8184 fn clear_all(&self) {
8185 self.say("clear");
8186 }
8187 fn save_collection(&self, name: &str) {
8188 self.say(format!("save:{name}"));
8189 }
8190 }
8191
8192 /// A router call against these filters.
8193 fn filtering(
8194 filters: &FakeFilters,
8195 request: Request,
8196 ) -> Result<Response, quasi_router::RouteError> {
8197 let store = Store::default();
8198 let sync = Offline;
8199 let files = FakeFiles::default();
8200 let themes = themes();
8201 let state = Panels {
8202 detail: &Unfocused,
8203 bulk: &Unchosen,
8204 shell: &Quiet,
8205 library: &Empty,
8206 bar: &Still,
8207 config: &store,
8208 sync: &sync,
8209 files: &files,
8210 export: &Idle,
8211 naming: &Unnamed,
8212 importing: &NoImport,
8213 integrity: &Sound,
8214 editor: &Unedited,
8215 forge: &Unforged,
8216 queue: &Unqueued,
8217 filters,
8218 themes: &themes,
8219 };
8220 router().handle(&state, request)
8221 }
8222
8223 /// The screen the filter panel answers.
8224 fn filter_screen(filters: &FakeFilters) -> Screen {
8225 screen_of(&filtering(filters, Request::get("/filters")).unwrap()).clone()
8226 }
8227
8228 /// A queue in memory, recording what was asked of it.
8229 struct FakeQueue {
8230 queued: Option<Queued>,
8231 asked: RefCell<Vec<String>>,
8232 }
8233
8234 impl FakeQueue {
8235 fn with(queued: Queued) -> Self {
8236 Self {
8237 queued: Some(queued),
8238 asked: RefCell::new(Vec::new()),
8239 }
8240 }
8241
8242 fn empty() -> Self {
8243 Self {
8244 queued: None,
8245 asked: RefCell::new(Vec::new()),
8246 }
8247 }
8248
8249 fn asked(&self) -> Vec<String> {
8250 self.asked.borrow().clone()
8251 }
8252
8253 fn say(&self, said: impl Into<String>) {
8254 self.asked.borrow_mut().push(said.into());
8255 }
8256 }
8257
8258 impl Queue for FakeQueue {
8259 fn queued(&self) -> Option<Queued> {
8260 self.queued.clone()
8261 }
8262
8263 fn read(&self, at: usize) {
8264 self.say(format!("read:{at}"));
8265 }
8266
8267 fn tick(&self, at: usize) {
8268 self.say(format!("tick:{at}"));
8269 }
8270
8271 fn tick_shown(&self, ticked: bool) {
8272 self.say(format!("tick:shown={ticked}"));
8273 }
8274
8275 fn accept(&self, scope: Scope) {
8276 self.say(format!("accept:{}", scope.as_str()));
8277 }
8278
8279 fn accept_confident(&self) {
8280 self.say("accept:everywhere");
8281 }
8282
8283 fn dismiss(&self) {
8284 self.say("dismiss");
8285 }
8286
8287 fn rescan(&self) {
8288 self.say("rescan");
8289 }
8290
8291 fn close(&self) {
8292 self.say("close");
8293 }
8294 }
8295
8296 /// One tag with something waiting under it.
8297 fn group(tag: &str, candidates: usize, confident: usize, checked: usize) -> Group {
8298 Group {
8299 tag: tag.to_owned(),
8300 candidates,
8301 confident,
8302 checked,
8303 }
8304 }
8305
8306 /// One candidate for the open tag.
8307 fn candidate(name: &str, score: f64, confident: bool, accepted: bool) -> Candidate {
8308 Candidate {
8309 name: name.to_owned(),
8310 score,
8311 confident,
8312 accepted,
8313 }
8314 }
8315
8316 /// A queue with two tags, the first one open.
8317 fn queued() -> Queued {
8318 Queued {
8319 groups: vec![group("drums/kick", 340, 120, 0), group("texture", 12, 0, 0)],
8320 at: 0,
8321 considered: 4_000,
8322 suggested: 352,
8323 confident: 120,
8324 rescanning: false,
8325 said: None,
8326 shown: vec![
8327 candidate("kick_01.wav", 0.94, true, false),
8328 candidate("kick_02.wav", 0.61, false, false),
8329 ],
8330 }
8331 }
8332
8333 /// A router call against this queue.
8334 fn queueing(queue: &FakeQueue, request: Request) -> Result<Response, quasi_router::RouteError> {
8335 let store = Store::default();
8336 let sync = Offline;
8337 let files = FakeFiles::default();
8338 let themes = themes();
8339 let state = Panels {
8340 config: &store,
8341 sync: &sync,
8342 files: &files,
8343 export: &Idle,
8344 detail: &Unfocused,
8345 bulk: &Unchosen,
8346 shell: &Quiet,
8347 library: &Empty,
8348 bar: &Still,
8349 naming: &Unnamed,
8350 importing: &NoImport,
8351 integrity: &Sound,
8352 editor: &Unedited,
8353 forge: &Unforged,
8354 queue,
8355 filters: &Unfiltered,
8356 themes: &themes,
8357 };
8358 router().handle(&state, request)
8359 }
8360
8361 /// The review screen, with whatever is queued.
8362 fn queue_screen(queue: &FakeQueue) -> Screen {
8363 screen_of(&queueing(queue, Request::get("/review")).unwrap()).clone()
8364 }
8365
8366 #[test]
8367 fn an_empty_queue_is_a_refusal_because_a_route_cannot_leave() {
8368 // The shipped screen closes itself rather than drawing an empty shell, so
8369 // "I finished" and "there was never anything" do not look the same. A route
8370 // answers what is at an address, and this address stopped being a place.
8371 let queue = FakeQueue::empty();
8372 assert!(queueing(&queue, Request::get("/review")).is_err());
8373 for address in [
8374 "/review/accept/all",
8375 "/review/accept-confident",
8376 "/review/dismiss",
8377 "/review/rescan",
8378 "/review/close",
8379 ] {
8380 assert!(
8381 queueing(&queue, Request::post(address)).is_err(),
8382 "{address}"
8383 );
8384 }
8385 }
8386
8387 #[test]
8388 fn the_promise_the_screen_rests_on_is_stated_on_the_screen() {
8389 let queue = FakeQueue::with(queued());
8390 let said = deep_said(&queue_screen(&queue));
8391 assert!(
8392 said.contains("Nothing is applied until you accept it."),
8393 "{said}"
8394 );
8395 // And what the pass actually found, so the queue-wide button is a decision
8396 // made with the count in view.
8397 assert!(said.contains("352 suggestions across 2 tags"), "{said}");
8398 assert!(said.contains("352 of 4000 samples"), "{said}");
8399 }
8400
8401 #[test]
8402 fn the_tag_is_the_unit_of_navigation_and_the_open_one_says_so() {
8403 // The design the shipped header argues for: 340 rows one at a time is 340
8404 // questions nobody finishes.
8405 let queue = FakeQueue::with(queued());
8406 let screen = queue_screen(&queue);
8407
8408 let tags = screen
8409 .slots
8410 .iter()
8411 .find(|slot| slot.id == "review-split")
8412 .expect("the screen is a split");
8413 assert_eq!(tags.kind, quasi_router::RegionKind::Split);
8414
8415 let rows = deep_rows(&screen);
8416 let open = rows
8417 .iter()
8418 .find(|row| row.primary() == "drums/kick")
8419 .expect("the open tag is listed");
8420 assert!(open.current, "the open tag reads as open");
8421 assert!(
8422 rows.iter()
8423 .find(|row| row.primary() == "texture")
8424 .is_some_and(|row| !row.current)
8425 );
8426
8427 queueing(&queue, Request::post("/review/1/read")).unwrap();
8428 assert_eq!(queue.asked(), ["read:1"]);
8429 assert!(queueing(&queue, Request::post("/review/9/read")).is_err());
8430 }
8431
8432 #[test]
8433 fn the_three_accept_scopes_are_offered_only_where_they_mean_something() {
8434 // "Accept confident" beside "Accept all" when every candidate is confident
8435 // is a second button that does what the first one does, and "Accept 0
8436 // checked" is a control that reports having done nothing.
8437 let queue = FakeQueue::with(queued());
8438 let labels = deep_labels(&queue_screen(&queue));
8439 assert!(labels.contains(&"Accept all 340".to_owned()), "{labels:?}");
8440 assert!(
8441 labels.contains(&"Accept 120 confident".to_owned()),
8442 "{labels:?}"
8443 );
8444 assert!(
8445 !labels.iter().any(|label| label.ends_with("checked")),
8446 "{labels:?}"
8447 );
8448
8449 let all_confident = FakeQueue::with(Queued {
8450 groups: vec![group("drums/kick", 340, 340, 3)],
8451 ..queued()
8452 });
8453 let labels = deep_labels(&queue_screen(&all_confident));
8454 assert!(
8455 !labels.contains(&"Accept 340 confident".to_owned()),
8456 "a subset of everything is not a subset: {labels:?}"
8457 );
8458 assert!(
8459 labels.contains(&"Accept 3 checked".to_owned()),
8460 "{labels:?}"
8461 );
8462
8463 // And each address refuses an empty scope, so a control the reader could
8464 // still reach by typing is refused where the button was hidden.
8465 assert!(queueing(&queue, Request::post("/review/accept/checked")).is_err());
8466 queueing(&queue, Request::post("/review/accept/confident")).unwrap();
8467 assert_eq!(queue.asked(), ["accept:confident"]);
8468 assert!(queueing(&queue, Request::post("/review/accept/most")).is_err());
8469 }
8470
8471 #[test]
8472 fn the_window_over_a_group_is_a_fact_about_the_data() {
8473 // `files` refused to describe its own windowing because it holds every row.
8474 // This one does not: a name is a backend call each, so the rows past the
8475 // window were never resolved, and `Rest` is what says so.
8476 let queue = FakeQueue::with(queued());
8477 let more = deep_nodes(&queue_screen(&queue))
8478 .into_iter()
8479 .find_map(|node| match node {
8480 Node::List { more, .. } => more,
8481 _ => None,
8482 })
8483 .expect("the candidate list says what it is not showing");
8484 assert_eq!(more.paging.total(), Some(340));
8485 // No way to widen it: the buttons act on the whole group, which is the
8486 // point of the cap.
8487 assert!(more.forward.is_none());
8488 assert!(more.back.is_none());
8489
8490 // A group that fits says nothing, because there is nothing to say.
8491 let small = FakeQueue::with(Queued {
8492 groups: vec![group("texture", 2, 0, 0)],
8493 at: 0,
8494 ..queued()
8495 });
8496 assert!(
8497 deep_nodes(&queue_screen(&small))
8498 .into_iter()
8499 .all(|node| !matches!(node, Node::List { more: Some(_), .. }))
8500 );
8501 }
8502
8503 #[test]
8504 fn nothing_arrives_ticked_and_ticking_is_bounded_to_what_is_drawn() {
8505 // A screen opening with 340 boxes checked is auto-apply wearing a checkbox,
8506 // and ticking 44,000 invisible ones would make "Accept checked" silently
8507 // mean "accept everything".
8508 let queue = FakeQueue::with(queued());
8509 let screen = queue_screen(&queue);
8510 let candidates: Vec<quasi_router::Row> = deep_rows(&screen)
8511 .into_iter()
8512 .filter(|row| row.selected.is_some())
8513 .collect();
8514 assert_eq!(candidates.len(), 2);
8515 assert!(candidates.iter().all(|row| row.selected == Some(false)));
8516
8517 let labels = deep_labels(&screen);
8518 assert!(labels.contains(&"Check all shown".to_owned()), "{labels:?}");
8519 // Uncheck appears only once something is ticked.
8520 assert!(
8521 !labels.contains(&"Uncheck all shown".to_owned()),
8522 "{labels:?}"
8523 );
8524
8525 queueing(&queue, Request::post("/review/rows/check")).unwrap();
8526 assert_eq!(queue.asked(), ["tick:shown=true"]);
8527 assert!(queueing(&queue, Request::post("/review/rows/7/tick")).is_err());
8528 }
8529
8530 #[test]
8531 fn the_two_destructive_gestures_ask_on_the_controls_that_do_them() {
8532 let queue = FakeQueue::with(queued());
8533 let screen = queue_screen(&queue);
8534
8535 let dismiss = deep_acts(&screen)
8536 .into_iter()
8537 .find(|act| act.label == "Dismiss tag")
8538 .expect("Dismiss is offered");
8539 assert_eq!(dismiss.tone, quasi_router::layout::Tone::Danger);
8540 assert!(
8541 dismiss
8542 .confirm
8543 .as_deref()
8544 .is_some_and(|asked| asked.contains("340 suggestions")),
8545 "{:?}",
8546 dismiss.confirm
8547 );
8548
8549 let everywhere = deep_acts(&screen)
8550 .into_iter()
8551 .find(|act| {
8552 act.label == "Accept 120 confident"
8553 && act.action.destination.as_str().contains("accept-confident")
8554 })
8555 .expect("the queue-wide accept is offered");
8556 assert!(
8557 everywhere
8558 .confirm
8559 .as_deref()
8560 .is_some_and(|asked| asked.contains("across every tag")),
8561 "{:?}",
8562 everywhere.confirm
8563 );
8564 }
8565
8566 #[test]
8567 fn rescanning_is_refused_while_a_pass_is_running() {
8568 let running = FakeQueue::with(Queued {
8569 rescanning: true,
8570 ..queued()
8571 });
8572 let screen = queue_screen(&running);
8573 let again = deep_acts(&screen)
8574 .into_iter()
8575 .find(|act| act.label == "Rescan library")
8576 .expect("Rescan is offered");
8577 assert!(!again.interactive());
8578 assert!(deep_said(&screen).contains("A pass is running."));
8579 assert!(queueing(&running, Request::post("/review/rescan")).is_err());
8580
8581 let idle = FakeQueue::with(queued());
8582 queueing(&idle, Request::post("/review/rescan")).unwrap();
8583 assert_eq!(idle.asked(), ["rescan"]);
8584 }
8585
8586 #[test]
8587 fn what_the_last_accept_did_is_reported_rather_than_described() {
8588 let queue = FakeQueue::with(Queued {
8589 said: Some("Applied 120 tags.".to_owned()),
8590 ..queued()
8591 });
8592 let notices: Vec<String> = deep_nodes(&queue_screen(&queue))
8593 .into_iter()
8594 .filter_map(|node| match node {
8595 Node::Notice { text, .. } => Some(text),
8596 _ => None,
8597 })
8598 .collect();
8599 assert_eq!(notices, ["Applied 120 tags."]);
8600 }
8601
8602 // ---------------------------------------------------------------------------
8603 // The filter panel. The sixteenth port and the first consumer of
8604 // `FieldKind::Interval`, so what these cover is mostly the pair: that the six
8605 // axes are six questions rather than twelve, that both ends travel together,
8606 // and that the sentinel edges survive the round trip in both directions.
8607 // ---------------------------------------------------------------------------
8608
8609 /// The intervals on the screen, by the name their lower end submits under.
8610 fn intervals(
8611 screen: &Screen,
8612 ) -> BTreeMap<String, (Option<String>, Option<String>, Option<String>)> {
8613 let mut found = BTreeMap::new();
8614 for slot in &screen.slots {
8615 for placed in &slot.body {
8616 let regions = match &placed.node {
8617 Node::Region(region) => std::slice::from_ref(region),
8618 _ => &[][..],
8619 };
8620 for region in regions {
8621 for inner in &region.body {
8622 if let Node::Form { fields, .. } = &inner.node {
8623 for field in fields {
8624 if field.kind == quasi_router::layout::FieldKind::Interval {
8625 found.insert(
8626 field.name.clone(),
8627 (
8628 field.upper_name.clone(),
8629 field.value.clone(),
8630 field.upper_value.clone(),
8631 ),
8632 );
8633 }
8634 }
8635 }
8636 }
8637 }
8638 }
8639 }
8640 found
8641 }
8642
8643 #[test]
8644 fn the_six_axes_are_six_questions_and_not_twelve() {
8645 // The whole reason this port waited for makeover-layout 0.34.0. Described
8646 // as `Number` pairs these are twelve fields with no relationship, and the
8647 // shipped panel's `range_filter_section` is the 55 lines that stood in for
8648 // the missing member.
8649 let filters = FakeFilters::default();
8650 let screen = filter_screen(&filters);
8651 let axes = intervals(&screen);
8652
8653 assert_eq!(axes.len(), 6, "{axes:?}");
8654 for (lower, upper) in [
8655 ("bpm_min", "bpm_max"),
8656 ("duration_min", "duration_max"),
8657 ("loudness_min", "loudness_max"),
8658 ("brightness_min", "brightness_max"),
8659 ("noisiness_min", "noisiness_max"),
8660 ("attack_min", "attack_max"),
8661 ] {
8662 let (named, _, _) = axes.get(lower).unwrap_or_else(|| panic!("{lower}"));
8663 assert_eq!(named.as_deref(), Some(upper), "{lower}");
8664 }
8665 }
8666
8667 #[test]
8668 fn an_axis_carries_the_shipped_geometry_rather_than_a_second_table() {
8669 // The six axes are a constant the shipped panel already reduced them to,
8670 // and a table here would drift the way the class filter's list and colour
8671 // table drifted before that reduction.
8672 let filters = FakeFilters::default();
8673 let screen = filter_screen(&filters);
8674 let bpm = axis_field(&screen, "bpm_min");
8675
8676 assert_eq!(bpm.min.as_deref(), Some("0"));
8677 assert_eq!(bpm.max.as_deref(), Some("300"));
8678 assert_eq!(bpm.label, crate::quasi::filters::BPM.title);
8679 // Two of the six have no unit, and an empty suffix is not one.
8680 assert_eq!(bpm.unit, None);
8681
8682 let loudness = axis_field(&screen, "loudness_min");
8683 assert_eq!(loudness.min.as_deref(), Some("-96"));
8684 // The suffix carried a leading space because it was going into a DragValue.
8685 // `Field::unit` is the symbol alone.
8686 assert_eq!(loudness.unit.as_deref(), Some("dB"));
8687
8688 let attack = axis_field(&screen, "attack_min");
8689 assert_eq!(attack.unit.as_deref(), Some("s"));
8690 // Written at the axis's own precision, which is what the shipped control
8691 // was given as `decimals`.
8692 assert_eq!(attack.max.as_deref(), Some("1.000"));
8693 }
8694
8695 /// One axis's field, by the name its lower end submits under.
8696 fn axis_field(screen: &Screen, name: &str) -> quasi_router::Field {
8697 for slot in &screen.slots {
8698 for placed in &slot.body {
8699 if let Node::Region(region) = &placed.node {
8700 for inner in &region.body {
8701 if let Node::Form { fields, .. } = &inner.node {
8702 for field in fields {
8703 if field.name == name {
8704 return field.clone();
8705 }
8706 }
8707 }
8708 }
8709 }
8710 }
8711 }
8712 panic!("no axis named {name}");
8713 }
8714
8715 #[test]
8716 fn a_narrowed_axis_comes_back_with_both_ends_in_it() {
8717 let filters = FakeFilters::default().holding("bpm", Some(90.0), Some(130.0));
8718 let screen = filter_screen(&filters);
8719 let axes = intervals(&screen);
8720
8721 let (_, lower, upper) = axes.get("bpm_min").expect("the axis");
8722 assert_eq!(lower.as_deref(), Some("90"));
8723 assert_eq!(upper.as_deref(), Some("130"));
8724 }
8725
8726 #[test]
8727 fn an_open_end_is_an_empty_box_rather_than_the_edge() {
8728 // The sentinel mapping, read the other way. A stored `None` is no bound,
8729 // and a box holding the axis floor would read as a filter for "at least 0".
8730 let filters = FakeFilters::default().holding("bpm", Some(120.0), None);
8731 let screen = filter_screen(&filters);
8732 let axes = intervals(&screen);
8733
8734 let (_, lower, upper) = axes.get("bpm_min").expect("the axis");
8735 assert_eq!(lower.as_deref(), Some("120"));
8736 assert_eq!(*upper, None);
8737 }
8738
8739 #[test]
8740 fn narrowing_sends_both_ends_and_maps_the_edges_back_to_no_bound() {
8741 // An interval is one answer: a handler taking only the end that moved would
8742 // drop the other bound every time either box was touched. And an end
8743 // sitting on its sentinel edge stores nothing, which is `range_bounds`'s
8744 // rule said once here.
8745 let filters = FakeFilters::default();
8746 filtering(
8747 &filters,
8748 Request::post("/filters/axis/bpm").sending(
8749 Params::new()
8750 .with("bpm_min".to_owned(), "90".to_owned())
8751 .with("bpm_max".to_owned(), "300".to_owned()),
8752 ),
8753 )
8754 .expect("the route answered");
8755
8756 assert_eq!(filters.asked(), ["narrow:bpm=Some(90.0)..None"]);
8757 }
8758
8759 #[test]
8760 fn a_crossed_interval_is_snapped_rather_than_refused() {
8761 // The shipped helper's sibling snap, which stays a write: the description
8762 // carries the extent and never checks it, and a description that could snap
8763 // could rewrite an answer without being asked.
8764 let filters = FakeFilters::default();
8765 filtering(
8766 &filters,
8767 Request::post("/filters/axis/bpm").sending(
8768 Params::new()
8769 .with("bpm_min".to_owned(), "140".to_owned())
8770 .with("bpm_max".to_owned(), "90".to_owned()),
8771 ),
8772 )
8773 .expect("the route answered");
8774
8775 assert_eq!(filters.asked(), ["narrow:bpm=Some(140.0)..Some(140.0)"]);
8776 }
8777
8778 #[test]
8779 fn an_axis_nobody_has_touched_offers_no_clear() {
8780 // The shipped section's own gate: a clear on an untouched axis is a control
8781 // that does nothing and says the axis is doing something.
8782 let open = FakeFilters::default();
8783 let narrowed = FakeFilters::default().holding("bpm", Some(90.0), None);
8784
8785 assert!(!filter_acts(&filter_screen(&open)).contains(&"/filters/axis/bpm/clear".to_owned()));
8786 assert!(filter_acts(&filter_screen(&narrowed)).contains(&"/filters/axis/bpm/clear".to_owned()));
8787 }
8788
8789 /// Every address the screen's acts call, regions included.
8790 fn filter_acts(screen: &Screen) -> Vec<String> {
8791 let mut found = Vec::new();
8792 let mut visit = |node: &Node| {
8793 if let Node::Act(act) = node
8794 && let quasi_router::Destination::Route(path) = &act.action.destination
8795 {
8796 found.push(path.clone());
8797 }
8798 };
8799 for slot in &screen.slots {
8800 for placed in &slot.body {
8801 visit(&placed.node);
8802 if let Node::Region(region) = &placed.node {
8803 for inner in &region.body {
8804 visit(&inner.node);
8805 }
8806 }
8807 }
8808 }
8809 found
8810 }
8811
8812 #[test]
8813 fn a_tag_that_is_not_one_is_refused_onto_the_field_it_was_typed_into() {
8814 // The shipped panel writes the complaint to the status line, which is the
8815 // affordance this vocabulary keeps moving messages off.
8816 let filters = FakeFilters::default();
8817 let refused = filtering(
8818 &filters,
8819 Request::post("/filters/tags/add")
8820 .sending(Params::new().with("tag".to_owned(), "not a tag!".to_owned())),
8821 );
8822
8823 assert!(refused.is_err(), "{refused:?}");
8824 assert!(filters.asked().is_empty(), "{:?}", filters.asked());
8825 }
8826
8827 #[test]
8828 fn adding_a_tag_empties_the_box_on_the_way_through() {
8829 // The half a bare `require` would lose, and it is the shipped path's own
8830 // behaviour rather than a nicety added here.
8831 let filters = FakeFilters::default();
8832 filtering(
8833 &filters,
8834 Request::post("/filters/tags/add")
8835 .sending(Params::new().with("tag".to_owned(), " kick ".to_owned())),
8836 )
8837 .expect("the route answered");
8838
8839 assert_eq!(filters.asked(), ["require:kick", "typing:"]);
8840 }
8841
8842 #[test]
8843 fn saving_with_no_name_takes_the_one_the_ghost_text_offered() {
8844 // An empty name is not a refusal: the box's ghost text *is* the name it
8845 // would take, which is the shipped control's own arrangement.
8846 let filters = FakeFilters::default().holding("bpm", Some(90.0), Some(130.0));
8847 filtering(
8848 &filters,
8849 Request::post("/filters/save")
8850 .sending(Params::new().with("name".to_owned(), " ".to_owned())),
8851 )
8852 .expect("the route answered");
8853
8854 assert_eq!(filters.asked(), ["save:BPM 90-130"]);
8855 }
8856
8857 #[test]
8858 fn nothing_is_saved_or_cleared_while_nothing_is_filtering() {
8859 let filters = FakeFilters::default();
8860 assert!(filtering(&filters, Request::post("/filters/save")).is_err());
8861 assert!(filtering(&filters, Request::post("/filters/clear")).is_err());
8862 assert!(filters.asked().is_empty(), "{:?}", filters.asked());
8863 }
8864
8865 #[test]
8866 fn a_key_the_library_does_not_spell_is_refused() {
8867 let filters = FakeFilters::default();
8868 assert!(filtering(&filters, Request::post("/filters/keys/H%20major/toggle")).is_err());
8869 filtering(&filters, Request::post("/filters/keys/C# minor/toggle"))
8870 .expect("the route answered");
8871 assert_eq!(filters.asked(), ["key:C# minor"]);
8872 }
8873