Skip to main content

max / quasi

160.6 KB · 4517 lines History Blame Raw
1 //! What egui draws from a description.
2 //!
3 //! Assertions are over what came back rather than over pixels: egui's test
4 //! harness lays a real `Ui` out and answers real `Response`s, so what a renderer
5 //! owes is that pressing a described control produces the described request.
6 //! What it looks like is the palette's answer and changes with it.
7
8 use egui::Color32;
9 use makeover_immediate::Palette;
10 use quasi_router::{
11 Act, Action, Address, Chrome, Consult, Field, Frame, Message, Method, Node, Outcome,
12 RegionKind, Request, Response, Row, Run, Screen, Slot, layout,
13 };
14
15 use crate::view::Asking;
16 use crate::{Immediate, Runtime, Step, View, runtime::types};
17
18 fn palette() -> Palette {
19 Palette {
20 page: Color32::from_rgb(1, 1, 1),
21 raised: Color32::from_rgb(2, 2, 2),
22 overlay: Color32::from_rgb(3, 3, 3),
23 well: Color32::from_rgb(4, 4, 4),
24 sunken: Color32::from_rgb(5, 5, 5),
25 bevel_light: Color32::WHITE,
26 bevel_dark: Color32::BLACK,
27 elevation: Color32::from_black_alpha(46),
28 content: Color32::from_rgb(6, 6, 6),
29 content_secondary: Color32::from_rgb(66, 66, 66),
30 content_muted: Color32::from_rgb(7, 7, 7),
31 action: Color32::from_rgb(8, 8, 8),
32 danger: Color32::from_rgb(9, 9, 9),
33 success: Color32::from_rgb(10, 10, 10),
34 warning: Color32::from_rgb(11, 11, 11),
35 info: Color32::from_rgb(12, 12, 12),
36 }
37 }
38
39 fn renderer() -> Immediate {
40 Immediate::new(palette())
41 }
42
43 /// A screen with one region holding these nodes.
44 fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen {
45 Screen::sidebar_content("Test").with(
46 nodes
47 .into_iter()
48 .fold(Slot::new("main", RegionKind::Pane), Slot::with),
49 )
50 }
51
52 /// Draw a screen once, with nothing pressed.
53 fn draw(screen: &Screen, view: &mut View) {
54 let immediate = renderer();
55 egui::__run_test_ui(|ui| {
56 immediate.screen(ui, screen, view);
57 });
58 }
59
60 /// One of every `Node` member, in declaration order.
61 ///
62 /// The list this test asserts against, kept as a function so more than one test
63 /// can walk it. It has to stay complete: it is the only thing standing between a
64 /// member added upstream and a renderer that draws less than it describes,
65 /// because the compiler's half of that guarantee has been defeated once already.
66 fn one_of_everything() -> Vec<Node> {
67 vec![
68 Node::page("Tasks"),
69 Node::text("plain"),
70 Node::rich("**bold** and `code`"),
71 Node::Act(Act::new("Save", Action::post("/save"))),
72 Node::Link {
73 text: "Docs".to_owned(),
74 action: Action::get("/docs"),
75 },
76 Node::Figure(quasi_router::Figure::new("17", "Streak")),
77 Node::since(std::time::SystemTime::UNIX_EPOCH),
78 Node::until(std::time::SystemTime::UNIX_EPOCH),
79 Node::age(std::time::SystemTime::UNIX_EPOCH),
80 Node::Image(quasi_router::Image::new("/cover.png", "The library view")),
81 Node::Token(quasi_router::Tag::badge("beta")),
82 Node::banner(layout::Tone::Info, "Saved"),
83 Node::empty("Nothing here yet"),
84 Node::Field(Box::new(Field::new(
85 layout::FieldKind::Text,
86 "title",
87 "Title",
88 ))),
89 Node::Form {
90 marks: ::quasi_router::stage::Marks::none(),
91 action: Action::post("/save"),
92 submit: "Save".to_owned(),
93 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
94 },
95 Node::list([quasi_router::Row::new("One")]),
96 Node::Table {
97 marks: ::quasi_router::stage::Marks::none(),
98 columns: vec![quasi_router::Column::new("Name")],
99 rows: vec![quasi_router::Row::cells([quasi_router::Cell::new("One")])],
100 more: None,
101 },
102 Node::Timeline {
103 marks: ::quasi_router::stage::Marks::none(),
104 track: layout::Track::DAY,
105 entries: vec![quasi_router::Placed::new(
106 540,
107 45,
108 quasi_router::Row::new("Standup"),
109 )],
110 focus: Some(540),
111 },
112 Node::Meter(quasi_router::Meter::new(3, 6)),
113 Node::stats([quasi_router::Figure::new("17", "Streak")]),
114 Node::Region(Slot::new("nested", RegionKind::Pane)),
115 ]
116 }
117
118 #[test]
119 fn every_described_node_draws_without_panicking() {
120 // The walk is exhaustive over `Node`, so this is the assertion that the
121 // exhaustiveness is real rather than a match that compiles: one of
122 // everything, drawn.
123 //
124 // It was neither, until 2026-08-15. `draw`'s last arm was a wildcard
125 // forwarding to `container`, and this list was nine members short, so
126 // `Node::Image` (0.4.0) and `Node::Timeline` (0.6.0) arrived, compiled and
127 // panicked on `container`'s `unreachable!` with nothing objecting. Both
128 // halves are fixed and the list is complete; keeping it complete is what
129 // this test is for.
130 let mut view = View::new();
131 let screen = screen_of(one_of_everything());
132 draw(&screen, &mut view);
133 }
134
135 #[test]
136 fn the_exhaustiveness_list_holds_one_of_every_member() {
137 // A count rather than a comment. `Node` cannot be iterated, so nothing but
138 // this stops the list above going stale the way it did through two releases.
139 //
140 // `Node` took `#[non_exhaustive]` in 0.10.0, which makes this the only
141 // guard rather than the second one: a member added upstream now lands on
142 // `draw`'s catch-all and compiles, so the count is what says the list has
143 // not learned it yet.
144 assert_eq!(
145 one_of_everything().len(),
146 21,
147 "one of every `Node` member, in declaration order"
148 );
149 }
150
151 #[test]
152 fn a_picture_draws_at_its_own_proportions_and_never_wider_than_the_box() {
153 // The dimensions are the whole of what holds a picture's place: without
154 // them the box is empty until the texture lands and then shoves everything
155 // below it down the screen. Never scaled up, since a 5120-wide screenshot
156 // is not asking for a 5120-wide window.
157 let mut view = View::new();
158 let picture = quasi_router::Image::new("/wide.png", "A wide screenshot")
159 .intrinsic(5120, 2560)
160 .caption("The library view");
161 draw(&screen_of([Node::Image(picture)]), &mut view);
162
163 // A decorative picture says nothing, so nothing stands in for it. The
164 // difference between an empty `alt` and a missing one is a claim, and this
165 // is the arm that reads it.
166 draw(
167 &screen_of([Node::Image(quasi_router::Image::new("/rule.png", ""))]),
168 &mut view,
169 );
170 }
171
172 #[test]
173 fn a_track_draws_whatever_unit_it_counts() {
174 // The geometry is unit-agnostic and was correct while the ruler printed
175 // `00:00` over a month strip, which is the defect `layout::Unit` closed.
176 // Both units draw here so a day strip is exercised rather than assumed.
177 let mut view = View::new();
178 let day = Node::Timeline {
179 marks: ::quasi_router::stage::Marks::none(),
180 track: layout::Track::DAY,
181 entries: vec![
182 quasi_router::Placed::new(540, 45, quasi_router::Row::new("Standup")),
183 // Overlapping, so the lane packing runs rather than sitting at one
184 // lane for every entry.
185 quasi_router::Placed::new(555, 60, quasi_router::Row::new("Review")),
186 // Past the end of the span, which `Track::fraction` clamps rather
187 // than drawing off the axis: an event running past midnight is a
188 // real thing.
189 quasi_router::Placed::new(1380, 180, quasi_router::Row::new("Late")),
190 ],
191 focus: Some(540),
192 };
193 let strip = Node::Timeline {
194 marks: ::quasi_router::stage::Marks::none(),
195 track: layout::Track::days(layout::Span::new(0, 31)),
196 entries: vec![quasi_router::Placed::new(
197 3,
198 5,
199 quasi_router::Row::new("Leave"),
200 )],
201 focus: None,
202 };
203 draw(&screen_of([day, strip]), &mut view);
204 }
205
206 #[test]
207 fn a_track_with_no_ticks_still_draws() {
208 // `Track::tick` of zero means an unlabelled axis, and a `slot` of zero
209 // reads as one slot spanning the whole thing rather than a division by
210 // zero. Both are documented upstream and both reach arithmetic here.
211 let mut view = View::new();
212 let node = Node::Timeline {
213 marks: ::quasi_router::stage::Marks::none(),
214 track: layout::Track {
215 span: layout::Span::DAY,
216 slot: 0,
217 tick: 0,
218 unit: layout::Unit::Minutes,
219 },
220 entries: vec![quasi_router::Placed::new(
221 0,
222 1,
223 quasi_router::Row::new("All day"),
224 )],
225 focus: None,
226 };
227 draw(&screen_of([node]), &mut view);
228 }
229
230 #[test]
231 fn a_screen_with_no_press_asks_for_nothing() {
232 // egui redraws continuously, so the ordinary frame is a user doing nothing.
233 // A renderer answering a request per frame would call the router sixty
234 // times a second.
235 let immediate = renderer();
236 let screen = screen_of([Node::Act(Act::new("Save", Action::post("/save")))]);
237 let mut view = View::new();
238 egui::__run_test_ui(|ui| {
239 assert!(immediate.screen(ui, &screen, &mut view).is_none());
240 });
241 }
242
243 #[test]
244 fn what_is_typed_lives_in_the_view_and_not_in_the_description() {
245 // The one thing egui does not hold for this renderer: a described field is
246 // rebuilt every frame, so the buffer behind it has to outlive the frame.
247 let mut view = View::new();
248 view.set("title", "hello");
249 assert_eq!(view.showing("title", Some("described")), "hello");
250 // Untouched reads the description; cleared does not.
251 assert_eq!(view.showing("other", Some("described")), "described");
252 view.set("other", "");
253 assert_eq!(view.showing("other", Some("described")), "");
254 }
255
256 #[test]
257 fn a_form_submits_every_name_it_declared() {
258 // A form that omits an untouched field is a form that cannot clear one.
259 let mut view = View::new();
260 view.set("title", "typed");
261 let described = [("body".to_owned(), "offered".to_owned())]
262 .into_iter()
263 .collect();
264 let params = view.submission(
265 &["title".to_owned(), "body".to_owned(), "empty".to_owned()],
266 &described,
267 );
268 assert_eq!(params.get("title"), Some("typed"));
269 assert_eq!(params.get("body"), Some("offered"));
270 assert_eq!(params.get("empty"), Some(""));
271 }
272
273 #[test]
274 fn a_tick_is_the_views_and_the_description_only_seeds_it() {
275 // After arrival the user's ticks are the truth, which is why seeding is
276 // applied once rather than read on every draw.
277 let mut row = quasi_router::Row::new("One");
278 row.selected = Some(true);
279 row.value = Some("1".to_owned());
280 let screen = screen_of([Node::list([row])]);
281
282 let mut view = View::new();
283 view.seed(&screen);
284 assert!(view.is_ticked("1"));
285 view.tick("1");
286 assert!(
287 !view.is_ticked("1"),
288 "the user untocked it and it stayed off"
289 );
290 }
291
292 #[test]
293 fn a_fresh_runtime_has_nowhere_to_reload_from() {
294 // Built from a screen rather than from an address, so there is no request
295 // behind the opening screen to ask again. Idle rather than a guess.
296 let runtime = Runtime::new(screen_of([Node::text("opening")]));
297 assert_eq!(runtime.here(), None);
298 assert_eq!(runtime.reload(), Step::Idle);
299 }
300
301 #[test]
302 fn reload_asks_for_the_screen_showing_now() {
303 let mut runtime = Runtime::new(screen_of([Node::text("opening")]));
304 runtime.apply(
305 &Request::get("/export"),
306 Response {
307 outcome: Outcome::Screen(screen_of([Node::text("configuring")])),
308 notice: None,
309 address: None,
310 invalidates: Vec::new(),
311 },
312 );
313
314 assert_eq!(runtime.reload(), Step::Call(Request::get("/export")));
315 }
316
317 #[test]
318 fn reloading_repeatedly_does_not_pile_up_history() {
319 // The failure this guards: `remember` pushes where you were whenever a read
320 // answers a screen, and a reload is a read answering the screen you are on.
321 // Without the guard, a host refreshing a progress screen every frame builds
322 // a history stack of that same screen and `back` walks through it.
323 let mut runtime = Runtime::new(screen_of([Node::text("opening")]));
324 runtime.apply(
325 &Request::get("/one"),
326 Response {
327 outcome: Outcome::Screen(screen_of([Node::text("one")])),
328 notice: None,
329 address: None,
330 invalidates: Vec::new(),
331 },
332 );
333 runtime.apply(
334 &Request::get("/two"),
335 Response {
336 outcome: Outcome::Screen(screen_of([Node::text("two")])),
337 notice: None,
338 address: None,
339 invalidates: Vec::new(),
340 },
341 );
342
343 for _ in 0..5 {
344 let Step::Call(request) = runtime.reload() else {
345 panic!("a screen that was navigated to can be asked for again");
346 };
347 runtime.apply(
348 &request,
349 Response {
350 outcome: Outcome::Screen(screen_of([Node::text("two, again")])),
351 notice: None,
352 address: None,
353 invalidates: Vec::new(),
354 },
355 );
356 }
357
358 // One step back is /one, and the next has nowhere to go: the five reloads
359 // left history exactly as the two navigations did.
360 assert_eq!(runtime.back(), Step::Call(Request::get("/one")));
361 assert_eq!(runtime.back(), Step::Idle);
362 }
363
364 #[test]
365 fn a_reload_keeps_what_the_user_is_in_the_middle_of_typing() {
366 // The failure this guards, and it is the one that decides whether `reload`
367 // is usable at all: a refresh goes through `Outcome::Screen`, which resets
368 // the view on arrival. A host reloading a form every frame would clear the
369 // box under the caret sixty times a second.
370 let mut runtime = Runtime::new(screen_of([Node::text("opening")]));
371 let form = || {
372 screen_of([Node::Field(Box::new(Field::new(
373 layout::FieldKind::Text,
374 "naming-pattern",
375 "Naming pattern",
376 )))])
377 };
378 runtime.apply(
379 &Request::get("/export"),
380 Response {
381 outcome: Outcome::Screen(form()),
382 notice: None,
383 address: None,
384 invalidates: Vec::new(),
385 },
386 );
387
388 runtime.view_mut().set("naming-pattern", "{name}-{bpm}");
389
390 // Five refreshes, which is what a host reloading every frame does.
391 for _ in 0..5 {
392 let Step::Call(request) = runtime.reload() else {
393 panic!("a screen that was navigated to can be asked for again");
394 };
395 runtime.apply(
396 &request,
397 Response {
398 outcome: Outcome::Screen(form()),
399 notice: None,
400 address: None,
401 invalidates: Vec::new(),
402 },
403 );
404 }
405 assert_eq!(
406 runtime.view().edit("naming-pattern"),
407 Some("{name}-{bpm}"),
408 "a refresh is not an arrival, so it does not clear the box being typed into"
409 );
410
411 // Going somewhere else is a different matter, and still clears.
412 runtime.apply(
413 &Request::get("/settings"),
414 Response {
415 outcome: Outcome::Screen(screen_of([Node::text("elsewhere")])),
416 notice: None,
417 address: None,
418 invalidates: Vec::new(),
419 },
420 );
421 assert_eq!(runtime.view().edit("naming-pattern"), None);
422 }
423
424 #[test]
425 fn a_reload_does_not_put_back_a_tick_the_user_took_off() {
426 // `View::seed` is arrival behaviour by its own documentation -- "after this
427 // the user's ticks are the truth" -- so a refresh must not run it. Otherwise
428 // unticking a row that the description says is ticked lasts exactly until
429 // the next reload.
430 let ticked = || {
431 let mut row = quasi_router::Row::new("One");
432 row.selected = Some(true);
433 row.value = Some("1".to_owned());
434 screen_of([Node::list([row])])
435 };
436
437 let mut runtime = Runtime::new(screen_of([Node::text("opening")]));
438 runtime.apply(
439 &Request::get("/files"),
440 Response {
441 outcome: Outcome::Screen(ticked()),
442 notice: None,
443 address: None,
444 invalidates: Vec::new(),
445 },
446 );
447
448 // Arrival seeded it, which is the behaviour being distinguished from.
449 assert!(runtime.view().is_ticked("1"));
450 runtime.view_mut().tick("1");
451
452 let Step::Call(request) = runtime.reload() else {
453 panic!("a screen that was navigated to can be asked for again");
454 };
455 runtime.apply(
456 &request,
457 Response {
458 outcome: Outcome::Screen(ticked()),
459 notice: None,
460 address: None,
461 invalidates: Vec::new(),
462 },
463 );
464 assert!(
465 !runtime.view().is_ticked("1"),
466 "the user took it off and a refresh is not an arrival"
467 );
468 }
469
470 #[test]
471 fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() {
472 let mut runtime = Runtime::new(screen_of([Node::text("underneath")]));
473 // Two navigations, because history holds where you *were*: the first
474 // records where we are and the second pushes it behind us.
475 for path in ["/two", "/three"] {
476 runtime.apply(
477 &Request::get(path),
478 Response {
479 outcome: Outcome::Screen(screen_of([Node::text("a place")])),
480 notice: None,
481 address: None,
482 invalidates: Vec::new(),
483 },
484 );
485 }
486
487 runtime.apply(
488 &Request::get("/palette"),
489 Response {
490 outcome: Outcome::Over(screen_of([Node::text("palette")])),
491 notice: None,
492 address: None,
493 invalidates: Vec::new(),
494 },
495 );
496 assert!(runtime.overlaid());
497 assert_eq!(runtime.screen().title, "Test");
498
499 // Dismissing reveals rather than navigates, so history is untouched and
500 // still has somewhere to go afterwards.
501 assert!(matches!(runtime.back(), Step::Call(_)));
502 }
503
504 #[test]
505 fn a_navigation_takes_the_overlay_with_it() {
506 // Arriving somewhere new with a palette still floating over it is the state
507 // nobody asked for.
508 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
509 runtime.apply(
510 &Request::get("/palette"),
511 Response {
512 outcome: Outcome::Over(screen_of([Node::text("palette")])),
513 notice: None,
514 address: None,
515 invalidates: Vec::new(),
516 },
517 );
518 assert!(runtime.overlaid());
519 runtime.apply(
520 &Request::get("/two"),
521 Response {
522 outcome: Outcome::Screen(screen_of([Node::text("second")])),
523 notice: None,
524 address: None,
525 invalidates: Vec::new(),
526 },
527 );
528 assert!(!runtime.overlaid());
529 }
530
531 #[test]
532 fn a_write_is_not_a_place_and_a_read_of_a_screen_is() {
533 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
534 for path in ["/two", "/three"] {
535 runtime.apply(
536 &Request::get(path),
537 Response {
538 outcome: Outcome::Screen(screen_of([Node::text("place")])),
539 notice: None,
540 address: None,
541 invalidates: Vec::new(),
542 },
543 );
544 }
545 // A write answering a screen is not somewhere to come back to.
546 runtime.apply(
547 &Request::post("/save"),
548 Response {
549 outcome: Outcome::Screen(screen_of([Node::text("saved")])),
550 notice: None,
551 address: None,
552 invalidates: Vec::new(),
553 },
554 );
555 match runtime.back() {
556 Step::Call(request) => assert_eq!(request.path, "/two"),
557 other => panic!("expected the place behind, got {other:?}"),
558 }
559 }
560
561 #[test]
562 fn an_address_the_router_named_overrides_the_derivation() {
563 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
564 runtime.apply(
565 &Request::get("/one"),
566 Response {
567 outcome: Outcome::Screen(screen_of([Node::text("one")])),
568 notice: None,
569 address: None,
570 invalidates: Vec::new(),
571 },
572 );
573 // `Unchanged` says this read is not a place, so nothing is pushed behind it.
574 runtime.apply(
575 &Request::get("/transient"),
576 Response {
577 outcome: Outcome::Screen(screen_of([Node::text("transient")])),
578 notice: None,
579 address: Some(Address::Unchanged),
580 invalidates: Vec::new(),
581 },
582 );
583 assert!(matches!(runtime.back(), Step::Idle));
584 }
585
586 #[test]
587 fn a_fragment_naming_a_region_that_is_not_there_says_so() {
588 // A terminal and a window can both say it, where a webview swallows it:
589 // drawing nothing would look like a control that does nothing.
590 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
591 runtime.apply(
592 &Request::get("/x"),
593 Response {
594 outcome: Outcome::Fragment {
595 region: "nowhere".to_owned(),
596 node: Node::text("new"),
597 },
598 notice: None,
599 address: None,
600 invalidates: Vec::new(),
601 },
602 );
603 let said = runtime
604 .screen()
605 .notices
606 .iter()
607 .any(|node| matches!(node, Node::Notice { text, .. } if text.contains("nowhere")));
608 assert!(said, "the missing region was not reported");
609 }
610
611 #[test]
612 fn what_a_response_says_lands_on_the_screen_it_belongs_to() {
613 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
614 runtime.apply(
615 &Request::post("/save"),
616 Response {
617 outcome: Outcome::Screen(screen_of([Node::text("second")])),
618 notice: Some(Message {
619 kind: layout::Notice::Toast,
620 tone: layout::Tone::Success,
621 text: "Saved".to_owned(),
622 undo: None,
623 }),
624 address: None,
625 invalidates: Vec::new(),
626 },
627 );
628 assert!(
629 runtime
630 .screen()
631 .notices
632 .iter()
633 .any(|node| matches!(node, Node::Notice { text, .. } if text == "Saved")),
634 "the notice did not arrive with the screen it belongs to"
635 );
636 }
637
638 #[test]
639 fn the_way_back_a_response_offered_survives_the_conversion() {
640 // `bde35298`. This host keeps a screen and converts a `Message` into a
641 // `Node::Notice`, so it is the one that has to carry the undo across;
642 // until the node grew an act it dropped it, exactly as quasi-tui did.
643 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
644 runtime.apply(
645 &Request::post("/tasks/7/delete"),
646 Response {
647 outcome: Outcome::Screen(screen_of([Node::text("second")])),
648 notice: Some(Message {
649 kind: layout::Notice::Toast,
650 tone: layout::Tone::Success,
651 text: "Deleted".to_owned(),
652 undo: Some(Action::post("/tasks/7/restore")),
653 }),
654 address: None,
655 invalidates: Vec::new(),
656 },
657 );
658
659 let notice = runtime.screen().notices.first().expect("a notice arrived");
660 let Node::Notice { act: Some(act), .. } = notice else {
661 panic!("the undo did not survive: {notice:?}");
662 };
663 assert_eq!(act.label, Message::UNDO);
664 assert_eq!(act.action.route(), Some("/tasks/7/restore"));
665 }
666
667 #[test]
668 fn pressing_a_notices_undo_calls_the_route_the_response_named() {
669 // Drawn and pressable, which is the half the conversion alone does not
670 // buy: a notice was a leaf here, drawn through a walk with no `Pass` to
671 // fire through, so an act on one had to move it out of that walk.
672 let mut host = Host::new();
673 let screen = screen_of([Node::text("here")]).saying(
674 Node::toast(layout::Tone::Success, "Deleted").about(quasi_router::Act::new(
675 "Undo",
676 Action::post("/tasks/7/restore"),
677 )),
678 );
679 host.settle(&screen);
680
681 assert!(on_screen(&host, "Deleted"), "the notice is not drawn");
682 let fired = host.click(&screen, "Undo");
683 assert_eq!(
684 fired.and_then(|fired| fired.action.route().map(str::to_owned)),
685 Some("/tasks/7/restore".to_owned()),
686 "the undo is on the screen and does nothing"
687 );
688 }
689
690 /// Press the control painted with this label, on a runtime, and answer its step.
691 ///
692 /// `Host` drives the renderer and this drives the `Runtime` around it, which is
693 /// the difference that matters for a destination the runtime performs rather
694 /// than reports: the fired action never leaves the runtime, so a test that
695 /// stopped at `Fired` would assert what was described rather than what happened.
696 ///
697 /// The first frame is drawn only to find out where the label landed, which is
698 /// `Host::find`'s trick with the runtime holding the screen.
699 fn runtime_press(runtime: &mut Runtime, immediate: &Immediate, label: &str) -> Step {
700 let ctx = egui::Context::default();
701 let input = |events: Vec<egui::Event>| egui::RawInput {
702 screen_rect: Some(egui::Rect::from_min_size(
703 egui::Pos2::ZERO,
704 egui::vec2(900.0, 700.0),
705 )),
706 events,
707 ..Default::default()
708 };
709
710 let mut found = None;
711 let output = ctx.clone().run_ui(input(Vec::new()), |ui| {
712 runtime.show(ui, immediate);
713 });
714 fn walk(shape: &egui::Shape, text: &str, found: &mut Option<egui::Rect>) {
715 match shape {
716 egui::Shape::Text(t) if t.galley.job.text.contains(text) => {
717 *found = Some(egui::Rect::from_min_size(t.pos, t.galley.size()));
718 }
719 egui::Shape::Vec(shapes) => {
720 for shape in shapes {
721 walk(shape, text, found);
722 }
723 }
724 _ => {}
725 }
726 }
727 for clipped in &output.shapes {
728 walk(&clipped.shape, label, &mut found);
729 }
730 let pos = found
731 .unwrap_or_else(|| panic!("nothing painted {label:?}; the press has nowhere to land"))
732 .center();
733
734 let press = |pressed| egui::Event::PointerButton {
735 pos,
736 button: egui::PointerButton::Primary,
737 pressed,
738 modifiers: egui::Modifiers::NONE,
739 };
740 let mut step = Step::Idle;
741 for events in [
742 vec![egui::Event::PointerMoved(pos)],
743 vec![egui::Event::PointerMoved(pos), press(true)],
744 vec![egui::Event::PointerMoved(pos), press(false)],
745 ] {
746 let mut this = Step::Idle;
747 let _ = ctx.clone().run_ui(input(events), |ui| {
748 this = runtime.show(ui, immediate);
749 });
750 if !matches!(this, Step::Idle) {
751 step = this;
752 }
753 }
754 step
755 }
756
757 #[test]
758 fn a_control_that_goes_back_asks_for_where_the_reader_came_from() {
759 // `33c27e81`. The address is this runtime's history and not anything the
760 // description could have named, which is the whole reason it is a
761 // destination rather than a path some screen computes.
762 let mut runtime = Runtime::new(screen_of([Node::text("the list")]));
763 runtime.apply(
764 &Request::get("/tasks"),
765 Response::screen(screen_of([Node::text("the list")])),
766 );
767 runtime.apply(
768 &Request::get("/settings"),
769 Response::screen(screen_of([Node::Act(Act::new("Close", Action::back()))])),
770 );
771
772 assert_eq!(
773 runtime_press(&mut runtime, &renderer(), "Close"),
774 Step::Call(Request::get("/tasks")),
775 "back did not ask for the place before this one"
776 );
777 }
778
779 #[test]
780 fn back_is_not_local_and_is_not_somewhere_outside() {
781 // The two guards it has to be handled before. `Local` says no request is
782 // made at all and this makes one; and `route()` is `None` here too, so the
783 // outside-the-app guard would hand the host an empty address to open.
784 let action = Action::back();
785 assert!(action.destination.is_back());
786 assert!(!action.destination.is_local());
787 assert!(action.destination.route().is_none());
788 assert_eq!(action.destination.as_str(), "");
789 }
790
791 #[test]
792 fn back_from_the_first_screen_goes_nowhere_rather_than_somewhere_wrong() {
793 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Close", Action::back()))]));
794 assert_eq!(
795 runtime_press(&mut runtime, &renderer(), "Close"),
796 Step::Idle,
797 "the first screen is not somewhere you arrived at"
798 );
799 }
800
801 /// A rule editor's conditions: slots of one repeating question.
802 ///
803 /// audiofiles' shape, cut down, and this is its host. Each condition is a
804 /// region of several fields, which is what `Repeat` could not say.
805 fn conditions(standing: usize, least: usize) -> Screen {
806 let mut group = Slot::new("conditions", RegionKind::Group).repeating(
807 quasi_router::Repeating::new(
808 "Condition",
809 Act::new("Add condition", Action::post("/rules/conditions/add")),
810 )
811 .least(least),
812 );
813 for at in 0..standing {
814 group = group.with(Node::Region(
815 Slot::new(format!("condition-{at}"), RegionKind::Group)
816 .with(Node::text(format!("condition {at}")))
817 .removes(Act::new(
818 "Remove condition",
819 Action::post(format!("/rules/conditions/{at}/remove")),
820 )),
821 ));
822 }
823 Screen::sidebar_content("Rules")
824 .with(Slot::new("main", RegionKind::Pane).with(Node::Region(group)))
825 }
826
827 #[test]
828 fn the_slots_of_a_repeating_question_are_numbered_for_a_reader() {
829 // `f7abbc08`. One-based, because it is read by a person, and the renderer's
830 // rather than the description's: numbers written into a description go
831 // stale the moment a slot leaves the middle.
832 let mut host = Host::new();
833 host.settle(&conditions(2, 1));
834
835 assert!(
836 on_screen(&host, "Condition 1"),
837 "the slots are not numbered"
838 );
839 assert!(
840 on_screen(&host, "Condition 2"),
841 "the slots are not numbered"
842 );
843 assert!(
844 !on_screen(&host, "Condition 0"),
845 "the numbering is zero-based"
846 );
847 assert!(on_screen(&host, "Add condition"), "nothing adds a slot");
848 assert!(on_screen(&host, "Remove condition"), "nothing removes one");
849 }
850
851 #[test]
852 fn the_floor_stops_the_last_slot_going_rather_than_the_app_doing_it() {
853 // The done condition of the whole member: "at least one condition" is the
854 // description's now, and the last Remove is drawn dead rather than hidden.
855 let alone = conditions(1, 1);
856 let mut host = Host::new();
857 host.settle(&alone);
858 assert!(
859 on_screen(&host, "Remove condition"),
860 "the boundary hid the control instead of disabling it"
861 );
862 assert!(
863 host.click(&alone, "Remove condition").is_none(),
864 "the last slot could be removed"
865 );
866
867 // One more standing, and it fires.
868 let pair = conditions(2, 1);
869 let mut host = Host::new();
870 host.settle(&pair);
871 // The second slot's, because `Host::find` takes the last painting of a
872 // label and both controls are called the same thing -- which they are in
873 // the app too. Which one it is matters less than that it carries its own
874 // slot's address: that is `Slot::removes` being the child's and not one
875 // action on the parent with an index bolted to it.
876 assert_eq!(
877 host.click(&pair, "Remove condition")
878 .and_then(|fired| fired.action.route().map(str::to_owned)),
879 Some("/rules/conditions/1/remove".to_owned()),
880 "a slot above the floor could not be removed"
881 );
882 }
883
884 #[test]
885 fn a_readers_value_survives_a_fragment_because_the_view_holds_it() {
886 // `a135f898` says the webview has to be told this and that egui was already
887 // right. Confirmed rather than changed: what is typed lives in the `View`
888 // under the field's name, and a fragment replaces a region rather than the
889 // buffer.
890 let field = || Field::new(layout::FieldKind::Text, "tag", "Tag").keeping_value();
891 let mut runtime = Runtime::new(
892 Screen::sidebar_content("Discover")
893 .with(Slot::new("side", RegionKind::Sidebar).with(Node::field(field()))),
894 );
895 runtime.view_mut().set("tag", "dru");
896
897 runtime.apply(
898 &Request::post("/discover/facet"),
899 Response::from(Outcome::Fragment {
900 region: "side".to_owned(),
901 node: Node::field(field()),
902 }),
903 );
904
905 assert_eq!(
906 runtime.view().edit("tag"),
907 Some("dru"),
908 "the reader's value was thrown away by a fragment"
909 );
910 }
911
912 #[test]
913 fn an_external_destination_is_handed_back_to_the_host() {
914 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
915 let step = runtime.answer(false);
916 assert!(
917 matches!(step, Step::Idle),
918 "an unasked question does nothing"
919 );
920
921 // The question a destructive control raises, answered both ways.
922 let mut runtime = Runtime::new(screen_of([Node::Act(
923 Act::new("Delete", Action::post("/delete")).confirm("Sure?"),
924 )]));
925 assert!(matches!(runtime.answer(true), Step::Idle));
926 }
927
928 #[test]
929 fn a_local_action_is_not_an_address_handed_to_the_host() {
930 // `210574ca`, and this is the renderer the ruling was reasoned from: egui
931 // redraws from memory every frame, so a local behaviour is what it already
932 // does and the mark tells it nothing. What it must not do is read "no
933 // route" as "somewhere outside" and hand the host an empty address, which
934 // is what the branch below `send`'s new guard would have done.
935 //
936 // Driven through a chrome binding because that is the one path into `send`
937 // a test can take without a pointer: a key needs no painted rectangle to
938 // land on.
939 let chrome = Chrome::new().bind("ctrl+k", "Dismiss", Action::local());
940 let mut runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome);
941 let immediate = renderer();
942
943 let ctx = egui::Context::default();
944 let input = egui::RawInput {
945 screen_rect: Some(egui::Rect::from_min_size(
946 egui::Pos2::ZERO,
947 egui::vec2(900.0, 700.0),
948 )),
949 events: vec![egui::Event::Key {
950 key: egui::Key::K,
951 physical_key: None,
952 pressed: true,
953 repeat: false,
954 modifiers: egui::Modifiers::CTRL,
955 }],
956 modifiers: egui::Modifiers::CTRL,
957 ..Default::default()
958 };
959 let mut step = None;
960 let _ = ctx.run_ui(input, |ui| {
961 step = Some(runtime.show(ui, &immediate));
962 });
963
964 // Not `Step::Open("")`, which is the host being asked to open the empty
965 // address, and not a call: there is no route on a local destination.
966 assert!(
967 matches!(step, Some(Step::Idle)),
968 "a local action produced {step:?} rather than doing nothing"
969 );
970
971 // The control, and it is what makes the assertion above mean anything: the
972 // same key on the same harness with an external destination must reach the
973 // host. Without this, a binding that never fired would pass as `Idle`.
974 let chrome = Chrome::new().bind(
975 "ctrl+k",
976 "Docs",
977 Action::external("https://example.invalid"),
978 );
979 let mut runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome);
980 let ctx = egui::Context::default();
981 let input = egui::RawInput {
982 screen_rect: Some(egui::Rect::from_min_size(
983 egui::Pos2::ZERO,
984 egui::vec2(900.0, 700.0),
985 )),
986 events: vec![egui::Event::Key {
987 key: egui::Key::K,
988 physical_key: None,
989 pressed: true,
990 repeat: false,
991 modifiers: egui::Modifiers::CTRL,
992 }],
993 modifiers: egui::Modifiers::CTRL,
994 ..Default::default()
995 };
996 let mut step = None;
997 let _ = ctx.run_ui(input, |ui| {
998 step = Some(runtime.show(ui, &immediate));
999 });
1000 assert_eq!(
1001 step,
1002 Some(Step::Open("https://example.invalid".to_string())),
1003 "the harness never delivered the key, so the local assertion proved nothing"
1004 );
1005
1006 // And the class this renderer declares, which is why ignoring the mark
1007 // beyond that is allowed rather than an omission.
1008 assert_eq!(crate::CLASS, quasi_router::Renderer::Client);
1009 assert!(!crate::CLASS.reads_locality());
1010 }
1011
1012 #[test]
1013 fn a_chrome_binding_this_renderer_cannot_read_is_ignored_rather_than_guessed() {
1014 // The same rule `Act::key` states: the vocabulary of keys is the host's, and
1015 // a name this one does not know never matches.
1016 let chrome = Chrome::new()
1017 .bind("ctrl+k", "Search", Action::get("/palette"))
1018 .bind("dpad-left", "Nope", Action::get("/nope"));
1019 let runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome);
1020 // Nothing is pressed in a test context, so this asserts the parse rather
1021 // than the press: an unreadable name must not panic the frame.
1022 let immediate = renderer();
1023 let mut runtime = runtime;
1024 egui::__run_test_ui(|ui| {
1025 assert!(matches!(runtime.show(ui, &immediate), Step::Idle));
1026 });
1027 }
1028
1029 #[test]
1030 fn a_method_survives_the_trip_from_description_to_request() {
1031 // What a control carries is what the router is asked for. The regression
1032 // this guards is a renderer that turns every press into a GET.
1033 let immediate = renderer();
1034 let screen = screen_of([Node::Act(Act::new("Delete", Action::post("/delete")))]);
1035 let mut view = View::new();
1036 egui::__run_test_ui(|ui| {
1037 // Not pressed, so nothing fires; the assertion is that drawing a write
1038 // control does not itself produce a request.
1039 assert!(immediate.screen(ui, &screen, &mut view).is_none());
1040 });
1041 assert_eq!(Action::post("/delete").method, Method::Post);
1042 }
1043
1044 #[test]
1045 fn a_selection_is_gathered_under_the_name_the_screen_gave_it() {
1046 // The commit half of a staged tick: the runtime reads the set the view is
1047 // holding and sends it with the call.
1048 let mut view = View::new();
1049 view.tick("1");
1050 view.tick("2");
1051 let params = view.gathering(Node::TICKED);
1052 let sent: Vec<&str> = params.get_all(Node::TICKED).collect();
1053 assert_eq!(sent, ["1", "2"]);
1054 }
1055
1056 #[test]
1057 fn the_host_can_say_something_no_handler_knows_about() {
1058 // A route that failed has to land somewhere the user is looking. For a
1059 // windowed app stderr is nowhere.
1060 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
1061 runtime.say("The library is not reachable.");
1062 assert!(
1063 runtime.screen().notices.iter().any(
1064 |node| matches!(node, Node::Notice { text, .. } if text.contains("not reachable"))
1065 ),
1066 "the host had nowhere to put it"
1067 );
1068 }
1069
1070 #[test]
1071 fn a_described_table_draws_its_columns_and_cells() {
1072 // The node this renderer declined to draw until 2026-08-14. The narrowing
1073 // and the tracks are makeover-immediate's; what is asserted here is that a
1074 // described table reaches them at all, with a cell holding an ordinary node.
1075 use quasi_router::{Cell, Column};
1076
1077 let columns = vec![Column::new("name"), Column::new("bpm")];
1078 let rows = vec![
1079 Row::cells(["kick.wav", "120"]),
1080 // A cell holding a control rather than a value, which is what
1081 // `CellPart` exists to separate and what a file list actually has.
1082 Row::cells([
1083 Cell::new("snare.wav"),
1084 Cell::acts([Act::new("Play", Action::post("/play"))]),
1085 ]),
1086 ];
1087
1088 let mut view = View::new();
1089 let screen = screen_of([Node::Table {
1090 marks: ::quasi_router::stage::Marks::none(),
1091 columns,
1092 rows,
1093 more: None,
1094 }]);
1095 draw(&screen, &mut view);
1096 }
1097
1098 /// What this file cannot assert, so that the next reader does not spend the
1099 /// afternoon finding out.
1100 ///
1101 /// **The right-click itself is not testable here.** This crate takes egui with
1102 /// `default-features = false`, so no font is loaded and every string measures
1103 /// zero points wide: a `Response` covers no pixel, nothing is ever hovered,
1104 /// and `Response::context_menu` never opens however faithfully the events are
1105 /// injected.
1106 ///
1107 /// So a menu's *gesture* is asserted where a gesture can be: `quasi-tui`, where
1108 /// reach is this renderer's own walk and a key press is a value. What is left
1109 /// here is that a described menu draws, that it fires nothing unpressed, and
1110 /// that the rect it hangs on is this row's rather than the list's -- which is
1111 /// the half a font would not have caught either way.
1112 ///
1113 /// The gesture half is covered now: see "Pressing things" below, which is what
1114 /// a dev-only `default_fonts` bought. This one stays because "drawing a menu
1115 /// does not open it" is a different claim from "pressing it does".
1116 #[test]
1117 fn a_row_that_offers_a_menu_draws_and_fires_nothing_unpressed() {
1118 use quasi_router::Row;
1119
1120 let screen = screen_of([Node::list([
1121 Row::new("kick.wav")
1122 .offers(Act::new("Preview", Action::post("/files/1/play")))
1123 .offers(
1124 Act::new("Delete", Action::post("/files/1/delete")).confirm("Delete kick.wav?"),
1125 ),
1126 // And a row beside it that offers nothing, so the interact rect is
1127 // claimed for one row and not the other.
1128 Row::new("snare.wav"),
1129 ])]);
1130
1131 let immediate = renderer();
1132 let mut view = View::new();
1133 egui::__run_test_ui(|ui| {
1134 assert!(
1135 immediate.screen(ui, &screen, &mut view).is_none(),
1136 "drawing a menu is not opening one"
1137 );
1138 });
1139 }
1140
1141 #[test]
1142 fn a_table_row_that_offers_a_menu_draws_and_fires_nothing_unpressed() {
1143 // The table half, and the one that matters most: audiofiles' file list is a
1144 // `Node::Table` and this renderer is what draws it. The collecting slot the
1145 // menu uses is separate from the one `activate` uses, so this also covers
1146 // the case where a row carries both.
1147 use quasi_router::Column;
1148
1149 let screen = screen_of([Node::Table {
1150 marks: ::quasi_router::stage::Marks::none(),
1151 columns: vec![Column::new("Name"), Column::new("BPM")],
1152 rows: vec![
1153 Row::cells(["kick.wav", "120"])
1154 .activate(Action::post("/files/1/open"))
1155 .offers(Act::new("Preview", Action::post("/files/1/play"))),
1156 Row::cells(["snare.wav", "140"]),
1157 ],
1158 more: None,
1159 }]);
1160
1161 let immediate = renderer();
1162 let mut view = View::new();
1163 egui::__run_test_ui(|ui| {
1164 assert!(
1165 immediate.screen(ui, &screen, &mut view).is_none(),
1166 "drawing a menu is not opening one"
1167 );
1168 });
1169 }
1170
1171 #[test]
1172 fn a_commit_control_is_inert_until_something_is_ticked() {
1173 // The two things a description cannot say about a selection, said here
1174 // because this renderer holds the set: how many are in it, and that a
1175 // control over none of them should not fire. Drawn rather than hidden, so
1176 // the affordance stays on screen and a reader learns bulk actions exist.
1177 use quasi_router::Column;
1178
1179 let screen = Screen::sidebar_content("Tasks").selecting("chosen").with(
1180 Slot::new("main", RegionKind::Pane)
1181 .with(Node::Table {
1182 marks: ::quasi_router::stage::Marks::none(),
1183 columns: vec![Column::new("title")],
1184 rows: vec![Row::cells(["First"]).ticking("t-1", false)],
1185 more: None,
1186 })
1187 .with(Node::Act(
1188 Act::new("Complete", Action::post("/tasks/complete")).over("chosen"),
1189 )),
1190 );
1191
1192 // Nothing ticked: drawing it fires nothing, whatever is clicked.
1193 let mut view = View::new();
1194 draw(&screen, &mut view);
1195
1196 // With one ticked the control carries the count it would act on.
1197 view.tick("t-1");
1198 draw(&screen, &mut view);
1199 assert!(view.is_ticked("t-1"));
1200 }
1201
1202 #[test]
1203 fn a_tickable_table_draws_a_column_the_description_did_not_name() {
1204 // A tick takes no column in the description -- it does not narrow, sort or
1205 // carry data -- and `makeover_immediate::table` addresses cells by column,
1206 // so this renderer adds one. The assertion is that the added column does
1207 // not disturb the described ones: a two-column table with ticks still
1208 // reaches both of its own cells.
1209 use quasi_router::Column;
1210
1211 let screen = screen_of([Node::Table {
1212 marks: ::quasi_router::stage::Marks::none(),
1213 columns: vec![Column::new("name"), Column::new("bpm")],
1214 rows: vec![
1215 Row::cells(["kick.wav", "120"]).ticking("k", false),
1216 Row::cells(["snare.wav", "140"]).ticking("s", true),
1217 ],
1218 more: None,
1219 }]);
1220
1221 let mut view = View::new();
1222 draw(&screen, &mut view);
1223 }
1224
1225 #[test]
1226 fn a_table_with_no_columns_draws_nothing_rather_than_panicking() {
1227 // `egui_extras` panics on a table with no tracks, and a description with no
1228 // columns is reachable. makeover-immediate answers `None` for that case and
1229 // this is the assertion that the described path takes it.
1230 let mut view = View::new();
1231 let screen = screen_of([Node::Table {
1232 marks: ::quasi_router::stage::Marks::none(),
1233 columns: Vec::new(),
1234 rows: Vec::new(),
1235 more: None,
1236 }]);
1237 draw(&screen, &mut view);
1238 }
1239
1240 /// One of every shape that could plausibly cache a size.
1241 ///
1242 /// A field at each [`layout::Width`], a table with mixed
1243 /// [`layout::Priority`], a list that says how much more there is, and a nested
1244 /// region. Written out here rather than shared with the other two renderers'
1245 /// copies: the fixture is a few lines, and sharing it would mean a new public
1246 /// surface on a crate for the sake of a test.
1247 fn every_shape_that_could_cache() -> Screen {
1248 Screen::sidebar_content("Any width").with(
1249 Slot::new("main", RegionKind::Pane)
1250 .with(Node::Field(Box::new(Field::new(
1251 layout::FieldKind::Text,
1252 "wide",
1253 "Wide",
1254 ))))
1255 .with(Node::Field(Box::new(
1256 Field::new(layout::FieldKind::Text, "tight", "Tight").width(layout::Width::Content),
1257 )))
1258 .with(Node::Field(Box::new(
1259 Field::new(layout::FieldKind::Text, "held", "Held").width(layout::Width::Fixed),
1260 )))
1261 .with(Node::Table {
1262 marks: ::quasi_router::stage::Marks::none(),
1263 columns: vec![
1264 quasi_router::Column::new("Name").priority(layout::Priority::Essential),
1265 quasi_router::Column::new("Kind").priority(layout::Priority::Secondary),
1266 quasi_router::Column::new("Added").priority(layout::Priority::Optional),
1267 ],
1268 rows: vec![quasi_router::Row::cells([
1269 "kick.wav",
1270 "sample",
1271 "2026-08-12",
1272 ])],
1273 more: Some(quasi_router::Rest::more(1, Action::get("/samples?from=1"))),
1274 })
1275 .with(Node::Table {
1276 marks: ::quasi_router::stage::Marks::none(),
1277 columns: Vec::new(),
1278 rows: vec![quasi_router::Row::new("one"), quasi_router::Row::new("two")],
1279 more: Some(quasi_router::Rest::more(2, Action::get("/rows?from=2"))),
1280 })
1281 .with(Node::Region(
1282 Slot::group("nested").with(Node::text("inside")),
1283 )),
1284 )
1285 }
1286
1287 /// Everything the renderer put on the screen at this width, as text.
1288 ///
1289 /// Through a real `Context` rather than `__run_test_ui`, because the width is
1290 /// the whole subject here and the test helper picks its own. The shapes are
1291 /// compared by their debug form: what is being asserted is that two frames are
1292 /// the same picture, and equality of the primitives is the strongest available
1293 /// statement of that.
1294 fn painted(
1295 ctx: &egui::Context,
1296 immediate: &Immediate,
1297 screen: &Screen,
1298 view: &mut View,
1299 width: f32,
1300 ) -> String {
1301 let input = egui::RawInput {
1302 screen_rect: Some(egui::Rect::from_min_size(
1303 egui::Pos2::ZERO,
1304 egui::vec2(width, 900.0),
1305 )),
1306 ..Default::default()
1307 };
1308 let output = ctx.run_ui(input, |ctx| {
1309 egui::CentralPanel::default().show(ctx, |ui| {
1310 immediate.screen(ui, screen, view);
1311 });
1312 });
1313 format!("{:?}", output.shapes)
1314 }
1315
1316 #[test]
1317 fn a_run_is_capped_at_the_lines_its_flow_allows() {
1318 use quasi_router::Row;
1319
1320 // The cap, settled by `7bfb554a`. Before it a long part in a row grew to as
1321 // many rows as the words needed, which is a block's behaviour in a place
1322 // that is a line.
1323 let long = "a headline long enough that it certainly will not fit across a narrow pane";
1324 let ctx = egui::Context::default();
1325 let immediate = renderer();
1326
1327 let tight = painted(
1328 &ctx,
1329 &immediate,
1330 &screen_of([Node::list([Row::new(long)])]),
1331 &mut View::new(),
1332 200.0,
1333 );
1334 let relaxed = painted(
1335 &ctx,
1336 &immediate,
1337 &screen_of([Node::list([Row::new(long).relaxed()])]),
1338 &mut View::new(),
1339 200.0,
1340 );
1341
1342 // Elided in both, because a cap is not a promise of room, and the ellipsis
1343 // is what tells a reader the rest is there.
1344 assert!(tight.contains('\u{2026}'), "{tight}");
1345 assert!(relaxed.contains('\u{2026}'), "{relaxed}");
1346 // Two rows carry more of the headline than one, and the picture says so.
1347 assert!(relaxed.len() > tight.len(), "{relaxed}");
1348 }
1349
1350 #[test]
1351 fn a_long_part_leaves_room_for_what_follows_it() {
1352 use quasi_router::{Row, Tag};
1353
1354 // The defect the budget fixes. `capped` gave the first text leaf the whole
1355 // available width, so the badge and the count after it started past the
1356 // right edge and egui clipped them: gone with no ellipsis and no sign they
1357 // had ever been there.
1358 let long = "a headline long enough that it certainly will not fit across this pane";
1359 let ctx = egui::Context::default();
1360 let immediate = renderer();
1361
1362 let out = painted(
1363 &ctx,
1364 &immediate,
1365 &screen_of([Node::list([Row::new(long)
1366 .token(Tag::badge("beta"))
1367 .meta("2 files")])]),
1368 &mut View::new(),
1369 320.0,
1370 );
1371
1372 assert!(out.contains("beta"), "{out}");
1373 assert!(out.contains("2 files"), "{out}");
1374 // The headline is what gave way, which is the trade: it elides, they do not
1375 // vanish.
1376 assert!(out.contains('\u{2026}'), "{out}");
1377 }
1378
1379 #[test]
1380 fn a_budget_that_cannot_fit_the_tail_is_not_taken() {
1381 use quasi_router::Row;
1382
1383 // quasi-tui's rule from the other end. If the reservation is wider than the
1384 // pane, eliding the headline buys room for parts that are still past the
1385 // edge, so the headline keeps the width and the tail is lost either way.
1386 let long = "a headline long enough that it certainly will not fit across this pane";
1387 let ctx = egui::Context::default();
1388 let immediate = renderer();
1389
1390 let narrow = painted(
1391 &ctx,
1392 &immediate,
1393 &screen_of([Node::list([
1394 Row::new(long).meta("a trailing fact that is itself far too wide for the pane")
1395 ])]),
1396 &mut View::new(),
1397 120.0,
1398 );
1399 let alone = painted(
1400 &ctx,
1401 &immediate,
1402 &screen_of([Node::list([Row::new(long)])]),
1403 &mut View::new(),
1404 120.0,
1405 );
1406
1407 // The headline is laid out the same either way: the tail took nothing from
1408 // it, because there was nothing to take that would have helped.
1409 assert!(narrow.contains(&headline_of(&alone)), "{narrow}");
1410 }
1411
1412 /// The first galley in a painted frame, as its debug form.
1413 ///
1414 /// Comparing whole frames would compare the tail too, and the tail is the thing
1415 /// that differs; what is being asserted is that the *headline* was laid out the
1416 /// same, which is the part a budget would have changed.
1417 fn headline_of(painted: &str) -> String {
1418 let start = painted.find("Galley").expect("a galley");
1419 painted[start..start + 120].to_owned()
1420 }
1421
1422 #[test]
1423 fn a_frame_is_the_same_picture_however_the_window_got_here() {
1424 // "Any width, one answer", `makeover-layout` 0.27.4. The same description
1425 // at the same width is the same frame, whatever widths came before it.
1426 //
1427 // The renderer, the context and the view are made once and reused across
1428 // the sequence, which is the half that matters: a fresh `Immediate` per
1429 // frame could not fail this test however much geometry it kept. egui makes
1430 // the property easy to lose rather than easy to keep -- an immediate-mode
1431 // library hands you `ui.available_width()` every frame and a memory store
1432 // to put the answer in -- so this is the renderer where the guard earns
1433 // its place.
1434 let screen = every_shape_that_could_cache();
1435 let ctx = egui::Context::default();
1436 let immediate = renderer();
1437 let mut view = View::new();
1438
1439 let cold = painted(&ctx, &immediate, &screen, &mut view, 400.0);
1440
1441 for width in [1200.0, 320.0, 900.0, 200.0] {
1442 let _ = painted(&ctx, &immediate, &screen, &mut view, width);
1443 }
1444 assert_eq!(painted(&ctx, &immediate, &screen, &mut view, 400.0), cold);
1445
1446 // And the fixture is one the width actually moves, or the assertion above
1447 // would be true of a blank frame.
1448 assert_ne!(painted(&ctx, &immediate, &screen, &mut view, 1200.0), cold);
1449 }
1450
1451 #[test]
1452 fn a_region_narrows_by_dropping_the_members_that_said_they_could_go() {
1453 // The same declared rule the other two renderers apply, in the third
1454 // renderer's units. The assertion is over what was painted rather than
1455 // over the cutoff helper alone, because the helper being right and the
1456 // walk ignoring it is the failure worth catching.
1457 let screen = Screen::sidebar_content("Toolbar").with(
1458 Slot::new("bar", RegionKind::Band)
1459 .with(Node::text("Library"))
1460 .with_ranked(Node::text("Filter"), layout::Priority::Secondary)
1461 .with_ranked(Node::text("Sort"), layout::Priority::Optional),
1462 );
1463
1464 let ctx = egui::Context::default();
1465 let immediate = renderer();
1466 let mut view = View::new();
1467
1468 let wide = painted(&ctx, &immediate, &screen, &mut view, 1000.0);
1469 let middling = painted(&ctx, &immediate, &screen, &mut view, 700.0);
1470 let narrow = painted(&ctx, &immediate, &screen, &mut view, 400.0);
1471
1472 // Fewer shapes each step down, and never more: the cutoff only rises.
1473 assert!(middling.len() < wide.len(), "{middling}");
1474 assert!(narrow.len() < middling.len(), "{narrow}");
1475
1476 // And the width alone decides it, so coming back is going back.
1477 assert_eq!(painted(&ctx, &immediate, &screen, &mut view, 1000.0), wide);
1478 }
1479
1480 #[test]
1481 fn the_cutoff_boundaries_are_the_size_classes_and_not_this_renderers_taste() {
1482 // 600 and 840 are `makeover-geometry`'s, quoted from Material's window
1483 // size classes and used verbatim by the webview's `@media` rules. Held
1484 // here as a test rather than as a comment because the cost of drifting off
1485 // them is invisible: two hosts showing one screen would narrow at
1486 // different widths and neither would look wrong on its own.
1487 assert_eq!(crate::node::cutoff(599.0), layout::Priority::Essential);
1488 assert_eq!(crate::node::cutoff(600.0), layout::Priority::Secondary);
1489 assert_eq!(crate::node::cutoff(839.0), layout::Priority::Secondary);
1490 assert_eq!(crate::node::cutoff(840.0), layout::Priority::Optional);
1491 }
1492
1493 #[test]
1494 fn each_question_about_one_box_keeps_its_own_deadline() {
1495 // `N8`. One deadline per field would have the faster of two questions
1496 // cancel the slower, which is the cost the ruling named for this renderer
1497 // and the whole of what the second half of the key buys.
1498 let mut view = View::new();
1499 let now = std::time::Instant::now();
1500
1501 let box_q = || Asking::Field("q".to_owned());
1502
1503 view.wait_to_consult(box_q(), 0, now + std::time::Duration::from_millis(200));
1504 view.wait_to_consult(box_q(), 1, now + std::time::Duration::from_millis(150));
1505
1506 assert_eq!(
1507 view.consult_due(box_q(), 0),
1508 Some(now + std::time::Duration::from_millis(200))
1509 );
1510 assert_eq!(
1511 view.consult_due(box_q(), 1),
1512 Some(now + std::time::Duration::from_millis(150))
1513 );
1514
1515 // Asking one leaves the other waiting.
1516 view.consulted(box_q(), 1);
1517 assert!(view.consult_due(box_q(), 0).is_some());
1518 assert_eq!(view.consult_due(box_q(), 1), None);
1519
1520 // `cb62a9dc`. A region that shares the box's name is a different asker, so
1521 // its own deadline is untouched by either call above.
1522 let region_q = || Asking::Region("q".to_owned());
1523 view.wait_to_consult(region_q(), 1, now + std::time::Duration::from_millis(300));
1524 assert_eq!(view.consult_due(box_q(), 1), None);
1525 assert_eq!(
1526 view.consult_due(region_q(), 1),
1527 Some(now + std::time::Duration::from_millis(300))
1528 );
1529 }
1530
1531 #[test]
1532 fn a_regions_question_comes_due_and_carries_every_dial_inside_it() {
1533 // `cb62a9dc`. The wait is the view's and the gathering is the drawing's, so
1534 // a deadline already past is what a frame needs to fire one. What is
1535 // asserted is the payload: every dial the region holds, at every depth,
1536 // and the untouched ones sending what they are showing.
1537 let mut view = View::new();
1538 view.wait_to_consult(
1539 Asking::Region("calculator".to_owned()),
1540 0,
1541 std::time::Instant::now()
1542 .checked_sub(std::time::Duration::from_millis(1))
1543 .expect("the clock has run for a millisecond"),
1544 );
1545 view.set("sales", "40");
1546
1547 let screen = Screen::sidebar_content("Pricing").with(
1548 Slot::group("calculator")
1549 .with(Node::field(
1550 Field::new(layout::FieldKind::Number, "item_price", "Price").value("10"),
1551 ))
1552 .with(Node::field(Field::new(
1553 layout::FieldKind::Number,
1554 "sales",
1555 "Sales",
1556 )))
1557 .with(Node::Region(Slot::group("other").with(Node::field(
1558 Field::new(layout::FieldKind::Number, "other_pct", "Their cut").value("30"),
1559 ))))
1560 .consulting(Consult::new(
1561 Action::get("/pricing/compare").replacing("results-panel"),
1562 )),
1563 );
1564
1565 let immediate = renderer();
1566 let mut fired = None;
1567 egui::__run_test_ui(|ui| {
1568 fired = immediate.screen(ui, &screen, &mut view);
1569 });
1570
1571 let fired = fired.expect("a deadline that has passed is a question asked");
1572 assert_eq!(fired.action.destination.route(), Some("/pricing/compare"));
1573 assert_eq!(fired.payload.get("sales"), Some("40"));
1574 assert_eq!(fired.payload.get("item_price"), Some("10"));
1575 assert_eq!(fired.payload.get("other_pct"), Some("30"));
1576 // Asked once. The wait is gone, so the next frame does not ask again.
1577 assert_eq!(
1578 view.consult_due(Asking::Region("calculator".to_owned()), 0),
1579 None
1580 );
1581 }
1582
1583 #[test]
1584 fn a_question_carries_the_controls_it_says_it_carries() {
1585 // Discover's results route answers about the current filters. Read out of
1586 // what has been drawn, which is where a described field's own offer lands
1587 // the first time it is drawn, so an untouched filter still contributes.
1588 let mut view = View::new();
1589 view.set("mode", "mine");
1590
1591 let carried = view.contributed(&["mode".to_owned(), "absent".to_owned()]);
1592
1593 assert_eq!(carried.get("mode"), Some("mine"));
1594 // A name nothing on the screen carries contributes nothing rather than an
1595 // empty value, so a route can tell "not on this screen" from "on it and
1596 // blank".
1597 assert_eq!(carried.get("absent"), None);
1598 }
1599
1600 // ── Pressing things ──
1601 //
1602 // `egui::__run_test_ui` runs one frame with no input, which is enough to assert
1603 // what a description drew and nothing about what pressing it does. A context
1604 // menu opens on one frame and is pressed on a later one, so an interaction test
1605 // needs the `Context` kept between frames and the pointer told where it is.
1606 //
1607 // The other half is fonts. With `default-features = false` every string measures
1608 // zero points wide, so a `Response` covers no pixel, is never hovered, and no
1609 // injected click reaches it however faithfully it is sent. The dev-dependency in
1610 // `Cargo.toml` is what makes the rest of this section possible; see the note
1611 // there for why it is dev-only.
1612 //
1613 // Positions come from the shapes the previous frame painted rather than from
1614 // arithmetic over the layout. A test that computed "the row is 18 points down"
1615 // would assert the theme's metrics as much as the renderer's wiring, and would
1616 // have to be rewritten whenever a margin changed.
1617
1618 /// A real context, one frame at a time.
1619 struct Host {
1620 ctx: egui::Context,
1621 view: View,
1622 painted: Vec<egui::epaint::ClippedShape>,
1623 /// What is being held down while the next press lands.
1624 ///
1625 /// On the host rather than passed to `click`, because it has to reach two
1626 /// places that do not share a call: the `PointerButton` event and
1627 /// `RawInput::modifiers`, which is where `Context::input().modifiers` comes
1628 /// from and therefore what a renderer reading the keyboard sees.
1629 held: egui::Modifiers,
1630 }
1631
1632 impl Host {
1633 fn new() -> Self {
1634 Self {
1635 ctx: egui::Context::default(),
1636 view: View::new(),
1637 painted: Vec::new(),
1638 held: egui::Modifiers::default(),
1639 }
1640 }
1641
1642 /// Draw `screen` once with `events` delivered, and return what it fired.
1643 fn frame(&mut self, screen: &Screen, events: Vec<egui::Event>) -> Option<crate::Fired> {
1644 let input = egui::RawInput {
1645 screen_rect: Some(egui::Rect::from_min_size(
1646 egui::Pos2::ZERO,
1647 egui::vec2(900.0, 700.0),
1648 )),
1649 events,
1650 modifiers: self.held,
1651 ..Default::default()
1652 };
1653 let immediate = renderer();
1654 let mut fired = None;
1655 let output = self.ctx.run_ui(input, |ui| {
1656 fired = immediate.screen(ui, screen, &mut self.view);
1657 });
1658 self.painted = output.shapes;
1659 fired
1660 }
1661
1662 /// Draw until the layout settles, discarding what it fires.
1663 ///
1664 /// egui needs a pass to learn a widget's size before it can answer a
1665 /// pointer over it, and a menu adds another for its own area.
1666 fn settle(&mut self, screen: &Screen) {
1667 for _ in 0..3 {
1668 self.frame(screen, Vec::new());
1669 }
1670 }
1671
1672 /// The middle of the last frame's rendering of `text`.
1673 ///
1674 /// Panics rather than returning an option: every caller is asserting that
1675 /// something is on screen, and "not painted" is a failure with a much better
1676 /// message here than a later `None` unwrap at the press.
1677 fn find(&self, text: &str) -> egui::Pos2 {
1678 fn walk(shape: &egui::Shape, text: &str, found: &mut Option<egui::Rect>) {
1679 match shape {
1680 egui::Shape::Text(t) if t.galley.job.text.contains(text) => {
1681 *found = Some(egui::Rect::from_min_size(t.pos, t.galley.size()));
1682 }
1683 egui::Shape::Vec(shapes) => {
1684 for shape in shapes {
1685 walk(shape, text, found);
1686 }
1687 }
1688 _ => {}
1689 }
1690 }
1691 let mut found = None;
1692 for clipped in &self.painted {
1693 walk(&clipped.shape, text, &mut found);
1694 }
1695 let rect = found.unwrap_or_else(|| {
1696 panic!("nothing painted the text {text:?}; the press has nowhere to land")
1697 });
1698 assert!(
1699 rect.width() > 0.0,
1700 "{text:?} painted zero points wide, so nothing can be pressed on it"
1701 );
1702 rect.center()
1703 }
1704
1705 /// Move the pointer to `pos` and press and release `button` there.
1706 ///
1707 /// Three frames because that is what egui takes: one to notice the pointer,
1708 /// one for the press, one for the release. The fired action can come back on
1709 /// any of them, so the first that answers wins.
1710 fn click_at(
1711 &mut self,
1712 screen: &Screen,
1713 pos: egui::Pos2,
1714 button: egui::PointerButton,
1715 ) -> Option<crate::Fired> {
1716 let modifiers = self.held;
1717 let press = |pressed| egui::Event::PointerButton {
1718 pos,
1719 button,
1720 pressed,
1721 modifiers,
1722 };
1723 let frames = [
1724 vec![egui::Event::PointerMoved(pos)],
1725 vec![egui::Event::PointerMoved(pos), press(true)],
1726 vec![egui::Event::PointerMoved(pos), press(false)],
1727 vec![egui::Event::PointerMoved(pos)],
1728 ];
1729 let mut fired = None;
1730 for events in frames {
1731 fired = self.frame(screen, events).or(fired);
1732 }
1733 fired
1734 }
1735
1736 /// Click `text` where it was last painted.
1737 fn click(&mut self, screen: &Screen, text: &str) -> Option<crate::Fired> {
1738 let pos = self.find(text);
1739 self.click_at(screen, pos, egui::PointerButton::Primary)
1740 }
1741
1742 /// Click `text` with these keys held.
1743 fn click_holding(
1744 &mut self,
1745 screen: &Screen,
1746 text: &str,
1747 held: egui::Modifiers,
1748 ) -> Option<crate::Fired> {
1749 self.held = held;
1750 let fired = self.click(screen, text);
1751 self.held = egui::Modifiers::default();
1752 fired
1753 }
1754
1755 /// Right-click `text` where it was last painted.
1756 fn right_click(&mut self, screen: &Screen, text: &str) -> Option<crate::Fired> {
1757 let pos = self.find(text);
1758 self.click_at(screen, pos, egui::PointerButton::Secondary)
1759 }
1760 }
1761
1762 /// A screen with a band, a pane and a band, said in that order.
1763 fn topped_and_tailed() -> Screen {
1764 Screen::sidebar_content("Test")
1765 .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar")))
1766 .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content")))
1767 .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status")))
1768 }
1769
1770 /// An outline: `drums` shut over a child, `genre` open over one.
1771 fn outline() -> Screen {
1772 screen_of([Node::list([
1773 quasi_router::Row::new("drums")
1774 .disclosing(false)
1775 .activate(Action::get("/tags/drums")),
1776 quasi_router::Row::new("drums.kick")
1777 .depth(quasi_router::layout::Nesting::at(1))
1778 .activate(Action::get("/k")),
1779 quasi_router::Row::new("genre").disclosing(true),
1780 quasi_router::Row::new("genre.house").depth(quasi_router::layout::Nesting::at(1)),
1781 ])])
1782 }
1783
1784 /// Whether anything painted this text on the last frame.
1785 fn on_screen(host: &Host, text: &str) -> bool {
1786 fn walk(shape: &egui::Shape, text: &str) -> bool {
1787 match shape {
1788 egui::Shape::Text(t) => t.galley.job.text.contains(text),
1789 egui::Shape::Vec(shapes) => shapes.iter().any(|shape| walk(shape, text)),
1790 _ => false,
1791 }
1792 }
1793 host.painted
1794 .iter()
1795 .any(|clipped| walk(&clipped.shape, text))
1796 }
1797
1798 #[test]
1799 fn a_shut_branch_draws_neither_its_children_nor_asks_the_app_anything() {
1800 // `ccaa7e4b`. The rows are in the description either way; which of them are
1801 // on screen is `Row::open`'s answer, and pressing the chevron is the reader
1802 // tidying their own view rather than a write.
1803 let mut host = Host::new();
1804 let screen = outline();
1805 host.settle(&screen);
1806 assert!(on_screen(&host, "drums"), "the branch is drawn");
1807 assert!(
1808 !on_screen(&host, "drums.kick"),
1809 "a shut branch hides its own"
1810 );
1811 assert!(on_screen(&host, "genre.house"), "an open one does not");
1812
1813 // The chevron opens it, and calls no route.
1814 let fired = host.click(&screen, "\u{25b6}");
1815 assert!(fired.is_none(), "folding asked the app for something");
1816 host.settle(&screen);
1817 assert!(on_screen(&host, "drums.kick"), "the branch did not open");
1818 }
1819
1820 #[test]
1821 fn pressing_a_branchs_chevron_is_not_pressing_the_branch() {
1822 // A separate hit target from the label, which is the shipped egui sidebar's
1823 // own behaviour: pressing a tag filters by it and pressing its chevron does
1824 // not. The row's own route still answers a press on its words.
1825 let mut host = Host::new();
1826 let screen = outline();
1827 host.settle(&screen);
1828 let fired = host.click(&screen, "drums");
1829 assert_eq!(
1830 fired.and_then(|fired| fired.action.route().map(str::to_string)),
1831 Some("/tags/drums".to_string())
1832 );
1833 }
1834
1835 #[test]
1836 fn a_band_said_after_the_body_is_drawn_under_it() {
1837 // Ruled by Max 2026-08-23 (quasicoherent 3725bacf): where a band was said
1838 // is which end it belongs to. Before it this renderer hoisted every band,
1839 // so a footer drew above the content it was the footer of while the same
1840 // description put it underneath in a webview.
1841 let mut host = Host::new();
1842 let screen = topped_and_tailed();
1843 host.settle(&screen);
1844
1845 let bar = host.find("the toolbar");
1846 let content = host.find("the content");
1847 let foot = host.find("the status");
1848
1849 assert!(
1850 bar.y < content.y,
1851 "the toolbar left the top: {bar:?} {content:?}"
1852 );
1853 assert!(
1854 content.y < foot.y,
1855 "the footer is above what it is the footer of: {content:?} {foot:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn the_band_said_last_is_the_one_at_the_bottom() {
1861 // Two trailing bands, which is audiofiles' shell: a migration strip above
1862 // the status band. A bottom-up layout puts the first widget lowest, so the
1863 // order they are drawn in is the reverse of the order they were said in,
1864 // and getting that wrong is invisible until there are two.
1865 let screen = Screen::sidebar_content("Test")
1866 .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content")))
1867 .with(Slot::new("strip", RegionKind::Band).with(Node::text("the strip")))
1868 .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status")));
1869 let mut host = Host::new();
1870 host.settle(&screen);
1871
1872 let strip = host.find("the strip");
1873 let foot = host.find("the status");
1874 assert!(
1875 strip.y < foot.y,
1876 "the bands are upside down: {strip:?} {foot:?}"
1877 );
1878 }
1879
1880 #[test]
1881 fn a_screen_of_nothing_but_bands_is_unchanged() {
1882 // The shape every description written before the ruling had. With no body
1883 // to be after, every band is a leading one.
1884 let screen = Screen::sidebar_content("Test")
1885 .with(Slot::new("one", RegionKind::Band).with(Node::text("first")))
1886 .with(Slot::new("two", RegionKind::Band).with(Node::text("second")));
1887 let mut host = Host::new();
1888 host.settle(&screen);
1889
1890 assert!(host.find("first").y < host.find("second").y);
1891 }
1892
1893 /// A band whose members were said to share one row.
1894 fn run_of(fallback: layout::Fallback, members: [(&str, layout::Priority); 3]) -> Screen {
1895 let mut row = Run::new(fallback);
1896 for (label, priority) in members {
1897 row = row.beside(
1898 Node::Act(Act::new(label, Action::post(format!("/{label}")))),
1899 priority,
1900 );
1901 }
1902 Screen::sidebar_content("Test").with(Slot::new("bar", RegionKind::Band).across(row))
1903 }
1904
1905 #[test]
1906 fn a_member_of_a_run_is_drawn_at_all() {
1907 // The defect: `region` walked `body` and nothing walked `run`, so a
1908 // description that said `across` and then `part` contributed controls that
1909 // were never painted and could never be pressed. `Row::menu`'s bug of
1910 // 2026-08-17 in a second place, and silent in the same way.
1911 let screen = run_of(
1912 layout::Fallback::Wrap,
1913 [
1914 ("Import", layout::Priority::Essential),
1915 ("Export", layout::Priority::Essential),
1916 ("Settings", layout::Priority::Essential),
1917 ],
1918 );
1919 let mut host = Host::new();
1920 host.settle(&screen);
1921
1922 assert_eq!(
1923 host.click(&screen, "Export").map(|fired| fired.action),
1924 Some(Action::post("/Export")),
1925 "a described control in a row must send what it said it sends"
1926 );
1927 }
1928
1929 #[test]
1930 fn a_run_puts_its_members_across_rather_than_down() {
1931 // What the row is for. Three controls in a band drew one per line before
1932 // this, which is the shape a toolbar is not.
1933 let screen = run_of(
1934 layout::Fallback::Wrap,
1935 [
1936 ("Import", layout::Priority::Essential),
1937 ("Export", layout::Priority::Essential),
1938 ("Settings", layout::Priority::Essential),
1939 ],
1940 );
1941 let mut host = Host::new();
1942 host.settle(&screen);
1943
1944 let first = host.find("Import");
1945 let second = host.find("Export");
1946 let third = host.find("Settings");
1947 assert!(
1948 (first.y - second.y).abs() < 1.0 && (second.y - third.y).abs() < 1.0,
1949 "the members left the row: {first:?} {second:?} {third:?}"
1950 );
1951 assert!(
1952 first.x < second.x && second.x < third.x,
1953 "the members are out of the order they were said in: {first:?} {second:?} {third:?}"
1954 );
1955 }
1956
1957 #[test]
1958 fn a_run_that_sheds_keeps_what_the_description_called_essential() {
1959 // The half a webview cannot do at all: there is no
1960 // `@container (inline-size < min-content)`, so `Shed` wraps there. This
1961 // renderer is holding the width, so it can honour the word.
1962 let screen = run_of(
1963 layout::Fallback::Shed,
1964 [
1965 ("Import", layout::Priority::Essential),
1966 ("Export", layout::Priority::Secondary),
1967 ("Settings", layout::Priority::Optional),
1968 ],
1969 );
1970 let ctx = egui::Context::default();
1971 let immediate = renderer();
1972
1973 let narrow = painted(&ctx, &immediate, &screen, &mut View::new(), 500.0);
1974 assert!(narrow.contains("Import"), "the essential member was shed");
1975 assert!(
1976 !narrow.contains("Export") && !narrow.contains("Settings"),
1977 "a shed row kept what it said it would drop: {narrow}"
1978 );
1979
1980 // And nothing is dropped when there is room for all three, or the cutoff
1981 // would be a permanent narrowing rather than a measurement.
1982 let wide = painted(&ctx, &immediate, &screen, &mut View::new(), 1000.0);
1983 assert!(
1984 wide.contains("Export") && wide.contains("Settings"),
1985 "{wide}"
1986 );
1987 }
1988
1989 #[test]
1990 fn a_run_that_menus_keeps_every_member_reachable() {
1991 // `Shed` and `Menu` drop the same members. What separates them is where the
1992 // dropped ones go, and a `Menu` that dropped them on the floor would be
1993 // this renderer answering with `Shed`.
1994 let screen = run_of(
1995 layout::Fallback::Menu,
1996 [
1997 ("Import", layout::Priority::Essential),
1998 ("Export", layout::Priority::Secondary),
1999 ("Settings", layout::Priority::Optional),
2000 ],
2001 );
2002 let ctx = egui::Context::default();
2003 let immediate = renderer();
2004
2005 let narrow = painted(&ctx, &immediate, &screen, &mut View::new(), 500.0);
2006 assert!(narrow.contains("Import"));
2007 assert!(
2008 narrow.contains("2 more"),
2009 "the two shed members went nowhere a reader could follow: {narrow}"
2010 );
2011 }
2012
2013 #[test]
2014 fn a_region_with_no_run_draws_exactly_what_it_did_before() {
2015 // The change is additive at the call site, so a description written before
2016 // runs existed must paint the same picture.
2017 let screen = screen_of([Node::Act(Act::new("Save", Action::post("/save")))]);
2018 let ctx = egui::Context::default();
2019 let immediate = renderer();
2020
2021 let before = painted(&ctx, &immediate, &screen, &mut View::new(), 900.0);
2022 assert!(before.contains("Save"));
2023 assert!(
2024 !before.contains("more"),
2025 "a region with no run grew a control out of nothing: {before}"
2026 );
2027 }
2028
2029 /// A list of two rows, the first offering both an opening route and a menu.
2030 fn menu_list() -> Screen {
2031 use quasi_router::Row;
2032
2033 screen_of([Node::list([
2034 Row::new("kick.wav")
2035 .activate(Action::get("/files/1"))
2036 .offers(Act::new("Preview", Action::post("/files/1/play")))
2037 .offers(
2038 Act::new("Delete", Action::post("/files/1/delete")).confirm("Delete kick.wav?"),
2039 ),
2040 // A row beside it offering nothing, so an interact rect claimed for the
2041 // wrong row shows up as the wrong route rather than as no route.
2042 Row::new("snare.wav").activate(Action::get("/files/2")),
2043 ])])
2044 }
2045
2046 #[test]
2047 fn a_control_over_the_selection_refuses_an_empty_set_and_sends_the_ticks() {
2048 // `Act::over` reached this renderer and did nothing until 2026-08-20: the
2049 // member was read into a parameter both callers passed `None` for, so the
2050 // count went undrawn, the press over nothing went through, and what it sent
2051 // travelled under the selection's name rather than under `Node::TICKED`.
2052 use quasi_router::Column;
2053
2054 let screen = Screen::sidebar_content("Tasks").selecting("chosen").with(
2055 Slot::new("main", RegionKind::Pane)
2056 .with(Node::Table {
2057 marks: ::quasi_router::stage::Marks::none(),
2058 columns: vec![Column::new("title")],
2059 rows: vec![Row::cells(["First"]).ticking("t-1", false)],
2060 more: None,
2061 })
2062 .with(Node::Act(
2063 Act::new("Complete", Action::post("/tasks/complete")).over("chosen"),
2064 )),
2065 );
2066
2067 let mut host = Host::new();
2068 host.settle(&screen);
2069 assert!(
2070 host.click(&screen, "Complete").is_none(),
2071 "a press over an empty selection is refused"
2072 );
2073
2074 host.view.tick("t-1");
2075 host.settle(&screen);
2076 let fired = host
2077 .click(&screen, "Complete")
2078 .expect("the control answers");
2079 assert_eq!(
2080 fired.payload.get_all(Node::TICKED).collect::<Vec<_>>(),
2081 ["t-1"]
2082 );
2083 }
2084
2085 #[test]
2086 fn clicking_a_control_that_asked_for_a_value_sends_it_with_the_ticks() {
2087 // `033ff3ca`. The box stands beside the control here rather than behind a
2088 // disclosure, so what is asserted is the payload: the value the box holds
2089 // and the set the verb acts over, in one call.
2090 use quasi_router::{Column, Field};
2091
2092 let screen = Screen::sidebar_content("Items").selecting("chosen").with(
2093 Slot::new("main", RegionKind::Pane)
2094 .with(Node::Table {
2095 marks: ::quasi_router::stage::Marks::none(),
2096 columns: vec![Column::new("title")],
2097 rows: vec![Row::cells(["First"]).ticking("i-1", false)],
2098 more: None,
2099 })
2100 .with(Node::Act(
2101 Act::new("Set Price", Action::post("/items/price"))
2102 .over("chosen")
2103 .asking(Field::new(
2104 layout::FieldKind::Number,
2105 "price",
2106 "New price ($)",
2107 )),
2108 )),
2109 );
2110
2111 let mut host = Host::new();
2112 host.settle(&screen);
2113 // What the reader would have typed and ticked. Typing into the box through
2114 // events is egui's own text edit rather than anything described here.
2115 host.view.set("price", "12");
2116 host.view.tick("i-1");
2117 host.settle(&screen);
2118
2119 let fired = host.click(&screen, "Set Price").expect("the verb answers");
2120 assert_eq!(fired.action, Action::post("/items/price"));
2121 assert_eq!(fired.payload.get("price"), Some("12"));
2122 assert_eq!(
2123 fired.payload.get_all(Node::TICKED).collect::<Vec<_>>(),
2124 ["i-1"]
2125 );
2126 }
2127
2128 #[test]
2129 fn clicking_a_row_opens_it() {
2130 let screen = menu_list();
2131 let mut host = Host::new();
2132 host.settle(&screen);
2133
2134 let fired = host.click(&screen, "kick.wav").expect("the row answers");
2135 assert_eq!(fired.action, Action::get("/files/1"));
2136 }
2137
2138 #[test]
2139 fn each_row_answers_for_itself() {
2140 // The `ui.min_rect()` defect: every row of a list draws into one shared
2141 // `Ui`, so a rect taken off the `Ui` grew with each row and the later rows'
2142 // targets covered the earlier ones. Clicking the second row and getting the
2143 // first row's route is exactly what that looked like.
2144 let screen = menu_list();
2145 let mut host = Host::new();
2146 host.settle(&screen);
2147
2148 let fired = host.click(&screen, "snare.wav").expect("the row answers");
2149 assert_eq!(fired.action, Action::get("/files/2"));
2150 }
2151
2152 #[test]
2153 fn right_clicking_a_row_opens_its_menu_and_the_menu_fires() {
2154 let screen = menu_list();
2155 let mut host = Host::new();
2156 host.settle(&screen);
2157
2158 assert!(
2159 host.right_click(&screen, "kick.wav").is_none(),
2160 "asking what a row offers is not opening it"
2161 );
2162
2163 let fired = host
2164 .click(&screen, "Preview")
2165 .expect("the menu item answers");
2166 assert_eq!(fired.action, Action::post("/files/1/play"));
2167 assert_eq!(fired.confirm, None);
2168 }
2169
2170 #[test]
2171 fn a_menu_item_carries_the_confirmation_it_was_described_with() {
2172 let screen = menu_list();
2173 let mut host = Host::new();
2174 host.settle(&screen);
2175 host.right_click(&screen, "kick.wav");
2176
2177 let fired = host
2178 .click(&screen, "Delete")
2179 .expect("the menu item answers");
2180 assert_eq!(fired.action, Action::post("/files/1/delete"));
2181 assert_eq!(fired.confirm.as_deref(), Some("Delete kick.wav?"));
2182 }
2183
2184 #[test]
2185 fn a_table_row_answers_the_menu_on_whichever_cell_was_pressed() {
2186 // The table half, and the one that matters most: audiofiles' file list is a
2187 // `Node::Table`. `makeover_immediate::table` answers a `Ui` per cell and no
2188 // row-wide rect, so the menu hangs off every cell of the row, and the
2189 // second column is what proves it rather than the first.
2190 use quasi_router::Column;
2191
2192 let screen = screen_of([Node::Table {
2193 marks: ::quasi_router::stage::Marks::none(),
2194 columns: vec![Column::new("Name"), Column::new("BPM")],
2195 rows: vec![
2196 Row::cells(["kick.wav", "120"])
2197 .activate(Action::post("/files/1/open"))
2198 .offers(Act::new("Preview", Action::post("/files/1/play"))),
2199 Row::cells(["snare.wav", "140"]).activate(Action::post("/files/2/open")),
2200 ],
2201 more: None,
2202 }]);
2203
2204 let mut host = Host::new();
2205 host.settle(&screen);
2206
2207 assert!(
2208 host.right_click(&screen, "120").is_none(),
2209 "asking what a row offers is not opening it"
2210 );
2211 let fired = host
2212 .click(&screen, "Preview")
2213 .expect("the menu item answers");
2214 assert_eq!(fired.action, Action::post("/files/1/play"));
2215 }
2216
2217 #[test]
2218 fn a_table_row_without_a_menu_still_opens() {
2219 use quasi_router::Column;
2220
2221 let screen = screen_of([Node::Table {
2222 marks: ::quasi_router::stage::Marks::none(),
2223 columns: vec![Column::new("Name")],
2224 rows: vec![
2225 Row::cells(["kick.wav"]).activate(Action::post("/files/1/open")),
2226 Row::cells(["snare.wav"]).activate(Action::post("/files/2/open")),
2227 ],
2228 more: None,
2229 }]);
2230
2231 let mut host = Host::new();
2232 host.settle(&screen);
2233
2234 let fired = host.click(&screen, "snare.wav").expect("the row answers");
2235 assert_eq!(fired.action, Action::post("/files/2/open"));
2236 }
2237
2238 #[test]
2239 fn a_region_fed_by_a_call_is_asked_for_and_then_stops_asking() {
2240 // `d8d6f380`. egui has no browser under it either, so the host performs the
2241 // region's call the way it performs every other one.
2242 let mut runtime = Runtime::new(Screen::sidebar_content("Payments").with(
2243 Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()),
2244 ));
2245
2246 let feeds = runtime.feeds();
2247 assert_eq!(feeds.len(), 1);
2248 assert_eq!(feeds[0].path, "/dashboard/payouts");
2249 assert_eq!(feeds[0].method, Method::Get);
2250
2251 runtime.apply(
2252 &feeds[0].clone(),
2253 Response {
2254 outcome: Outcome::Fragment {
2255 region: "payouts".to_owned(),
2256 node: Node::text("$12.00"),
2257 },
2258 notice: None,
2259 address: None,
2260 invalidates: Vec::new(),
2261 },
2262 );
2263 assert!(
2264 runtime.feeds().is_empty(),
2265 "a region that has been filled asks again"
2266 );
2267 }
2268
2269 #[test]
2270 fn a_live_region_is_re_asked_on_the_cadence_and_not_faster() {
2271 // The half `feeds` deliberately does not do. A feed is cleared as it lands
2272 // and a cadence is not, so the pacing has to live somewhere, and it lives
2273 // here rather than in every host that draws a live screen.
2274 let mut runtime = Runtime::new(
2275 Screen::sidebar_content("Admin").with(
2276 Slot::new("queue", RegionKind::Pane)
2277 .fed_by(Action::get("/admin/queue"))
2278 .live(),
2279 ),
2280 );
2281
2282 // A live call is never a feed, so a host performing feeds asks for nothing.
2283 assert!(runtime.feeds().is_empty());
2284 assert!(runtime.is_live());
2285
2286 let start = std::time::Instant::now();
2287 let first = runtime.refreshes_at(start);
2288 assert_eq!(first.len(), 1);
2289 assert_eq!(first[0].path, "/admin/queue");
2290
2291 // Asked again a frame later, which is what an event loop does: nothing, or
2292 // the rate would be the loop's rather than this crate's.
2293 assert!(
2294 runtime
2295 .refreshes_at(start + std::time::Duration::from_millis(16))
2296 .is_empty()
2297 );
2298
2299 // The answer landing leaves the region live, which is what `replace`
2300 // learned, so the next period asks again.
2301 runtime.apply(
2302 &first[0].clone(),
2303 Response::fragment("queue", Node::text("4 waiting")),
2304 );
2305 assert_eq!(runtime.refreshes_at(start + crate::CADENCE).len(), 1);
2306 }
2307
2308 /// The write is offloaded, the route answers that it started, and the region's
2309 /// own live call is what reports the finish. This host draws the wait off the
2310 /// readiness axis, so nothing here has to look at the sentence.
2311 #[test]
2312 fn work_handed_off_leaves_the_region_waiting_and_keeps_the_call_that_reports_it() {
2313 let mut runtime = Runtime::new(
2314 Screen::sidebar_content("Import & Export").with(
2315 Slot::new("backups", RegionKind::Pane)
2316 .fed_by(Action::get("/backups"))
2317 .live(),
2318 ),
2319 );
2320 let start = std::time::Instant::now();
2321 let first = runtime.refreshes_at(start);
2322 runtime.apply(
2323 &first[0].clone(),
2324 Response::fragment("backups", Node::text("3 backups")),
2325 );
2326 let region = |runtime: &Runtime| {
2327 runtime
2328 .screen()
2329 .slots
2330 .iter()
2331 .find_map(|slot| slot.find("backups"))
2332 .expect("the region is on the screen")
2333 .clone()
2334 };
2335 assert_eq!(region(&runtime).readiness, layout::Readiness::Ready);
2336
2337 runtime.apply(
2338 &Request::post("/backups/create"),
2339 Response::started("backups", "Creating backup…"),
2340 );
2341
2342 let waiting = region(&runtime);
2343 assert_eq!(waiting.readiness, layout::Readiness::Pending);
2344 assert_eq!(
2345 waiting.body.get(0).expect("a member").node,
2346 Node::pending("Creating backup…")
2347 );
2348 // Still the same screen: handing work off is not a navigation and puts up
2349 // no layer.
2350 assert_eq!(runtime.screen().title, "Import & Export");
2351
2352 // And the cadence survived, which is what makes the finish reportable.
2353 assert_eq!(runtime.refreshes_at(start + crate::CADENCE).len(), 1);
2354 }
2355
2356 #[test]
2357 fn a_still_screen_refreshes_nothing() {
2358 let mut runtime = Runtime::new(
2359 Screen::sidebar_content("Payments")
2360 .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts"))),
2361 );
2362
2363 assert!(!runtime.is_live());
2364 assert!(runtime.refreshes().is_empty());
2365 assert_eq!(runtime.feeds().len(), 1, "a still region is still a feed");
2366 }
2367
2368 #[test]
2369 fn a_live_region_with_no_call_re_asks_the_screens_own_address() {
2370 // The audiofiles sync panel: state the host already holds, moved by an
2371 // OAuth callback landing in another process. There is no fragment to fetch,
2372 // so re-reading it is building the description again, which is the address
2373 // the screen came from.
2374 let mut runtime = Runtime::new(
2375 Screen::sidebar_content("Sync").with(
2376 Slot::new("sync", RegionKind::Pane)
2377 .live()
2378 .with(Node::text("Authenticating")),
2379 ),
2380 );
2381
2382 assert!(runtime.is_live());
2383 // Nothing to ask until the runtime knows where the screen came from, which
2384 // is what `apply` records. A first screen handed straight to `new` has no
2385 // address, and inventing one would be a route this crate made up.
2386 assert!(runtime.refreshes().is_empty());
2387
2388 let home = Request::get("/sync");
2389 runtime.apply(
2390 &home,
2391 Response::from(
2392 Screen::sidebar_content("Sync").with(
2393 Slot::new("sync", RegionKind::Pane)
2394 .live()
2395 .with(Node::text("Needs encryption")),
2396 ),
2397 ),
2398 );
2399
2400 let due = runtime.refreshes_at(std::time::Instant::now() + crate::CADENCE);
2401 assert_eq!(due.len(), 1);
2402 assert_eq!(due[0].path, "/sync");
2403 }
2404
2405 #[test]
2406 fn the_control_being_waited_on_is_the_one_that_is_busy() {
2407 // What `act_node` reads to disable the control that was pressed, and the
2408 // whole guard with it: a disabled widget reports no click.
2409 let mut view = View::new();
2410 let buying = Action::post("/checkout").awaiting();
2411 assert!(!view.busy(&buying));
2412
2413 view.await_on(Some(buying.clone()));
2414 assert!(view.busy(&buying));
2415 // One control, not the app: everything else on the screen still answers.
2416 assert!(!view.busy(&Action::post("/cancel")));
2417
2418 // A screen arriving is the answer, or is somewhere else entirely.
2419 view.reset();
2420 assert!(!view.busy(&buying));
2421 }
2422
2423 #[test]
2424 fn a_screen_waiting_on_a_control_still_draws() {
2425 // One frame with an outstanding control, which is the state every frame
2426 // between the press and the answer is in.
2427 let screen = screen_of([Node::Act(Act::new(
2428 "Buy",
2429 Action::post("/checkout").awaiting(),
2430 ))]);
2431 let mut view = View::new();
2432 view.await_on(Some(Action::post("/checkout").awaiting()));
2433 draw(&screen, &mut view);
2434 }
2435
2436 // ── The frame a mount puts around a screen ──
2437 //
2438 // Through a real `Context` for `painted`'s reason: what is asserted is the
2439 // order things were laid out in, and that is only readable off the shapes.
2440
2441 /// One frame's painted shapes, as their debug form.
2442 fn framed_shapes(
2443 ctx: &egui::Context,
2444 immediate: &Immediate,
2445 screen: &Screen,
2446 frame: &Frame,
2447 view: &mut View,
2448 ) -> String {
2449 let input = egui::RawInput {
2450 screen_rect: Some(egui::Rect::from_min_size(
2451 egui::Pos2::ZERO,
2452 egui::vec2(600.0, 900.0),
2453 )),
2454 ..Default::default()
2455 };
2456 let output = ctx.run_ui(input, |ctx| {
2457 egui::CentralPanel::default().show(ctx, |ui| {
2458 immediate.framed(ui, screen, frame, view);
2459 });
2460 });
2461 format!("{:?}", output.shapes)
2462 }
2463
2464 /// Where in the painting a piece of text first appears.
2465 ///
2466 /// A character offset into the shapes' debug form, which is enough to order two
2467 /// things against each other and is what every assertion here needs.
2468 fn painted_at(shapes: &str, text: &str) -> usize {
2469 shapes
2470 .find(text)
2471 .unwrap_or_else(|| panic!("{text:?} was never painted"))
2472 }
2473
2474 #[test]
2475 fn a_mount_that_declares_no_frame_draws_what_it_always_drew() {
2476 // The default has to be the old picture, or every host that puts a screen
2477 // up changes what it paints when this arrives.
2478 let ctx = egui::Context::default();
2479 let immediate = renderer();
2480 let screen = screen_of([Node::text("body")]);
2481
2482 let framed = framed_shapes(&ctx, &immediate, &screen, &Frame::new(), &mut View::new());
2483 let plain = painted(&ctx, &immediate, &screen, &mut View::new(), 600.0);
2484
2485 assert_eq!(plain, framed);
2486 }
2487
2488 #[test]
2489 fn a_frames_verbs_are_drawn_under_the_screen() {
2490 // goingson's compose window. The verbs belong to the mount, so they are
2491 // drawn without the screen describing them, and after it because an
2492 // immediate-mode host lays out in the order it is told.
2493 let ctx = egui::Context::default();
2494 let immediate = renderer();
2495 let screen = screen_of([Node::text("body")]);
2496 let frame = Frame::new()
2497 .offering(Act::new("Send", Action::post("/compose/send")))
2498 .offering(Act::new("Discard", Action::post("/compose/discard")));
2499
2500 let shapes = framed_shapes(&ctx, &immediate, &screen, &frame, &mut View::new());
2501
2502 let body = painted_at(&shapes, "body");
2503 let send = painted_at(&shapes, "Send");
2504 let discard = painted_at(&shapes, "Discard");
2505 assert!(send > body, "the frame is under the screen");
2506 assert!(discard > send, "verbs keep the order they were declared in");
2507 }
2508
2509 #[test]
2510 fn a_banner_rests_in_a_reporting_frame_and_a_toast_still_floats() {
2511 // The status line without a channel: `Screen::notices` already carries the
2512 // messages, and a reporting mount changes where one of the two kinds lands
2513 // rather than adding a second way to say it.
2514 let ctx = egui::Context::default();
2515 let immediate = renderer();
2516 let mut screen = screen_of([Node::text("body")]);
2517 screen
2518 .notices
2519 .push(Node::banner(layout::Tone::Danger, "Not sent"));
2520 screen.notices.push(Node::Notice {
2521 kind: layout::Notice::Toast,
2522 tone: layout::Tone::Info,
2523 text: "Saved".into(),
2524 act: None,
2525 });
2526
2527 let shapes = framed_shapes(
2528 &ctx,
2529 &immediate,
2530 &screen,
2531 &Frame::new().reporting(),
2532 &mut View::new(),
2533 );
2534
2535 let body = painted_at(&shapes, "body");
2536 assert!(
2537 painted_at(&shapes, "Not sent") > body,
2538 "a banner rests under the screen"
2539 );
2540 assert!(
2541 painted_at(&shapes, "Saved") < body,
2542 "a toast floats above it as it always did"
2543 );
2544 }
2545
2546 // ── The panel the app keeps on screen ──
2547
2548 /// One frame's painted shapes, with the app's chrome drawn as well.
2549 fn chromed_shapes(
2550 ctx: &egui::Context,
2551 immediate: &Immediate,
2552 screen: &Screen,
2553 frame: &Frame,
2554 chrome: &Chrome,
2555 view: &mut View,
2556 ) -> String {
2557 let input = egui::RawInput {
2558 screen_rect: Some(egui::Rect::from_min_size(
2559 egui::Pos2::ZERO,
2560 egui::vec2(600.0, 900.0),
2561 )),
2562 ..Default::default()
2563 };
2564 let output = ctx.run_ui(input, |ctx| {
2565 egui::CentralPanel::default().show(ctx, |ui| {
2566 immediate.chromed(ui, screen, frame, chrome, view);
2567 });
2568 });
2569 format!("{:?}", output.shapes)
2570 }
2571
2572 #[test]
2573 fn an_app_that_declares_no_panel_draws_what_it_always_drew() {
2574 // The default has to be the old picture, or every host paints something new
2575 // the moment this member arrives.
2576 let ctx = egui::Context::default();
2577 let immediate = renderer();
2578 let screen = screen_of([Node::text("body")]);
2579
2580 let framed = framed_shapes(&ctx, &immediate, &screen, &Frame::new(), &mut View::new());
2581 let chromed = chromed_shapes(
2582 &ctx,
2583 &immediate,
2584 &screen,
2585 &Frame::new(),
2586 &Chrome::new(),
2587 &mut View::new(),
2588 );
2589
2590 assert_eq!(framed, chromed);
2591 }
2592
2593 #[test]
2594 fn the_panel_is_drawn_under_the_frame() {
2595 // goingson's running-timer widget. It belongs to the app, so it is painted
2596 // without any screen describing it, and after the frame because the panel
2597 // outlives the mount the frame came from.
2598 let ctx = egui::Context::default();
2599 let immediate = renderer();
2600 let screen = screen_of([Node::text("body")]);
2601 let frame = Frame::new().offering(Act::new("Send", Action::post("/compose/send")));
2602 let chrome = Chrome::new().presenting(
2603 "timer",
2604 quasi_router::Role::Activity,
2605 Node::text("00:12:04"),
2606 );
2607
2608 let shapes = chromed_shapes(&ctx, &immediate, &screen, &frame, &chrome, &mut View::new());
2609
2610 let body = painted_at(&shapes, "body");
2611 let send = painted_at(&shapes, "Send");
2612 let panel = painted_at(&shapes, "00:12:04");
2613 assert!(send > body, "the frame is under the screen");
2614 assert!(panel > send, "the panel is under the frame");
2615 }
2616
2617 #[test]
2618 fn an_answer_aimed_at_the_panel_lands_in_it_rather_than_being_reported_missing() {
2619 // How a timer ever moves: the panel carries an address, so a route that has
2620 // changed what it says reaches it the way it reaches any other region.
2621 let mut runtime =
2622 Runtime::new(screen_of([Node::text("body")])).with_chrome(Chrome::new().presenting(
2623 "timer",
2624 quasi_router::Role::Activity,
2625 Node::text("00:12:04"),
2626 ));
2627
2628 runtime.apply(
2629 &Request::post("/timer/tick"),
2630 Response::fragment("timer", Node::text("00:12:05")),
2631 );
2632
2633 assert_eq!(
2634 runtime.chrome().panel("timer").map(|panel| &panel.content),
2635 Some(&Node::text("00:12:05"))
2636 );
2637 // A description bug is still reported: the panel is one address, not a
2638 // catch-all for everything the screen does not have.
2639 assert!(runtime.screen().notices.is_empty());
2640 }
2641
2642 /// A screen holding one bespoke region with a described heading in it.
2643 fn with_a_transport() -> Screen {
2644 Screen::sidebar_content("Library")
2645 .with(Slot::handover("player", "media-transport").with(Node::section("Episode 4")))
2646 }
2647
2648 #[test]
2649 fn a_bespoke_region_draws_the_host_fill_under_the_blocks_the_description_owns() {
2650 // Decision 4's egui half, and the counterpart to `Webview::with_fill` and
2651 // `Tui::with_fill`: the renderer hands the space over. The ordering is the
2652 // arrangement `Containment::Opaque` describes -- a heading the description
2653 // owns above a canvas it does not.
2654 use std::sync::Arc;
2655 use std::sync::atomic::{AtomicBool, Ordering};
2656
2657 let ctx = egui::Context::default();
2658 let painting = Arc::new(AtomicBool::new(false));
2659 let seen = Arc::clone(&painting);
2660 let immediate =
2661 renderer().with_fill("player", move |immediate: &Immediate, ui: &mut egui::Ui| {
2662 // The fill paints in the palette the screen around it is painted in,
2663 // which is the whole reason it is handed the renderer.
2664 seen.store(immediate.palette().page == palette().page, Ordering::SeqCst);
2665 ui.label("PLAYING");
2666 });
2667
2668 let shapes = painted(
2669 &ctx,
2670 &immediate,
2671 &with_a_transport(),
2672 &mut View::new(),
2673 600.0,
2674 );
2675
2676 assert!(
2677 painting.load(Ordering::SeqCst),
2678 "the fill drew, in the palette"
2679 );
2680 assert!(
2681 painted_at(&shapes, "Episode 4") < painted_at(&shapes, "PLAYING"),
2682 "the fill goes under the described blocks"
2683 );
2684 }
2685
2686 #[test]
2687 fn a_bespoke_region_with_no_fill_draws_what_the_description_says_and_stops() {
2688 // What every host that offers no fill gets, and what this renderer did for
2689 // every host before `with_fill` existed.
2690 let ctx = egui::Context::default();
2691 let shapes = painted(
2692 &ctx,
2693 &renderer(),
2694 &with_a_transport(),
2695 &mut View::new(),
2696 600.0,
2697 );
2698
2699 assert!(shapes.contains("Episode 4"));
2700 assert!(!shapes.contains("PLAYING"));
2701 }
2702
2703 #[test]
2704 fn a_fill_named_against_a_pane_is_ignored() {
2705 // A host reaching into a region the description already owns. All three
2706 // renderers keep the rule, and it is why a fill is not simply "a drawing
2707 // for this id".
2708 let ctx = egui::Context::default();
2709 let screen = Screen::sidebar_content("Library")
2710 .with(Slot::new("player", RegionKind::Pane).with(Node::text("described")));
2711 let immediate = renderer().with_fill("player", |_: &Immediate, ui: &mut egui::Ui| {
2712 ui.label("PLAYING");
2713 });
2714
2715 let shapes = painted(&ctx, &immediate, &screen, &mut View::new(), 600.0);
2716
2717 assert!(shapes.contains("described"));
2718 assert!(!shapes.contains("PLAYING"));
2719 }
2720
2721 #[test]
2722 fn a_fill_naming_a_slot_the_screen_does_not_have_draws_nowhere() {
2723 let ctx = egui::Context::default();
2724 let immediate = renderer().with_fill("elsewhere", |_: &Immediate, ui: &mut egui::Ui| {
2725 ui.label("PLAYING");
2726 });
2727
2728 let shapes = painted(
2729 &ctx,
2730 &immediate,
2731 &with_a_transport(),
2732 &mut View::new(),
2733 600.0,
2734 );
2735
2736 assert!(!shapes.contains("PLAYING"));
2737 }
2738
2739 #[test]
2740 fn a_toast_goes_away_on_its_own_and_a_banner_stays() {
2741 // `4453bf82`. The description says which of the two a message is and never
2742 // says how long a toast keeps: the when is this renderer's, so this is the
2743 // host being the thing that takes it away.
2744 let mut runtime = Runtime::new(screen_of([Node::text("Tasks")]));
2745 runtime.apply(
2746 &Request::post("/tasks/1/done"),
2747 Response::from(Outcome::Screen(screen_of([Node::text("Done")])))
2748 .toast(layout::Tone::Success, "Task completed"),
2749 );
2750 runtime.apply(
2751 &Request::post("/sync"),
2752 Response::from(Outcome::Fragment {
2753 region: "main".to_owned(),
2754 node: Node::text("Done"),
2755 })
2756 .banner(layout::Tone::Danger, "Sync is failing"),
2757 );
2758 assert_eq!(runtime.screen().notices.len(), 2);
2759
2760 let start = std::time::Instant::now();
2761 assert!(!runtime.expires_at(start), "nothing goes early");
2762 assert!(
2763 runtime
2764 .tick_in_at(start)
2765 .is_some_and(|wait| wait <= crate::LINGER),
2766 "and the host is told when to come back"
2767 );
2768
2769 assert!(runtime.expires_at(start + crate::LINGER + std::time::Duration::from_secs(1)));
2770 let left = &runtime.screen().notices;
2771 assert_eq!(left.len(), 1, "{left:?}");
2772 assert!(
2773 matches!(&left[0], Node::Notice { text, .. } if text == "Sync is failing"),
2774 "the banner is the one that stays: {left:?}"
2775 );
2776 // Nothing left on a clock, so the host may sleep as it did before.
2777 assert!(!runtime.expires_at(start + crate::LINGER * 4));
2778 assert_eq!(runtime.tick_in_at(start), None);
2779 }
2780
2781 #[test]
2782 fn a_toast_arriving_on_a_screen_lingers_from_when_the_screen_did() {
2783 // The other way a toast joins a screen: described onto one rather than said
2784 // by a response.
2785 let mut runtime = Runtime::new(screen_of([Node::text("Tasks")]));
2786 let arriving = screen_of([Node::text("Today")]).saying(Node::Notice {
2787 kind: layout::Notice::Toast,
2788 tone: layout::Tone::Info,
2789 text: "Welcome back".to_owned(),
2790 act: None,
2791 });
2792 runtime.apply(&Request::get("/tasks"), Response::screen(arriving));
2793
2794 let landed = std::time::Instant::now();
2795 assert!(!runtime.expires_at(landed));
2796 assert_eq!(runtime.screen().notices.len(), 1);
2797 assert!(runtime.expires_at(landed + crate::LINGER + std::time::Duration::from_secs(1)));
2798 assert!(runtime.screen().notices.is_empty());
2799 assert_eq!(runtime.tick_in_at(landed), None);
2800 }
2801
2802 #[test]
2803 fn a_screen_asks_for_the_frame_its_readouts_need() {
2804 // egui sleeps between frames, so a stopwatch nobody asked to redraw is a
2805 // stopwatch that stops. This is the number `show` hands the context.
2806 let at = std::time::SystemTime::UNIX_EPOCH;
2807 let still = Runtime::new(screen_of([Node::text("Write the brief")]));
2808 assert_eq!(still.tick_in(), None);
2809
2810 let stamped = Runtime::new(screen_of([Node::age(at)]));
2811 assert_eq!(stamped.tick_in(), Some(crate::COARSE));
2812
2813 // The finest of the kinds on the screen, so one wake serves both.
2814 let both = Runtime::new(screen_of([Node::age(at), Node::since(at)]));
2815 assert_eq!(both.tick_in(), Some(crate::TICK));
2816 }
2817
2818 #[test]
2819 fn a_readout_in_a_row_is_drawn_where_the_row_puts_it() {
2820 // The measured shape, and the one a cell walk could quietly drop: goingson
2821 // puts the elapsed time on a task row beside its title.
2822 let mut view = View::new();
2823 let started = std::time::SystemTime::now() - std::time::Duration::from_secs(65);
2824 let row =
2825 quasi_router::Row::new("Write the brief").part(layout::RowPart::Meta, Node::since(started));
2826 draw(&screen_of([Node::list([row])]), &mut view);
2827
2828 // And in a table cell, which is the other run this renderer walks by hand.
2829 let cell = quasi_router::Cell::new("Write the brief");
2830 draw(
2831 &screen_of([Node::Table {
2832 marks: ::quasi_router::stage::Marks::none(),
2833 columns: vec![quasi_router::Column::new("Task")],
2834 rows: vec![quasi_router::Row::cells([
2835 cell,
2836 quasi_router::Cell {
2837 content: vec![Node::since(started)],
2838 ..quasi_router::Cell::default()
2839 },
2840 ])],
2841 more: None,
2842 }]),
2843 &mut view,
2844 );
2845 }
2846
2847 /// A file leaves by `handed` rather than by the return value, and the screen
2848 /// the control was pressed on is still the screen showing.
2849 #[test]
2850 fn a_file_answer_is_handed_over_and_changes_nothing_on_screen() {
2851 let mut runtime = Runtime::new(screen_of([Node::text("settings")]));
2852 let follow_up = runtime.apply(
2853 &Request::get("/data/export/json"),
2854 Response::file(
2855 "goingson-export.json",
2856 quasi_router::Accepted::media_type("application/json"),
2857 br#"{"tasks":[]}"#.to_vec(),
2858 ),
2859 );
2860
2861 assert_eq!(follow_up, None);
2862 let handed = runtime.handed().expect("the answer handed a file over");
2863 assert_eq!(handed.name, "goingson-export.json");
2864 assert_eq!(handed.bytes, br#"{"tasks":[]}"#);
2865 assert_eq!(
2866 handed.kind,
2867 quasi_router::Accepted::Type("application/json".into())
2868 );
2869 // Nothing on the screen moved: a file is not a region and not a place.
2870 assert_eq!(runtime.handed(), None);
2871 }
2872
2873 /// An ask for a place leaves by `locating`, drains once, and leaves the screen
2874 /// the control was pressed on showing.
2875 #[test]
2876 fn an_ask_for_a_place_leaves_by_locating_and_changes_nothing_on_screen() {
2877 let mut runtime = Runtime::new(screen_of([Node::text("import")]));
2878 let follow_up = runtime.apply(
2879 &Request::post("/import/open"),
2880 Response::locate(quasi_router::Locating::folder(
2881 "Import folder",
2882 Action::post("/import/from"),
2883 "folder",
2884 )),
2885 );
2886
2887 assert_eq!(follow_up, None);
2888 let asking = runtime.locating().expect("the answer asked for a place");
2889 assert_eq!(asking.sought, quasi_router::Sought::Folder);
2890 assert_eq!(asking.prompt, "Import folder");
2891 // The picker is the host's furniture: nothing was drawn, nothing navigated,
2892 // and a second drain gets the ask no second time.
2893 assert_eq!(runtime.locating(), None);
2894
2895 // What the reader picked comes back as an ordinary request, built by the
2896 // crate that stated the parameter name rather than by the host.
2897 let answered = asking
2898 .answered([quasi_router::Picked::new("/home/max/samples", "samples")])
2899 .expect("a route to answer to");
2900 assert_eq!(
2901 answered,
2902 Request::post("/import/from")
2903 .sending(quasi_router::Params::new().with("folder", "/home/max/samples"))
2904 );
2905 }
2906
2907 /// The save shape reaches the host whole, suggested name included, because the
2908 /// name is the reason the dialog is opened rather than a folder picker.
2909 #[test]
2910 fn a_save_ask_carries_its_suggested_name_out_to_the_host() {
2911 let mut runtime = Runtime::new(screen_of([Node::text("classifier")]));
2912 let follow_up = runtime.apply(
2913 &Request::post("/classifier/open"),
2914 Response::locate(quasi_router::Locating::new(
2915 quasi_router::Sought::Save {
2916 name: "drums-2026-08-25.afcl".into(),
2917 accept: vec![quasi_router::Accepted::suffix(".afcl")],
2918 },
2919 "Export classifier",
2920 Action::post("/classifier/export"),
2921 "path",
2922 )),
2923 );
2924
2925 assert_eq!(follow_up, None);
2926 let asking = runtime.locating().expect("the answer asked for a place");
2927 let quasi_router::Sought::Save { name, accept } = &asking.sought else {
2928 panic!("a save ask");
2929 };
2930 assert_eq!(name, "drums-2026-08-25.afcl");
2931 assert_eq!(accept, &[quasi_router::Accepted::Suffix(".afcl".into())]);
2932 assert_eq!(asking.prompt, "Export classifier");
2933
2934 let answered = asking
2935 .answered([quasi_router::Picked::new(
2936 "/home/max/exports/drums.afcl",
2937 "drums.afcl",
2938 )])
2939 .expect("a route to answer to");
2940 assert_eq!(
2941 answered,
2942 Request::post("/classifier/export")
2943 .sending(quasi_router::Params::new().with("path", "/home/max/exports/drums.afcl"))
2944 );
2945 }
2946
2947 /// Several files picked at once are one call, which is what keeps a batched
2948 /// import a batch.
2949 #[test]
2950 fn several_picked_files_answer_the_ask_once() {
2951 let mut runtime = Runtime::new(screen_of([Node::text("import")]));
2952 let follow_up = runtime.apply(
2953 &Request::post("/import/open"),
2954 Response::locate(quasi_router::Locating::new(
2955 quasi_router::Sought::Files {
2956 accept: vec![quasi_router::Accepted::suffix(".wav")],
2957 },
2958 "Import files",
2959 Action::post("/import/files"),
2960 "path",
2961 )),
2962 );
2963
2964 assert_eq!(follow_up, None);
2965 let asking = runtime.locating().expect("the answer asked for a place");
2966 let answered = asking
2967 .answered([
2968 quasi_router::Picked::new("/tmp/a.wav", "a.wav"),
2969 quasi_router::Picked::new("/tmp/b.wav", "b.wav"),
2970 ])
2971 .expect("a route to answer to");
2972 assert_eq!(
2973 answered,
2974 Request::post("/import/files").sending(
2975 quasi_router::Params::new()
2976 .with("path", "/tmp/a.wav")
2977 .with("path", "/tmp/b.wav")
2978 )
2979 );
2980 }
2981
2982 /// A reader who backs out of the picker has answered nothing, so the host makes
2983 /// no call and the runtime is not told. Nothing to assert but the absence, and
2984 /// the absence is the design: a cancelled picker costs the screen nothing.
2985 #[test]
2986 fn a_screen_with_no_ask_hands_out_no_place() {
2987 let mut runtime = Runtime::new(screen_of([Node::text("import")]));
2988 assert_eq!(runtime.locating(), None);
2989 }
2990
2991 /// The name is sanitised where the host cannot forget to do it. This runtime
2992 /// writes nothing itself, so a host taking the name at face value is exactly
2993 /// the failure -- `../../.ssh/authorized_keys` beside the process.
2994 #[test]
2995 fn a_handed_file_carries_a_name_no_host_can_traverse_with() {
2996 let mut runtime = Runtime::new(screen_of([Node::text("settings")]));
2997 runtime.apply(
2998 &Request::get("/data/export"),
2999 Response::file(
3000 "../../.ssh/authorized_keys",
3001 quasi_router::Accepted::suffix(".txt"),
3002 b"ssh-rsa".to_vec(),
3003 ),
3004 );
3005
3006 let handed = runtime.handed().expect("the answer handed a file over");
3007 assert!(!handed.name.contains('/'));
3008 assert!(!handed.name.contains(".."));
3009 }
3010
3011 /// A host that never drains it drops the download, which is the cost of keeping
3012 /// the writing out of this crate. A second file replaces the first rather than
3013 /// queueing: two files from one answer is not something the vocabulary says.
3014 #[test]
3015 fn a_second_file_replaces_the_one_the_host_never_took() {
3016 let mut runtime = Runtime::new(screen_of([Node::text("settings")]));
3017 for name in ["first.json", "second.json"] {
3018 runtime.apply(
3019 &Request::get("/data/export"),
3020 Response::file(
3021 name,
3022 quasi_router::Accepted::media_type("application/json"),
3023 b"{}".to_vec(),
3024 ),
3025 );
3026 }
3027 assert_eq!(
3028 runtime.handed().map(|handed| handed.name),
3029 Some("second.json".to_owned())
3030 );
3031 }
3032
3033 /// One frame of a runtime, with these key events delivered to it.
3034 ///
3035 /// The same context across frames, because focus is memory and the guard being
3036 /// tested reads the focus a previous frame left behind.
3037 fn press(
3038 ctx: &egui::Context,
3039 runtime: &mut Runtime,
3040 immediate: &Immediate,
3041 events: Vec<egui::Event>,
3042 ) -> Step {
3043 let modifiers = events
3044 .iter()
3045 .find_map(|event| match event {
3046 egui::Event::Key { modifiers, .. } => Some(*modifiers),
3047 _ => None,
3048 })
3049 .unwrap_or(egui::Modifiers::NONE);
3050 let input = egui::RawInput {
3051 screen_rect: Some(egui::Rect::from_min_size(
3052 egui::Pos2::ZERO,
3053 egui::vec2(900.0, 700.0),
3054 )),
3055 events,
3056 modifiers,
3057 ..Default::default()
3058 };
3059 let mut step = Step::Idle;
3060 let _ = ctx.clone().run_ui(input, |ui| {
3061 step = runtime.show(ui, immediate);
3062 });
3063 step
3064 }
3065
3066 fn key(key: egui::Key, modifiers: egui::Modifiers) -> Vec<egui::Event> {
3067 vec![egui::Event::Key {
3068 key,
3069 physical_key: None,
3070 pressed: true,
3071 repeat: false,
3072 modifiers,
3073 }]
3074 }
3075
3076 #[test]
3077 fn a_box_with_the_focus_keeps_the_letters_a_binding_would_have_taken() {
3078 // `bc5528e2`. audiofiles has 26 shortcuts and 16 of them are bare letters,
3079 // and until this guard existed declaring one meant the tag field and the
3080 // search box stopped accepting that letter: the binding fired first and the
3081 // character never arrived.
3082 let chrome = Chrome::new().bind("s", "Sync", Action::get("/sync")).bind(
3083 "ctrl+t",
3084 "Theme",
3085 Action::get("/theme"),
3086 );
3087 let mut runtime = Runtime::new(screen_of([Node::Field(Box::new(Field::new(
3088 layout::FieldKind::Text,
3089 "search",
3090 "Search",
3091 )))]))
3092 .with_chrome(chrome);
3093 let immediate = renderer();
3094 let ctx = egui::Context::default();
3095
3096 // Nothing is focused yet, so the bare key is the app's. This is also the
3097 // control: without it a guard that suppressed everything would pass.
3098 assert_eq!(
3099 press(
3100 &ctx,
3101 &mut runtime,
3102 &immediate,
3103 key(egui::Key::S, egui::Modifiers::NONE)
3104 ),
3105 Step::Call(Request::get("/sync")),
3106 "a bare binding must still fire when no box is answering the keyboard"
3107 );
3108
3109 // Tab reaches the one focusable widget on the screen, which is the field.
3110 let _ = press(
3111 &ctx,
3112 &mut runtime,
3113 &immediate,
3114 key(egui::Key::Tab, egui::Modifiers::NONE),
3115 );
3116 assert!(
3117 ctx.text_edit_focused(),
3118 "the harness never focused the field, so the assertions below prove nothing"
3119 );
3120
3121 // The letter belongs to the box now.
3122 assert_eq!(
3123 press(
3124 &ctx,
3125 &mut runtime,
3126 &immediate,
3127 key(egui::Key::S, egui::Modifiers::NONE)
3128 ),
3129 Step::Idle,
3130 "the binding took a letter the box was going to receive"
3131 );
3132
3133 // And the half the shipped app's broader rule would have lost: a held key
3134 // produces no character, so it was never the box's to begin with.
3135 assert_eq!(
3136 press(
3137 &ctx,
3138 &mut runtime,
3139 &immediate,
3140 key(egui::Key::T, egui::Modifiers::CTRL)
3141 ),
3142 Step::Call(Request::get("/theme")),
3143 "a modified binding stopped working while a box had the focus"
3144 );
3145 }
3146
3147 #[test]
3148 fn shift_is_typing_and_command_is_not() {
3149 // The rule the guard turns on, asserted where it is readable. Shift is how
3150 // a keyboard makes a capital letter, so shift+key is a box's; the other
3151 // three produce no character on any layout.
3152 use egui::Modifiers;
3153 assert!(types(Modifiers::NONE));
3154 assert!(types(Modifiers::SHIFT));
3155 assert!(!types(Modifiers::CTRL));
3156 assert!(!types(Modifiers::ALT));
3157 assert!(!types(Modifiers::COMMAND));
3158 assert!(!types(Modifiers::CTRL | Modifiers::SHIFT));
3159 }
3160
3161 #[test]
3162 fn a_navigating_call_swaps_the_view_and_takes_the_overlay_with_it() {
3163 // `00ee7af5`. The same sentence the webview says with an anchor and the
3164 // terminal by pushing a screen: the whole view is being replaced, so the
3165 // call is made and whatever is floating over the place being left is put
3166 // away on the way out rather than left hanging over wherever the answer
3167 // lands.
3168 let chrome = Chrome::new().bind(
3169 "g",
3170 "Slow Reader",
3171 Action::get("/p/slow-reader").navigating(),
3172 );
3173 let mut runtime = Runtime::new(screen_of([Node::text("discover")])).with_chrome(chrome);
3174 runtime.apply(
3175 &Request::get("/discover/suggest"),
3176 Response {
3177 outcome: Outcome::Over(screen_of([Node::text("suggestions")])),
3178 notice: None,
3179 address: None,
3180 invalidates: Vec::new(),
3181 },
3182 );
3183 assert!(runtime.overlaid());
3184
3185 let immediate = renderer();
3186 let ctx = egui::Context::default();
3187 let step = press(
3188 &ctx,
3189 &mut runtime,
3190 &immediate,
3191 key(egui::Key::G, egui::Modifiers::NONE),
3192 );
3193 let Step::Call(request) = step else {
3194 panic!("a navigating call was not made: {step:?}");
3195 };
3196 assert_eq!(request.path, "/p/slow-reader");
3197 // Before the answer, which is the point: nothing has come back yet and the
3198 // overlay is already gone.
3199 assert!(!runtime.overlaid());
3200 }
3201
3202 #[test]
3203 fn an_ordinary_call_leaves_the_overlay_where_it_is() {
3204 // The mark is what puts the overlay away. A call without one that answers a
3205 // fragment still answers it into what is showing.
3206 let chrome = Chrome::new().bind("g", "Refine", Action::get("/discover/refine"));
3207 let mut runtime = Runtime::new(screen_of([Node::text("discover")])).with_chrome(chrome);
3208 runtime.apply(
3209 &Request::get("/discover/suggest"),
3210 Response {
3211 outcome: Outcome::Over(screen_of([Node::text("suggestions")])),
3212 notice: None,
3213 address: None,
3214 invalidates: Vec::new(),
3215 },
3216 );
3217
3218 let immediate = renderer();
3219 let ctx = egui::Context::default();
3220 let step = press(
3221 &ctx,
3222 &mut runtime,
3223 &immediate,
3224 key(egui::Key::G, egui::Modifiers::NONE),
3225 );
3226 assert!(matches!(step, Step::Call(_)), "{step:?}");
3227 assert!(runtime.overlaid());
3228 }
3229
3230 #[test]
3231 fn a_call_that_goes_elsewhere_is_handed_over_rather_than_made() {
3232 // goingson `3fb2526a`. A mount here is a viewport, and putting one up is
3233 // the host's the same way a file dialog is. So the runtime builds the
3234 // request and hands it over instead of making it: what comes back must be
3235 // `Mount` and not `Call`, or the screen replaces the one that asked for a
3236 // second surface rather than appearing beside it.
3237 let chrome = Chrome::new().bind(
3238 "n",
3239 "New message",
3240 Action::get("/compose/7")
3241 .elsewhere()
3242 .carrying("folder", "drafts"),
3243 );
3244 let mut runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome);
3245 let immediate = renderer();
3246 let ctx = egui::Context::default();
3247
3248 let step = press(
3249 &ctx,
3250 &mut runtime,
3251 &immediate,
3252 key(egui::Key::N, egui::Modifiers::NONE),
3253 );
3254 let Step::Mount(request) = step else {
3255 panic!("a call marked elsewhere was made here: {step:?}");
3256 };
3257 assert_eq!(request.path, "/compose/7");
3258 // The view rides along, so the mount comes up on the place the control was
3259 // offered under rather than on a default.
3260 assert_eq!(request.carried.get("folder"), Some("drafts"));
3261 }
3262
3263 #[test]
3264 fn a_bare_binding_does_not_answer_for_its_shifted_twin() {
3265 // audiofiles binds `f` to the forge and `shift+f` to Find similar, which is
3266 // two entries a table is entitled to hold and `matches_logically` cannot
3267 // tell apart: it ignores a shift the pattern never asked for, so the bare
3268 // one matched both and the shifted one was unreachable. Only visible once
3269 // bare letters could be declared at all.
3270 let chrome = Chrome::new()
3271 .bind("f", "Forge", Action::get("/forge"))
3272 .bind("shift+f", "Find similar", Action::post("/detail/similar"));
3273 let mut runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome);
3274 let immediate = renderer();
3275 let ctx = egui::Context::default();
3276
3277 assert_eq!(
3278 press(
3279 &ctx,
3280 &mut runtime,
3281 &immediate,
3282 key(egui::Key::F, egui::Modifiers::NONE)
3283 ),
3284 Step::Call(Request::get("/forge"))
3285 );
3286 assert_eq!(
3287 press(
3288 &ctx,
3289 &mut runtime,
3290 &immediate,
3291 key(egui::Key::F, egui::Modifiers::SHIFT)
3292 ),
3293 Step::Call(Request::post("/detail/similar")),
3294 "the shifted binding is unreachable behind its bare twin"
3295 );
3296 }
3297
3298 /// The act names a box on the screen and the press puts its value in, and this
3299 /// renderer's answer to "where in it" is the end.
3300 #[test]
3301 fn a_control_that_deposits_a_value_puts_it_in_the_box_it_named() {
3302 let screen = screen_of([
3303 Node::Field(Box::new(
3304 Field::new(layout::FieldKind::Text, "body", "Body").value("Intro. "),
3305 )),
3306 Node::Act(Act::new("kick.png", Action::local()).filling("body", "![](media/kick.png)")),
3307 ]);
3308
3309 let mut host = Host::new();
3310 host.settle(&screen);
3311 // Local, so what comes back names no route and the runtime's own guard is
3312 // what stops it becoming a call. See
3313 // `a_local_action_is_not_an_address_handed_to_the_host`.
3314 let fired = host
3315 .click(&screen, "kick.png")
3316 .expect("the press is noticed");
3317 assert_eq!(fired.action, Action::local());
3318
3319 // After what the box was already showing, not over it. That is the whole
3320 // reason the member exists: a server-side append loses the draft.
3321 assert_eq!(host.view.edit("body"), Some("Intro. ![](media/kick.png)"));
3322 }
3323
3324 /// Twice is twice. Nothing here is idempotent and nothing should be: a reader
3325 /// inserting two images pressed two cards.
3326 #[test]
3327 fn two_deposits_land_one_after_the_other() {
3328 let screen = screen_of([
3329 Node::Field(Box::new(Field::new(
3330 layout::FieldKind::Text,
3331 "body",
3332 "Body",
3333 ))),
3334 Node::Act(Act::new("one", Action::local()).filling("body", "a")),
3335 Node::Act(Act::new("two", Action::local()).filling("body", "b")),
3336 ]);
3337
3338 let mut host = Host::new();
3339 host.settle(&screen);
3340 host.click(&screen, "one");
3341 host.click(&screen, "two");
3342
3343 assert_eq!(host.view.edit("body"), Some("ab"));
3344 }
3345
3346 /// An ordinary control writes into nothing, or every press on every screen
3347 /// starts touching a box.
3348 #[test]
3349 fn a_control_that_names_no_box_writes_into_none() {
3350 let screen = screen_of([
3351 Node::Field(Box::new(
3352 Field::new(layout::FieldKind::Text, "body", "Body").value("Intro."),
3353 )),
3354 Node::Act(Act::new("Save", Action::post("/save"))),
3355 ]);
3356
3357 let mut host = Host::new();
3358 host.settle(&screen);
3359 let fired = host.click(&screen, "Save").expect("the route is called");
3360 assert_eq!(fired.action, Action::post("/save"));
3361 assert_eq!(host.view.edit("body"), Some("Intro."));
3362 }
3363
3364 /// What the accessibility tree says a screen drew, as `(role, name)` pairs.
3365 ///
3366 /// egui builds it from the `WidgetInfo` each widget reports, so this is what a
3367 /// screen reader would be handed rather than a second opinion about it.
3368 fn announced(screen: &Screen, view: &mut View) -> Vec<(egui::accesskit::Role, String)> {
3369 let immediate = renderer();
3370 let ctx = egui::Context::default();
3371 ctx.enable_accesskit();
3372 let input = || egui::RawInput {
3373 screen_rect: Some(egui::Rect::from_min_size(
3374 egui::Pos2::ZERO,
3375 egui::vec2(900.0, 600.0),
3376 )),
3377 ..Default::default()
3378 };
3379 // Two passes: egui lays out against the previous frame, so the first sees
3380 // widgets at the wrong rect and a row's strip has no rect to claim yet.
3381 let _ = ctx.run_ui(input(), |ui| {
3382 immediate.screen(ui, screen, view);
3383 });
3384 let out = ctx.run_ui(input(), |ui| {
3385 immediate.screen(ui, screen, view);
3386 });
3387
3388 out.platform_output
3389 .accesskit_update
3390 .expect("accesskit is on, so a tree was built")
3391 .nodes
3392 .iter()
3393 .map(|(_, node)| {
3394 (
3395 node.role(),
3396 node.label()
3397 .or_else(|| node.value())
3398 .unwrap_or_default()
3399 .to_owned(),
3400 )
3401 })
3402 .collect()
3403 }
3404
3405 #[test]
3406 fn a_row_that_opens_is_announced_as_something_you_press() {
3407 // The row's press is a bare `ui.interact`, which registers no `WidgetInfo`,
3408 // so before 2026-08-22 this row reached the tree as nothing at all: it
3409 // worked under a mouse and did not exist for a keyboard or a screen reader.
3410 let screen = screen_of([Node::list([
3411 quasi_router::Row::new("Standup").activate(Action::post("/notes/1")),
3412 quasi_router::Row::new("Review").activate(Action::post("/notes/2")),
3413 ])]);
3414 let mut view = View::new();
3415 let drawn = announced(&screen, &mut view);
3416
3417 for named in ["Standup", "Review"] {
3418 assert!(
3419 drawn
3420 .iter()
3421 .any(|(role, name)| *role == egui::accesskit::Role::Button && name == named),
3422 "the row that opens {named} is not in the tree: {drawn:?}"
3423 );
3424 }
3425 }
3426
3427 #[test]
3428 fn a_row_that_only_lists_claims_nothing() {
3429 // Nothing to press, so nothing to announce. The counterpart of the test
3430 // above, and what stops the fix from calling every row a button.
3431 let screen = screen_of([Node::list([quasi_router::Row::new("Standup")])]);
3432 let mut view = View::new();
3433 let drawn = announced(&screen, &mut view);
3434
3435 assert!(
3436 !drawn
3437 .iter()
3438 .any(|(role, _)| *role == egui::accesskit::Role::Button),
3439 "{drawn:?}"
3440 );
3441 }
3442
3443 #[test]
3444 fn a_table_row_that_opens_is_announced_once() {
3445 // The table half of the same defect the list half fixed. Claimed per cell,
3446 // so the announcement goes on the first column only: saying it per cell
3447 // would put one button per column in the tree, all called the same thing.
3448 let screen = screen_of([Node::Table {
3449 marks: ::quasi_router::stage::Marks::none(),
3450 columns: vec![
3451 quasi_router::Column::new("Name"),
3452 quasi_router::Column::new("Tempo"),
3453 ],
3454 rows: vec![
3455 quasi_router::Row::cells(vec![
3456 quasi_router::Cell::new("kick.wav"),
3457 quasi_router::Cell::new("90"),
3458 ])
3459 .activate(Action::post("/files/1/open")),
3460 ],
3461 more: None,
3462 }]);
3463 let mut view = View::new();
3464 let drawn = announced(&screen, &mut view);
3465
3466 let named: Vec<_> = drawn
3467 .iter()
3468 .filter(|(role, name)| *role == egui::accesskit::Role::Button && name == "kick.wav")
3469 .collect();
3470 assert_eq!(named.len(), 1, "once, not once per column: {drawn:?}");
3471 }
3472
3473 #[test]
3474 fn a_table_row_that_only_lists_claims_nothing() {
3475 let screen = screen_of([Node::Table {
3476 marks: ::quasi_router::stage::Marks::none(),
3477 columns: vec![quasi_router::Column::new("Name")],
3478 rows: vec![quasi_router::Row::cells(vec![quasi_router::Cell::new(
3479 "kick.wav",
3480 )])],
3481 more: None,
3482 }]);
3483 let mut view = View::new();
3484 let drawn = announced(&screen, &mut view);
3485
3486 assert!(
3487 !drawn
3488 .iter()
3489 .any(|(role, _)| *role == egui::accesskit::Role::Button),
3490 "{drawn:?}"
3491 );
3492 }
3493
3494 /// A region says which control and which value bring it out, and this renderer
3495 /// answers it from the view it is already holding. What proves the region was
3496 /// not drawn is the buffer: drawing a field seeds one from the description, so
3497 /// a field with no buffer after a frame is a field that was never on the
3498 /// screen.
3499 #[test]
3500 fn a_region_whose_control_holds_nothing_is_not_drawn() {
3501 let screen = Screen::sidebar_content("Pricing").with(
3502 Slot::new("main", RegionKind::Pane)
3503 .with(Node::Field(Box::new(Field::new(
3504 layout::FieldKind::Checkbox,
3505 "pwyw",
3506 "Pay what you want",
3507 ))))
3508 .with(Node::Region(
3509 Slot::group("pwyw-settings")
3510 .revealed_by(quasi_router::Reveal::ticked("pwyw"))
3511 .with(Node::Field(Box::new(
3512 Field::new(layout::FieldKind::Number, "suggested", "Suggested price")
3513 .value("12"),
3514 ))),
3515 )),
3516 );
3517
3518 let mut view = View::new();
3519 draw(&screen, &mut view);
3520 assert_eq!(
3521 view.edit("suggested"),
3522 None,
3523 "the section was drawn on a control holding nothing"
3524 );
3525
3526 // Ticked, and the same description draws it, with nothing asked for: the
3527 // region carries no call and the reveal is local.
3528 let mut view = View::new();
3529 view.set("pwyw", quasi_router::Node::SELECTED);
3530 draw(&screen, &mut view);
3531 assert_eq!(view.edit("suggested"), Some("12"));
3532 }
3533
3534 /// An untouched control holds what the description offered, so a section
3535 /// arrives already out on a form the server refilled.
3536 #[test]
3537 fn a_region_reads_the_value_the_description_offered() {
3538 let screen = Screen::sidebar_content("Licensing").with(
3539 Slot::new("main", RegionKind::Pane)
3540 .with(Node::Field(Box::new(
3541 Field::new(layout::FieldKind::Text, "license_kind", "Licence").value("custom"),
3542 )))
3543 .with(Node::Region(
3544 Slot::group("dash-custom-license")
3545 .revealed_by(quasi_router::Reveal::holding("license_kind", "custom"))
3546 .with(Node::Field(Box::new(
3547 Field::new(layout::FieldKind::Text, "licence_text", "Terms")
3548 .value("All rights reserved"),
3549 ))),
3550 )),
3551 );
3552
3553 let mut view = View::new();
3554 draw(&screen, &mut view);
3555 assert_eq!(view.edit("licence_text"), Some("All rights reserved"));
3556 }
3557
3558 /// The two client renderers answer one condition the same way, which is what
3559 /// keeps a screen described once from being two screens.
3560 #[test]
3561 fn the_set_of_regions_that_do_not_apply_is_computed_from_the_view() {
3562 let screen = Screen::sidebar_content("Placement").with(
3563 Slot::new("main", RegionKind::Pane).with(Node::Region(
3564 Slot::group("offset-input")
3565 .revealed_by(quasi_router::Reveal::holding_one_of(
3566 "position",
3567 ["before", "after"],
3568 ))
3569 .with(Node::text("Offset")),
3570 )),
3571 );
3572
3573 let mut view = View::new();
3574 let chrome = Chrome::new();
3575 assert_eq!(
3576 crate::reveal::hidden(&screen, &chrome, &view).regions,
3577 vec!["offset-input"]
3578 );
3579 view.set("position", "after");
3580 assert!(
3581 crate::reveal::hidden(&screen, &chrome, &view)
3582 .regions
3583 .is_empty()
3584 );
3585 view.set("position", "inline");
3586 assert_eq!(
3587 crate::reveal::hidden(&screen, &chrome, &view).regions,
3588 vec!["offset-input"]
3589 );
3590 }
3591
3592 // One conditional question inside a form: `8fdb814c`, goingson's zone picker.
3593
3594 /// goingson's event form. The condition is the question's own because a form's
3595 /// questions are a flat list, and this renderer answers it exactly as it
3596 /// answers a region's: the box is not drawn, and no route is asked.
3597 #[test]
3598 fn a_question_whose_control_holds_another_value_is_not_drawn() {
3599 let screen = Screen::sidebar_content("Event").with(Slot::new("main", RegionKind::Pane).with(
3600 Node::Form {
3601 marks: ::quasi_router::stage::Marks::none(),
3602 action: Action::post("/events"),
3603 submit: "Save".into(),
3604 fields: vec![
3605 Field::new(layout::FieldKind::Text, "tz_kind", "Time zone").value("relative"),
3606 Field::new(layout::FieldKind::Text, "timezone", "Anchored to")
3607 .value("America/Denver")
3608 .revealed_by(quasi_router::Reveal::holding("tz_kind", "local")),
3609 ],
3610 },
3611 ));
3612
3613 // Drawing a field seeds a buffer from the description, so no buffer is a
3614 // question that was never on the screen.
3615 let mut view = View::new();
3616 draw(&screen, &mut view);
3617 assert_eq!(view.edit("timezone"), None);
3618
3619 let mut view = View::new();
3620 view.set("tz_kind", "local");
3621 draw(&screen, &mut view);
3622 assert_eq!(view.edit("timezone"), Some("America/Denver"));
3623 }
3624
3625 // A question answered N times: `60d1753c`, ruled 2026-08-25.
3626
3627 /// The reminders question goingson `8fdb814c` restores.
3628 fn reminders() -> Field {
3629 Field::new(layout::FieldKind::Number, "reminder", "Reminder").repeating(
3630 quasi_router::Repeat::answered(["300", "900"])
3631 .most(8)
3632 .adding("Add reminder")
3633 .removing("Remove"),
3634 )
3635 }
3636
3637 fn reminders_form() -> Screen {
3638 screen_of([Node::Form {
3639 marks: ::quasi_router::stage::Marks::none(),
3640 action: Action::post("/events"),
3641 submit: "Save".into(),
3642 fields: vec![reminders()],
3643 }])
3644 }
3645
3646 /// Every slot is drawn, each under its own indexed name. What proves a slot was
3647 /// drawn is its buffer: drawing a field seeds one from the description.
3648 #[test]
3649 fn every_slot_of_a_repeating_question_is_drawn_under_its_own_name() {
3650 let mut view = View::new();
3651 draw(&reminders_form(), &mut view);
3652
3653 assert_eq!(view.edit("reminder[0]"), Some("300"));
3654 assert_eq!(view.edit("reminder[1]"), Some("900"));
3655 assert_eq!(view.edit("reminder[2]"), None);
3656 // And the bare name submits nothing: the question is the group, and the
3657 // answers are the slots.
3658 assert_eq!(view.edit("reminder"), None);
3659 }
3660
3661 /// The third hard part, in the host with no document: adding a slot is a fact
3662 /// the view records, and nothing is asked of a route.
3663 #[test]
3664 fn adding_and_removing_a_slot_asks_no_route() {
3665 let screen = reminders_form();
3666 let mut host = Host::new();
3667 host.settle(&screen);
3668 assert_eq!(host.view.standing(&reminders()), 2);
3669
3670 let fired = host.click(&screen, "Add reminder");
3671 assert!(fired.is_none(), "adding a slot called a route");
3672 assert_eq!(host.view.standing(&reminders()), 3);
3673
3674 let fired = host.click(&screen, "Remove");
3675 assert!(fired.is_none(), "removing a slot called a route");
3676 assert_eq!(host.view.standing(&reminders()), 2);
3677 }
3678
3679 /// One submit carrying every instance, which is the whole of what separates
3680 /// this from a list of forms.
3681 #[test]
3682 fn one_submit_carries_every_slot() {
3683 let screen = reminders_form();
3684 let mut host = Host::new();
3685 host.settle(&screen);
3686 host.click(&screen, "Add reminder");
3687 host.view.set("reminder[2]", "7200");
3688 host.settle(&screen);
3689
3690 let fired = host.click(&screen, "Save").expect("the form submits");
3691 let sent: Vec<(&str, &str)> = fired.payload.iter().collect();
3692 assert_eq!(
3693 sent,
3694 [
3695 ("reminder[0]", "300"),
3696 ("reminder[1]", "900"),
3697 ("reminder[2]", "7200"),
3698 ],
3699 "one submit, three answers"
3700 );
3701 assert_eq!(fired.payload.repeated("reminder"), ["300", "900", "7200"]);
3702 }
3703
3704 /// Removing a slot moves the answers after it up, buffers and all. Leaving them
3705 /// where they were would submit a hole under the name the reader emptied.
3706 #[test]
3707 fn removing_a_slot_moves_the_answers_after_it_up() {
3708 let field = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
3709 .repeating(quasi_router::Repeat::answered(["300", "900", "3600"]));
3710 let mut view = View::new();
3711 view.set("reminder[0]", "300");
3712 view.set("reminder[1]", "900");
3713 view.set("reminder[2]", "3600");
3714
3715 view.remove_slot(&field, 0);
3716
3717 assert_eq!(view.standing(&field), 2);
3718 assert_eq!(view.edit("reminder[0]"), Some("900"));
3719 assert_eq!(view.edit("reminder[1]"), Some("3600"));
3720 assert_eq!(view.edit("reminder[2]"), None);
3721 }
3722
3723 /// The floor and the ceiling are the description's, and no press gets past
3724 /// them.
3725 #[test]
3726 fn the_floor_and_the_ceiling_hold() {
3727 let capped = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
3728 .repeating(quasi_router::Repeat::answered(["300", "900"]).most(2));
3729 let mut view = View::new();
3730 view.add_slot(&capped);
3731 assert_eq!(view.standing(&capped), 2, "the ceiling held");
3732
3733 let floored = Field::new(layout::FieldKind::Text, "guest", "Guest")
3734 .repeating(quasi_router::Repeat::answered(["ana"]).least(1));
3735 let mut view = View::new();
3736 view.remove_slot(&floored, 0);
3737 assert_eq!(view.standing(&floored), 1, "the floor held");
3738 }
3739
3740 /// A per-slot message belongs to its own box, and the question's own error is
3741 /// about the set. Both are drawn, and neither is the other.
3742 #[test]
3743 fn a_slot_carries_its_own_error() {
3744 let mut field = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
3745 .repeating(quasi_router::Repeat::answered(["300", "-1"]).wrong(1, "Must be positive"));
3746 field.error = Some("At most eight reminders".into());
3747
3748 assert_eq!(field.instance(1).error.as_deref(), Some("Must be positive"));
3749 assert_eq!(field.instance(0).error, None);
3750
3751 let mut host = Host::new();
3752 let screen = screen_of([Node::Field(Box::new(field))]);
3753 host.settle(&screen);
3754 // Both messages are on the screen: the slot's under its box, the set's
3755 // under the question.
3756 host.find("Must be positive");
3757 host.find("At most eight reminders");
3758 }
3759
3760 #[test]
3761 fn a_pending_region_draws_the_mark_and_not_a_spinner() {
3762 // `5eccb6aa`. `ui.spinner()` was the only true rotating animation in the
3763 // tree, and it was drawn for `Readiness::Pending` whatever the region was
3764 // waiting on. Asserted through accesskit rather than over pixels, which is
3765 // this module's rule: what matters is that the drawing happens and that it
3766 // is not a widget announcing itself as something else.
3767 let screen = Screen::sidebar_content("Test").with(
3768 Slot::new("payouts", RegionKind::Pane)
3769 .fed_by(Action::get("/dashboard/payouts").awaiting())
3770 .pending(),
3771 );
3772 let mut view = View::new();
3773 draw(&screen, &mut view);
3774 }
3775
3776 #[test]
3777 fn a_delivery_count_belongs_to_the_wait_that_was_running() {
3778 // Rule 1's other half. A count that survived into the next call would be
3779 // drawn against a payload it says nothing about, and a count arriving with
3780 // nothing outstanding has no wait to belong to at all.
3781 let mut view = View::new();
3782 view.delivered(4_096);
3783 assert_eq!(
3784 view.progress_at(std::time::Instant::now()).delivered,
3785 None,
3786 "nothing is outstanding, so there is nothing for the count to describe"
3787 );
3788
3789 let action = Action::post("/media").awaiting_amount(41_943_040);
3790 view.await_on(Some(action));
3791 view.delivered(10_485_760);
3792 let progress = view.progress_at(std::time::Instant::now());
3793 assert_eq!(progress.delivered, Some(10_485_760));
3794 assert!(
3795 progress.elapsed.is_some(),
3796 "the clock started with the call"
3797 );
3798
3799 // The next call starts clean rather than inheriting the last one's figure.
3800 view.await_on(Some(Action::post("/other").awaiting_amount(8)));
3801 assert_eq!(view.progress_at(std::time::Instant::now()).delivered, None);
3802
3803 // And an answered call leaves nothing behind.
3804 view.await_on(None);
3805 assert_eq!(view.progress_at(std::time::Instant::now()).elapsed, None);
3806 }
3807
3808 #[test]
3809 fn the_same_overlay_does_not_stack_on_itself() {
3810 let mut runtime = Runtime::new(screen_of([Node::text("under")]));
3811 let open = |runtime: &mut Runtime| {
3812 runtime.apply(
3813 &Request::get("/help"),
3814 Response {
3815 outcome: Outcome::Over(screen_of([Node::text("help")])),
3816 notice: None,
3817 address: None,
3818 invalidates: Vec::new(),
3819 },
3820 );
3821 };
3822
3823 open(&mut runtime);
3824 assert!(runtime.overlaid());
3825 // Four more presses. Every one is a fresh route call answering an equal but
3826 // distinct screen value, which is why the guard is the request.
3827 for _ in 0..4 {
3828 open(&mut runtime);
3829 }
3830
3831 // One layer, so one Escape rather than five.
3832 assert!(runtime.dismiss());
3833 assert!(!runtime.overlaid());
3834 assert!(!runtime.dismiss(), "and nothing left underneath it");
3835 }
3836
3837 /// The guard is the top layer only: a different overlay raised over one still
3838 /// stacks, which is what a confirm over a palette is.
3839 #[test]
3840 fn a_different_overlay_still_stacks_and_unwinds_in_order() {
3841 let mut runtime = Runtime::new(screen_of([Node::text("under")]));
3842 let raise = |runtime: &mut Runtime, path: &str, label: &str| {
3843 runtime.apply(
3844 &Request::get(path),
3845 Response {
3846 outcome: Outcome::Over(screen_of([Node::text(label)])),
3847 notice: None,
3848 address: None,
3849 invalidates: Vec::new(),
3850 },
3851 );
3852 };
3853
3854 raise(&mut runtime, "/palette", "palette");
3855 raise(&mut runtime, "/confirm", "confirm");
3856 // And the inner one refuses to stack on itself while it is on top.
3857 raise(&mut runtime, "/confirm", "confirm");
3858
3859 assert!(runtime.dismiss());
3860 assert!(runtime.overlaid(), "the palette is still up");
3861 // Back on the palette, its own identity is restored rather than lost, so it
3862 // still refuses to stack itself.
3863 raise(&mut runtime, "/palette", "palette");
3864 assert!(runtime.dismiss(), "the palette refused to stack on itself");
3865 assert!(!runtime.overlaid());
3866 assert!(!runtime.dismiss());
3867 }
3868
3869 /// A host that has to read a gesture the description does not carry
3870 /// -- audiofiles' drag into a DAW -- could not tell a press on a row from a
3871 /// press on the toolbar, and could not say which row. The description hands back
3872 /// no geometry and should not; the renderer knows where it drew and now says so.
3873 #[test]
3874 fn a_host_can_ask_which_described_row_it_drew_under_a_point() {
3875 let immediate = renderer();
3876 let screen = screen_of([Node::list([
3877 quasi_router::Row::new("kick.wav").ticking("1", false),
3878 quasi_router::Row::new("snare.wav").ticking("2", false),
3879 ])]);
3880 let mut view = View::new();
3881
3882 let mut found = None;
3883 let mut off = None;
3884 let mut ctx = None;
3885 egui::__run_test_ui(|ui| {
3886 immediate.screen(ui, &screen, &mut view);
3887 // Inside the first row's strip. Taken off the `Ui` rather than guessed,
3888 // so this is not asserting a layout.
3889 let inside = ui.min_rect().left_top() + egui::vec2(4.0, 4.0);
3890 found = crate::row_at(ui.ctx(), inside);
3891 // Far outside anything drawn, which is the toolbar case.
3892 off = crate::row_at(ui.ctx(), egui::pos2(-500.0, -500.0));
3893 ctx = Some(ui.ctx().clone());
3894 });
3895
3896 let found = found.expect("a point inside the list is over a row");
3897 assert_eq!(found.index, 0);
3898 assert_eq!(found.value.as_deref(), Some("1"));
3899 assert!(off.is_none(), "a point over nothing is over no row");
3900 drop(ctx);
3901 }
3902
3903 /// A table is the shape the one consumer actually uses, and a row there is the
3904 /// run of its cells rather than one widget.
3905 #[test]
3906 fn a_table_row_is_found_by_any_of_its_cells() {
3907 let immediate = renderer();
3908 let screen = screen_of([Node::Table {
3909 marks: ::quasi_router::stage::Marks::none(),
3910 columns: vec![
3911 quasi_router::Column::new("Name"),
3912 quasi_router::Column::new("Size"),
3913 ],
3914 rows: vec![
3915 quasi_router::Row::cells([
3916 quasi_router::Cell::new("kick.wav"),
3917 quasi_router::Cell::new("2.1 MB"),
3918 ])
3919 .ticking("1", false),
3920 quasi_router::Row::cells([
3921 quasi_router::Cell::new("snare.wav"),
3922 quasi_router::Cell::new("1.4 MB"),
3923 ])
3924 .ticking("2", false),
3925 ],
3926 more: None,
3927 }]);
3928 let mut view = View::new();
3929
3930 let mut hits = Vec::new();
3931 egui::__run_test_ui(|ui| {
3932 immediate.screen(ui, &screen, &mut view);
3933 let rect = ui.min_rect();
3934 // Sweep the drawn area and collect which rows answered, rather than
3935 // asserting a coordinate this renderer never promised.
3936 let mut y = rect.top();
3937 while y < rect.bottom() {
3938 if let Some(at) = crate::row_at(ui.ctx(), egui::pos2(rect.left() + 4.0, y)) {
3939 hits.push(at.value.clone());
3940 }
3941 y += 2.0;
3942 }
3943 // Sweep the drawn area and collect which rows answered, rather than
3944 // asserting a coordinate this renderer never promised.
3945 let mut y = rect.top();
3946 while y < rect.bottom() {
3947 if let Some(at) = crate::row_at(ui.ctx(), egui::pos2(rect.left() + 4.0, y)) {
3948 hits.push(at.value.clone());
3949 }
3950 y += 2.0;
3951 }
3952 });
3953
3954 assert!(
3955 hits.iter().any(|value| value.as_deref() == Some("1")),
3956 "the first row answered somewhere: {hits:?}"
3957 );
3958 }
3959
3960 /// `Field::writes` fires when the value is complete rather than on the way to
3961 /// it, which is what `quasi-webview` has always meant by emitting it as `hx-
3962 /// trigger="change"`. This renderer fired on every keystroke, so typing 30
3963 /// into a bounded box posted 3 on the way.
3964 #[test]
3965 fn a_change_fires_when_the_value_is_complete_and_not_on_the_way_to_it() {
3966 let screen = screen_of([
3967 Node::Field(Box::new(
3968 Field::new(layout::FieldKind::Number, "row_height", "Row height")
3969 .value("20")
3970 .writes(Action::post("/settings/row-height")),
3971 )),
3972 Node::act("Elsewhere", Action::post("/elsewhere")),
3973 ]);
3974 let mut host = Host::new();
3975 host.settle(&screen);
3976
3977 // Into the box, which is what focuses it.
3978 host.click(&screen, "20");
3979
3980 // Typing. Every one of these was a write before, and 202 is exactly the
3981 // shape of the defect: outside the bounds the field's own hint states.
3982 for typed in ["2", "02"] {
3983 let fired = host.frame(&screen, vec![egui::Event::Text(typed.to_owned())]);
3984 assert!(
3985 fired.is_none(),
3986 "typing {typed} is on the way to a value, not a write"
3987 );
3988 }
3989
3990 // Leaving the box is what completes it. The press lands on another control,
3991 // so this is the ordinary way a reader finishes with a field.
3992 let fired = host.click(&screen, "Elsewhere");
3993 let wrote =
3994 fired.is_some_and(|fired| fired.action.destination.route() == Some("/settings/row-height"));
3995 assert!(wrote, "leaving the box is the write");
3996 }
3997
3998 /// The other half: a value that never differs from what the description offered
3999 /// is not a write however the reader leaves the box, which is what a browser's
4000 /// `change` promises.
4001 #[test]
4002 fn leaving_a_box_untouched_writes_nothing() {
4003 let screen = screen_of([Node::Field(Box::new(
4004 Field::new(layout::FieldKind::Text, "title", "Title")
4005 .value("Kick")
4006 .writes(Action::post("/rename")),
4007 ))]);
4008 let mut host = Host::new();
4009 host.settle(&screen);
4010 assert!(host.frame(&screen, Vec::new()).is_none());
4011 }
4012
4013 /// A press on a row of a live selection says what it meant, and the meaning is
4014 /// what this host reads off the keys rather than what the description carries.
4015 #[test]
4016 fn a_press_on_a_row_of_a_live_selection_says_how_it_was_meant() {
4017 let screen = screen_of([Node::list([
4018 quasi_router::Row::new("kick.wav")
4019 .activate(Action::post("/files/1/open"))
4020 .choosing("1", false),
4021 quasi_router::Row::new("snare.wav")
4022 .activate(Action::post("/files/2/open"))
4023 .choosing("2", true),
4024 ])]);
4025 let mut host = Host::new();
4026 host.settle(&screen);
4027
4028 let meant = |fired: Option<crate::Fired>| {
4029 fired
4030 .expect("the row fired")
4031 .payload
4032 .get(quasi_router::Node::CHOOSING)
4033 .map(ToOwned::to_owned)
4034 };
4035
4036 // The plain press: this row and nothing else.
4037 assert_eq!(
4038 meant(host.click(&screen, "kick.wav")),
4039 Some("only".to_owned())
4040 );
4041 // Command, which is ctrl on everything but a Mac and which egui has already
4042 // resolved for us.
4043 assert_eq!(
4044 meant(host.click_holding(&screen, "kick.wav", egui::Modifiers::COMMAND)),
4045 Some("also".to_owned())
4046 );
4047 assert_eq!(
4048 meant(host.click_holding(&screen, "kick.wav", egui::Modifiers::SHIFT)),
4049 Some("through".to_owned())
4050 );
4051 // Both held is a range, matching every file manager: the one thing it
4052 // cannot mean is the plain press.
4053 assert_eq!(
4054 meant(host.click_holding(
4055 &screen,
4056 "kick.wav",
4057 egui::Modifiers::COMMAND | egui::Modifiers::SHIFT
4058 )),
4059 Some("through".to_owned())
4060 );
4061 }
4062
4063 /// The other half, and it is what keeps this additive: a row that is not part of
4064 /// a live selection sends what it always sent.
4065 #[test]
4066 fn a_row_that_cannot_be_chosen_says_nothing_about_how_it_was_pressed() {
4067 let screen = screen_of([Node::list([
4068 quasi_router::Row::new("kick.wav").activate(Action::get("/1"))
4069 ])]);
4070 let mut host = Host::new();
4071 host.settle(&screen);
4072
4073 let fired = host
4074 .click_holding(&screen, "kick.wav", egui::Modifiers::COMMAND)
4075 .expect("the row fired");
4076 assert!(
4077 fired.payload.get(quasi_router::Node::CHOOSING).is_none(),
4078 "an ordinary row grew a parameter: {:?}",
4079 fired.payload
4080 );
4081 }
4082
4083 /// This host is the one that can anchor to a real rect, so it does: the
4084 /// renderer notes where it drew each region and each named control, and the
4085 /// runtime reads that back at draw time.
4086 #[test]
4087 fn the_renderer_says_where_it_drew_a_region_and_a_named_control() {
4088 let immediate = renderer();
4089 let screen = Screen::sidebar_content("Files").with(
4090 Slot::new("browser", RegionKind::Pane)
4091 .with(Node::Act(Act::new("Sort", Action::get("/sort")).id("sort")))
4092 .with(Node::text("rows")),
4093 );
4094 let mut view = View::new();
4095
4096 let mut region = None;
4097 let mut control = None;
4098 let mut unnamed = None;
4099 let mut ctx = None;
4100 egui::__run_test_ui(|ui| {
4101 immediate.screen(ui, &screen, &mut view);
4102 region = crate::geometry::anchor_rect(
4103 ui.ctx(),
4104 &quasi_router::Anchor::Region("browser".into()),
4105 &[],
4106 );
4107 control = crate::geometry::anchor_rect(
4108 ui.ctx(),
4109 &quasi_router::Anchor::Control("sort".into()),
4110 &[],
4111 );
4112 // Nothing was named this, so there is nothing to point at.
4113 unnamed = crate::geometry::anchor_rect(
4114 ui.ctx(),
4115 &quasi_router::Anchor::Control("nothing".into()),
4116 &[],
4117 );
4118 ctx = Some(ui.ctx().clone());
4119 });
4120
4121 let region = region.expect("the region was drawn");
4122 let control = control.expect("the named control was drawn");
4123 // The control is inside the region that holds it, which is the check that
4124 // says these are the rects they claim to be rather than two defaults.
4125 assert!(region.contains_rect(control), "{control:?} in {region:?}");
4126 assert!(unnamed.is_none(), "an unnamed control is not noted");
4127 drop(ctx);
4128 }
4129
4130 /// A menu over a selection opens against the set, which is the box around every
4131 /// ticked row that was actually drawn.
4132 #[test]
4133 fn a_selection_anchor_is_the_box_around_the_ticked_rows() {
4134 let immediate = renderer();
4135 let screen = screen_of([Node::list([
4136 quasi_router::Row::new("kick.wav").ticking("1", false),
4137 quasi_router::Row::new("snare.wav").ticking("2", false),
4138 ])]);
4139 let mut view = View::new();
4140
4141 let mut one = None;
4142 let mut both = None;
4143 let mut none = None;
4144 let mut ctx = None;
4145 egui::__run_test_ui(|ui| {
4146 immediate.screen(ui, &screen, &mut view);
4147 one = crate::geometry::anchor_rect(
4148 ui.ctx(),
4149 &quasi_router::Anchor::Selection,
4150 &["1".to_owned()],
4151 );
4152 both = crate::geometry::anchor_rect(
4153 ui.ctx(),
4154 &quasi_router::Anchor::Selection,
4155 &["1".to_owned(), "2".to_owned()],
4156 );
4157 // Nothing ticked is nothing to anchor to.
4158 none = crate::geometry::anchor_rect(ui.ctx(), &quasi_router::Anchor::Selection, &[]);
4159 ctx = Some(ui.ctx().clone());
4160 });
4161
4162 let one = one.expect("the first row was drawn");
4163 let both = both.expect("both rows were drawn");
4164 assert!(
4165 both.contains_rect(one),
4166 "the set is the box around its members"
4167 );
4168 assert!(none.is_none(), "an empty selection anchors nothing");
4169 drop(ctx);
4170 }
4171
4172 /// The runtime keeps an anchor it can resolve and drops one it cannot, so the
4173 /// draw path has one question to ask rather than two.
4174 #[test]
4175 fn an_anchor_that_names_nothing_is_dropped_and_the_menu_still_opens() {
4176 let mut runtime =
4177 Runtime::new(Screen::sidebar_content("Files").with(Slot::new("browser", RegionKind::Pane)));
4178
4179 runtime.apply(
4180 &Request::get("/menu"),
4181 Response {
4182 outcome: Outcome::Anchored {
4183 screen: screen_of([Node::text("menu")]),
4184 anchor: quasi_router::Anchor::Region("browser".into()),
4185 },
4186 notice: None,
4187 address: None,
4188 invalidates: Vec::new(),
4189 },
4190 );
4191 assert!(runtime.overlaid());
4192 assert_eq!(
4193 runtime.anchor,
4194 Some(quasi_router::Anchor::Region("browser".into()))
4195 );
4196
4197 // Dismissing puts the screen back and takes the anchor with it. `dismiss`
4198 // rather than `back`: an anchored menu is not a place, so the layer pops
4199 // and history is never consulted.
4200 assert!(runtime.dismiss());
4201 assert!(!runtime.overlaid());
4202 assert_eq!(runtime.anchor, None);
4203
4204 let mut runtime = Runtime::new(screen_of([Node::text("rows")]));
4205 runtime.apply(
4206 &Request::get("/menu"),
4207 Response {
4208 outcome: Outcome::Anchored {
4209 screen: screen_of([Node::text("menu")]),
4210 anchor: quasi_router::Anchor::Region("nowhere".into()),
4211 },
4212 notice: None,
4213 address: None,
4214 invalidates: Vec::new(),
4215 },
4216 );
4217 // The menu opened all the same. What was lost is where it sits.
4218 assert!(runtime.overlaid());
4219 assert_eq!(runtime.anchor, None);
4220 }
4221
4222 /// The `900865dd` guard covers the anchored member too.
4223 #[test]
4224 fn an_anchored_menu_does_not_stack_on_the_same_request() {
4225 let mut runtime = Runtime::new(screen_of([Node::text("rows")]));
4226 let request = Request::get("/menu");
4227 let answer = || Response {
4228 outcome: Outcome::Anchored {
4229 screen: screen_of([Node::text("menu")]),
4230 anchor: quasi_router::Anchor::Selection,
4231 },
4232 notice: None,
4233 address: None,
4234 invalidates: Vec::new(),
4235 };
4236
4237 runtime.apply(&request, answer());
4238 runtime.apply(&request, answer());
4239
4240 assert!(runtime.dismiss());
4241 assert!(!runtime.overlaid());
4242 }
4243
4244 /// A tab group: three labelled panels, one of them showing.
4245 fn tabbed(shown: usize) -> Screen {
4246 Screen::sidebar_content("Help").with(
4247 Slot::new("body", RegionKind::Pane).with(Node::Region(
4248 Slot::new("strip", RegionKind::TabGroup)
4249 .showing_one(shown)
4250 .frame(
4251 "Shortcuts",
4252 Node::Region(
4253 Slot::new("first", RegionKind::Group)
4254 .with(Node::text("every key that works")),
4255 ),
4256 )
4257 .frame(
4258 "Features",
4259 Node::Region(
4260 Slot::new("second", RegionKind::Group)
4261 .with(Node::text("what the app does")),
4262 ),
4263 ),
4264 )),
4265 )
4266 }
4267
4268 #[test]
4269 fn a_region_showing_one_child_draws_one_child_and_a_strip() {
4270 // This renderer drew the whole body whatever `Showing` said until
4271 // `showing_body` existed, so a described tab group came out as every panel
4272 // stacked with no strip. Both halves are asserted: the strip is there, and
4273 // the panel that is not up is not.
4274 let mut host = Host::new();
4275 host.settle(&tabbed(0));
4276
4277 assert!(on_screen(&host, "Shortcuts"), "the strip is not drawn");
4278 assert!(on_screen(&host, "Features"), "the strip is missing a tab");
4279 assert!(
4280 on_screen(&host, "every key that works"),
4281 "the shown panel is not drawn"
4282 );
4283 assert!(
4284 !on_screen(&host, "what the app does"),
4285 "a panel that is not up was drawn anyway"
4286 );
4287 }
4288
4289 #[test]
4290 fn the_description_says_which_tab_a_screen_arrives_on() {
4291 let mut host = Host::new();
4292 host.settle(&tabbed(1));
4293
4294 assert!(on_screen(&host, "what the app does"), "the wrong tab is up");
4295 assert!(
4296 !on_screen(&host, "every key that works"),
4297 "both tabs are up"
4298 );
4299 }
4300
4301 #[test]
4302 fn pressing_a_tab_moves_the_frame_and_asks_the_app_nothing() {
4303 // The carousel half of `showing_body`: the panels are here already, so
4304 // moving between them is local. A round trip to reveal bytes the reader has
4305 // already downloaded is what the webview's derivation refuses too.
4306 let mut host = Host::new();
4307 let screen = tabbed(0);
4308 host.settle(&screen);
4309
4310 let fired = host.click(&screen, "Features");
4311 assert!(fired.is_none(), "moving to a local panel called a route");
4312
4313 host.settle(&screen);
4314 assert!(
4315 on_screen(&host, "what the app does"),
4316 "the tab did not move"
4317 );
4318 assert!(
4319 !on_screen(&host, "every key that works"),
4320 "the old panel stayed up"
4321 );
4322 }
4323
4324 #[test]
4325 fn a_tab_over_a_routed_panel_calls_its_own_route() {
4326 // The other half, and MNW's shape: a panel behind `Slot::fed_by` is fetched
4327 // when its tab is pressed. The strip button carries that address and no
4328 // target -- the router answers with a fragment naming the slot it changed.
4329 let mut host = Host::new();
4330 let screen = Screen::sidebar_content("Dashboard").with(
4331 Slot::new("body", RegionKind::Pane).with(Node::Region(
4332 Slot::new("strip", RegionKind::TabGroup)
4333 .showing_one(0)
4334 .frame(
4335 "Library",
4336 Node::Region(
4337 Slot::new("first", RegionKind::Group).with(Node::text("the library")),
4338 ),
4339 )
4340 .frame(
4341 "Settings",
4342 Node::Region(
4343 Slot::new("second", RegionKind::Group)
4344 .fed_by(Action::get("/dashboard/tabs/settings")),
4345 ),
4346 ),
4347 )),
4348 );
4349 host.settle(&screen);
4350
4351 let fired = host.click(&screen, "Settings");
4352 assert_eq!(
4353 fired.and_then(|fired| fired.action.route().map(str::to_owned)),
4354 Some("/dashboard/tabs/settings".to_owned()),
4355 "a routed panel's tab did not call it"
4356 );
4357 }
4358
4359 #[test]
4360 fn children_without_labels_get_previous_position_next() {
4361 // A carousel. Nothing here reads `RegionKind`'s name: what the children
4362 // carry is what picks the idiom, which is the rule all three renderers
4363 // derive from.
4364 let mut host = Host::new();
4365 let screen = Screen::sidebar_content("Gallery").with(
4366 Slot::new("body", RegionKind::Pane).with(Node::Region(
4367 Slot::new("frames", RegionKind::Group)
4368 .showing_one(0)
4369 .with(Node::text("the first picture"))
4370 .with(Node::text("the second picture")),
4371 )),
4372 );
4373 host.settle(&screen);
4374
4375 assert!(on_screen(&host, "1 / 2"), "the position is not drawn");
4376 assert!(
4377 !on_screen(&host, "the second picture"),
4378 "a carousel drew every frame"
4379 );
4380
4381 let fired = host.click(&screen, "Next");
4382 assert!(fired.is_none(), "moving a carousel called a route");
4383 host.settle(&screen);
4384 assert!(
4385 on_screen(&host, "the second picture"),
4386 "Next did not advance the frame"
4387 );
4388 }
4389
4390 #[test]
4391 fn a_lone_dismissible_child_is_a_summary_line_that_opens_and_shuts() {
4392 // `Showing::AtMostOne` is the one member where showing nothing is a resting
4393 // place, so the control has to shut as well as open. quasi-tui's `shown` is
4394 // a bare `usize` and cannot reach the closed state again; this one can.
4395 let mut host = Host::new();
4396 let screen = Screen::sidebar_content("Settings").with(
4397 Slot::new("body", RegionKind::Pane).with(Node::Region(
4398 Slot::new("advanced", RegionKind::Group)
4399 .showing_at_most_one(None)
4400 .frame(
4401 "Advanced",
4402 Node::Region(
4403 Slot::new("inner", RegionKind::Group)
4404 .with(Node::text("the dangerous knobs")),
4405 ),
4406 ),
4407 )),
4408 );
4409 host.settle(&screen);
4410
4411 assert!(
4412 on_screen(&host, "Advanced"),
4413 "the summary line is not drawn"
4414 );
4415 assert!(
4416 !on_screen(&host, "the dangerous knobs"),
4417 "a closed disclosure drew its contents"
4418 );
4419
4420 let fired = host.click(&screen, "Advanced");
4421 assert!(fired.is_none(), "opening a disclosure called a route");
4422 host.settle(&screen);
4423 assert!(
4424 on_screen(&host, "the dangerous knobs"),
4425 "the disclosure did not open"
4426 );
4427
4428 host.click(&screen, "Advanced");
4429 host.settle(&screen);
4430 assert!(
4431 !on_screen(&host, "the dangerous knobs"),
4432 "the disclosure would not shut again"
4433 );
4434 }
4435
4436 #[test]
4437 fn a_region_showing_everything_draws_what_it_always_drew() {
4438 // The additive claim. Nothing written before `Showing` existed changes, and
4439 // no chrome is derived for a region that shows its whole body.
4440 let mut host = Host::new();
4441 let screen = screen_of([Node::text("first"), Node::text("second")]);
4442 host.settle(&screen);
4443
4444 assert!(on_screen(&host, "first") && on_screen(&host, "second"));
4445 assert!(!on_screen(&host, "1 / 2"), "a plain region grew a counter");
4446 }
4447
4448 #[test]
4449 fn the_caret_is_owed_to_the_question_the_screen_named_and_only_on_arrival() {
4450 // Focus is egui's here, so what this renderer owes is the one-frame request
4451 // rather than the caret. The claim is taken by the frame that spends it: a
4452 // flag left standing would ask again every frame and the reader could never
4453 // move off the box.
4454 let screen = screen_of([Node::Form {
4455 marks: ::quasi_router::stage::Marks::none(),
4456 action: Action::post("/login"),
4457 submit: "Log in".into(),
4458 fields: vec![
4459 Field::new(layout::FieldKind::Text, "email", "Email"),
4460 Field::new(layout::FieldKind::Secret, "password", "Password"),
4461 ],
4462 }])
4463 .opening_at("password");
4464
4465 let mut runtime = Runtime::new(screen);
4466 // The runtime read it on arrival, so the first frame spends it and the
4467 // second has nothing to spend.
4468 assert!(runtime.view_mut().claims_caret("password"));
4469 assert!(!runtime.view_mut().claims_caret("password"));
4470 }
4471
4472 #[test]
4473 fn a_screen_that_names_no_question_owes_the_caret_to_nobody() {
4474 let mut runtime = Runtime::new(screen_of([Node::field(Field::new(
4475 layout::FieldKind::Text,
4476 "email",
4477 "Email",
4478 ))]));
4479 assert!(!runtime.view_mut().claims_caret("email"));
4480 }
4481
4482 #[test]
4483 fn drawing_a_band_draws_it_and_fires_nothing_by_itself() {
4484 // egui's harness answers responses rather than pixels, so what can be
4485 // asserted here is that the band lays out and that nothing in it goes off
4486 // without a press. The placement claim -- above the screen, notices
4487 // included -- is the order of the calls in `chromed` and is asserted in
4488 // quasi-tui, where a buffer can be read.
4489 use quasi_router::{Band, Brand, Place};
4490
4491 let chrome = Chrome::new()
4492 .offering(Place::new("discover", "Discover", Action::get("/discover")))
4493 .banded(
4494 Band::new()
4495 .branded(Brand::new("Makenot.work", Action::get("/")).marking("."))
4496 .searching(Field::new(layout::FieldKind::Text, "q", "Search")),
4497 );
4498 let screen = screen_of([Node::text("body")]).at_place("discover");
4499 let immediate = renderer();
4500
4501 let mut view = View::new();
4502 let mut fired = None;
4503 egui::__run_test_ui(|ui| {
4504 fired = immediate.chromed(ui, &screen, &Frame::new(), &chrome, &mut view);
4505 });
4506 assert!(fired.is_none(), "the band called a route nobody pressed");
4507
4508 // The default has to be the old window, or every app grows a header the
4509 // moment this member arrives.
4510 let mut plain = View::new();
4511 let mut nothing = None;
4512 egui::__run_test_ui(|ui| {
4513 nothing = immediate.chromed(ui, &screen, &Frame::new(), &Chrome::new(), &mut plain);
4514 });
4515 assert!(nothing.is_none());
4516 }
4517