Skip to main content

max / quasi

205.7 KB · 5548 lines History Blame Raw
1 //! What a terminal draws from a description.
2 //!
3 //! Assertions are over the buffer's text rather than over styles, for the
4 //! reason the webview's tests assert on classes rather than on colours: the
5 //! colour is the theme's answer and changes with it, and what a renderer owes
6 //! is that the words are there and in the right place.
7
8 use makeover_layout as layout;
9 use makeover_tui::{Fidelity, Theme};
10 use quasi_router::{
11 Act, Action, Candidate, Cell, Choice, Column, Consult, Field, Figure, Meter, Node, RegionKind,
12 Row, Run, Screen, Slot, Tag,
13 };
14 use ratatui::buffer::Buffer;
15 use ratatui::layout::Rect;
16 use ratatui::style::Modifier;
17
18 use crate::{Local, Tui, View};
19
20 /// A renderer in a shipped theme, at full colour.
21 ///
22 /// Through `Theme::from_theme` and a bundled theme file rather than a literal,
23 /// because `makeover_tui::Theme` is `#[non_exhaustive]` and that is the only
24 /// way to get one. A test that could build a partial theme by hand would be a
25 /// test drawing in colours no theme ships.
26 fn tui() -> Tui {
27 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
28 let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads");
29 Tui::new(
30 Theme::from_theme(&colours).expect("a shipped theme resolves"),
31 Fidelity::TrueColor,
32 )
33 }
34
35 /// Everything a buffer holds, as one string per row.
36 fn rows(buf: &Buffer) -> Vec<String> {
37 (0..buf.area.height)
38 .map(|y| {
39 (0..buf.area.width)
40 .map(|x| buf[(x, y)].symbol())
41 .collect::<String>()
42 .trim_end()
43 .to_string()
44 })
45 .collect()
46 }
47
48 /// Draw one node into a buffer of this size.
49 ///
50 /// With an empty [`View`], which is what "the description and nothing else"
51 /// looks like now that drawing takes two arguments: nothing typed, nothing
52 /// focused yet, nothing scrolled. Every assertion in this file that predates
53 /// the interaction runtime still reads the same picture through it.
54 fn buffer(node: &Node, width: u16, height: u16) -> Buffer {
55 let area = Rect::new(0, 0, width, height);
56 let mut buf = Buffer::empty(area);
57 tui().node(node, &View::new(), area, &mut buf);
58 buf
59 }
60
61 /// Draw one node into a buffer of this size.
62 fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> {
63 rows(&buffer(node, width, height))
64 }
65
66 /// The characters on row `y` whose cells carry `modifier`.
67 ///
68 /// The exception to the note at the top of this file, and a narrow one. A
69 /// colour is the theme's answer, but bold is not: no theme decides which words
70 /// in a paragraph are emphasised, so which cells carry it is this renderer's
71 /// claim and the only way to assert it is to read it.
72 fn marked(buf: &Buffer, y: u16, modifier: Modifier) -> String {
73 (0..buf.area.width)
74 .filter(|x| buf[(*x, y)].modifier.contains(modifier))
75 .map(|x| buf[(x, y)].symbol())
76 .collect()
77 }
78
79 /// Draw a whole screen.
80 /// What a runtime draws, which is the screen *and* what the user has done to
81 /// it. `shown` builds a fresh `View`, so it draws the description alone -- and
82 /// a tick lives in the view, which is the whole of `5f2b8753`.
83 fn held(runtime: &Runtime, width: u16, height: u16) -> String {
84 let area = Rect::new(0, 0, width, height);
85 let mut buf = Buffer::empty(area);
86 runtime.draw(&tui(), area, &mut buf);
87 rows(&buf).join(" ")
88 }
89
90 /// The column some text starts at, in whichever row holds it.
91 ///
92 /// A region's contents sit below its top border, so the row a word lands on is
93 /// not the row the region starts on. Asserting against row 0 measures the
94 /// border and not the content.
95 fn column_of(rows: &[String], text: &str) -> Option<usize> {
96 rows.iter().find_map(|row| row.find(text))
97 }
98
99 fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> {
100 let area = Rect::new(0, 0, width, height);
101 let mut buf = Buffer::empty(area);
102 tui().screen(screen, &View::new(), area, &mut buf);
103 rows(&buf)
104 }
105
106 /// A band whose members were said to share one row.
107 fn run_of(fallback: layout::Fallback, members: &[(&str, layout::Priority)]) -> Screen {
108 let mut row = Run::new(fallback);
109 for (label, priority) in members {
110 row = row.beside(
111 Node::Act(Act::new(*label, Action::post(format!("/{label}")))),
112 *priority,
113 );
114 }
115 Screen::sidebar_content("Test").with(Slot::new("bar", RegionKind::Band).across(row))
116 }
117
118 /// The three toolbar controls, all essential.
119 const THREE: [(&str, layout::Priority); 3] = [
120 ("Import", layout::Priority::Essential),
121 ("Export", layout::Priority::Essential),
122 ("Settings", layout::Priority::Essential),
123 ];
124
125 /// One of every `Node` member, in declaration order.
126 ///
127 /// Kept as a function so more than one test can walk it, and it has to stay
128 /// complete: `Node` is `#[non_exhaustive]`, so a member added upstream lands on
129 /// a catch-all arm and compiles. This list plus the count below is what says a
130 /// walk has learned the member rather than merely accepting it.
131 fn one_of_everything() -> Vec<Node> {
132 vec![
133 Node::page("Tasks"),
134 Node::text("plain"),
135 Node::rich("**bold** and `code`"),
136 Node::Act(Act::new("Save", Action::post("/save"))),
137 Node::Link {
138 text: "Docs".to_owned(),
139 action: Action::get("/docs"),
140 },
141 Node::Figure(Figure::new("17", "Streak")),
142 Node::since(std::time::SystemTime::UNIX_EPOCH),
143 Node::until(std::time::SystemTime::UNIX_EPOCH),
144 Node::age(std::time::SystemTime::UNIX_EPOCH),
145 Node::Image(quasi_router::Image::new("/cover.png", "The library view")),
146 Node::Token(Tag::badge("beta")),
147 Node::banner(layout::Tone::Info, "Saved"),
148 Node::empty("Nothing here yet"),
149 Node::Field(Box::new(Field::new(
150 layout::FieldKind::Text,
151 "title",
152 "Title",
153 ))),
154 Node::Form {
155 marks: ::quasi_router::stage::Marks::none(),
156 action: Action::post("/save"),
157 submit: "Save".to_owned(),
158 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
159 },
160 Node::list([Row::new("One")]),
161 Node::Table {
162 marks: ::quasi_router::stage::Marks::none(),
163 columns: vec![Column::new("Name")],
164 rows: vec![Row::cells([Cell::new("One")])],
165 more: None,
166 },
167 Node::Timeline {
168 marks: ::quasi_router::stage::Marks::none(),
169 track: layout::Track::DAY,
170 entries: vec![quasi_router::Placed::new(540, 45, Row::new("Standup"))],
171 focus: Some(540),
172 },
173 Node::Meter(Meter::new(3, 6)),
174 Node::stats([Figure::new("17", "Streak")]),
175 Node::Region(Slot::new("nested", RegionKind::Pane)),
176 ]
177 }
178
179 #[test]
180 fn every_described_node_draws_without_panicking() {
181 // This crate walks `Node` twice, so the exhaustiveness it claims is two
182 // claims. Drawing is the first of them: one of everything, painted.
183 for node in one_of_everything() {
184 let _ = drawn(&node, 60, 12);
185 }
186 }
187
188 #[test]
189 fn every_described_node_is_measured_without_panicking() {
190 // The second walk, and the one that has no visible symptom when it drifts:
191 // a member `height` does not know is measured as something else and the
192 // rows below it land in the wrong place.
193 let tui = tui();
194 for node in one_of_everything() {
195 let _ = tui.height(&node, 60);
196 }
197 }
198
199 #[test]
200 fn the_exhaustiveness_list_holds_one_of_every_member() {
201 // A count rather than a comment. `Node` cannot be iterated, so nothing but
202 // this stops the list above going stale while both walks keep compiling.
203 assert_eq!(
204 one_of_everything().len(),
205 21,
206 "one of every `Node` member, in declaration order"
207 );
208 }
209
210 #[test]
211 fn a_member_of_a_run_is_drawn_at_all() {
212 // The defect: `draw` and `height` both walked `body` and nothing walked
213 // `run`, so a description that said `across` and then `beside` contributed
214 // members that were never painted and never counted. Silent, and the same
215 // shape quasi-immediate had until quasi@98a7276.
216 let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6);
217 for label in ["Import", "Export", "Settings"] {
218 assert!(
219 out.iter().any(|row| row.contains(label)),
220 "{label} was never drawn: {out:?}"
221 );
222 }
223 }
224
225 #[test]
226 fn a_run_puts_its_members_across_rather_than_down() {
227 let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6);
228 let row = out
229 .iter()
230 .find(|row| row.contains("Import"))
231 .expect("no row holds the first member");
232 assert!(
233 row.contains("Export") && row.contains("Settings"),
234 "the members went down the screen instead of across it: {out:?}"
235 );
236 let (first, second) = (row.find("Import").unwrap(), row.find("Export").unwrap());
237 assert!(first < second, "the members are out of order: {row:?}");
238 }
239
240 #[test]
241 fn a_run_wraps_when_the_line_cannot_hold_the_next_member() {
242 // Derived from what the members hold, at the width the region was given.
243 // No breakpoint is authored anywhere in this path.
244 let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 16, 6);
245 let lines: Vec<&String> = out
246 .iter()
247 .filter(|row| THREE.iter().any(|(label, _)| row.contains(label)))
248 .collect();
249 assert!(lines.len() > 1, "a narrow row did not wrap: {out:?}");
250 for label in ["Import", "Export", "Settings"] {
251 assert!(
252 out.iter().any(|row| row.contains(label)),
253 "wrapping lost {label}: {out:?}"
254 );
255 }
256 }
257
258 #[test]
259 fn a_region_is_as_tall_as_its_row_plus_its_body() {
260 // The half that is invisible until something scrolls: `height` is what the
261 // scroll arithmetic trusts, so a row it did not count is a region that
262 // scrolls short by however many lines the row took.
263 let bare = Slot::new("bar", RegionKind::Band).with(Node::text("body"));
264 let rowed = Slot::new("bar", RegionKind::Band)
265 .with(Node::text("body"))
266 .across(Run::new(layout::Fallback::Wrap).beside(
267 Node::Act(Act::new("Import", Action::post("/import"))),
268 layout::Priority::Essential,
269 ));
270
271 let tui = tui();
272 assert_eq!(
273 crate::region::height(&tui, &rowed, 60, &Local::none()),
274 crate::region::height(&tui, &bare, 60, &Local::none()) + 1,
275 "the row was not counted"
276 );
277 }
278
279 #[test]
280 fn a_run_that_sheds_drops_by_priority_and_keeps_the_essential() {
281 let members = [
282 ("Import", layout::Priority::Essential),
283 ("Export", layout::Priority::Secondary),
284 ("Settings", layout::Priority::Optional),
285 ];
286 let screen = run_of(layout::Fallback::Shed, &members);
287
288 let narrow = shown(&screen, 30, 6);
289 assert!(narrow.iter().any(|row| row.contains("Import")));
290 assert!(
291 !narrow.iter().any(|row| row.contains("Export")),
292 "a shed row kept what it said it would drop: {narrow:?}"
293 );
294
295 // And nothing is dropped where there is room, or the cutoff would be a
296 // permanent narrowing rather than a measurement.
297 let wide = shown(&screen, 120, 6);
298 assert!(wide.iter().any(|row| row.contains("Settings")), "{wide:?}");
299 }
300
301 #[test]
302 fn a_run_that_menus_keeps_every_member_because_a_terminal_has_nowhere_to_put_them() {
303 // This renderer's answer rather than a shortfall, and the header says so: a
304 // menu here is a key, and a marker showing a count nobody can open is a
305 // control drawn, reachable and doing nothing.
306 let members = [
307 ("Import", layout::Priority::Essential),
308 ("Export", layout::Priority::Secondary),
309 ("Settings", layout::Priority::Optional),
310 ];
311 let narrow = shown(&run_of(layout::Fallback::Menu, &members), 30, 6);
312 for label in ["Import", "Export", "Settings"] {
313 assert!(
314 narrow.iter().any(|row| row.contains(label)),
315 "Menu shed {label} with nowhere to put it: {narrow:?}"
316 );
317 }
318 }
319
320 #[test]
321 fn the_caret_reaches_a_row_s_members_before_the_body() {
322 // The invariant holding the two walks together: the caret lights the
323 // control the drawing put first, or Enter calls something the reader is not
324 // looking at.
325 let screen = Screen::sidebar_content("Test").with(
326 Slot::new("bar", RegionKind::Band)
327 .across(Run::new(layout::Fallback::Wrap).beside(
328 Node::Act(Act::new("Import", Action::post("/import"))),
329 layout::Priority::Essential,
330 ))
331 .with(Node::Act(Act::new("Below", Action::post("/below")))),
332 );
333
334 let reached: Vec<String> = crate::focus::reaches(&screen, &Local::none())
335 .iter()
336 .filter_map(|reach| match &reach.spot {
337 Spot::Act { action, .. } => action.destination.route().map(ToOwned::to_owned),
338 _ => None,
339 })
340 .collect();
341 assert_eq!(reached, ["/import", "/below"]);
342 }
343
344 /// The row a piece of text was drawn on.
345 fn row_of(rows: &[String], text: &str) -> Option<usize> {
346 rows.iter().position(|row| row.contains(text))
347 }
348
349 #[test]
350 fn a_band_said_after_the_body_is_drawn_under_it() {
351 // Ruled by Max 2026-08-23 (quasicoherent 3725bacf): where a band was said
352 // is which end it belongs to. This renderer hoisted every band before it,
353 // so a status band drew above the content it belonged under while the same
354 // description put it underneath in a webview.
355 let screen = Screen::sidebar_content("Test")
356 .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar")))
357 .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content")))
358 .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status")));
359 let out = shown(&screen, 40, 12);
360
361 let bar = row_of(&out, "the toolbar").expect("no toolbar drawn");
362 let content = row_of(&out, "the content").expect("no content drawn");
363 let foot = row_of(&out, "the status").expect("no status drawn");
364 assert!(bar < content, "the toolbar left the top: {out:?}");
365 assert!(
366 content < foot,
367 "the footer is above what it is the footer of: {out:?}"
368 );
369 }
370
371 #[test]
372 fn the_room_a_trailing_band_needs_is_kept_out_of_the_body() {
373 // The failure a reservation prevents: a pane that fills its area leaves a
374 // footer nowhere to go, and the band drawn into no rows is a band nobody
375 // sees. The pane here holds more lines than the screen has.
376 let mut pane = Slot::new("main", RegionKind::Pane);
377 for line in 0..30 {
378 pane = pane.with(Node::text(format!("line {line}")));
379 }
380 let screen = Screen::sidebar_content("Test")
381 .with(pane)
382 .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status")));
383 let out = shown(&screen, 40, 10);
384
385 assert!(
386 row_of(&out, "the status").is_some(),
387 "the body took the footer's rows: {out:?}"
388 );
389 }
390
391 #[test]
392 fn the_band_said_last_is_the_one_at_the_bottom() {
393 // Two trailing bands, which is audiofiles' shell: a migration strip above
394 // the status band.
395 let screen = Screen::sidebar_content("Test")
396 .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content")))
397 .with(Slot::new("strip", RegionKind::Band).with(Node::text("the strip")))
398 .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status")));
399 let out = shown(&screen, 40, 12);
400
401 let strip = row_of(&out, "the strip").expect("no strip drawn");
402 let foot = row_of(&out, "the status").expect("no status drawn");
403 assert!(strip < foot, "the bands are upside down: {out:?}");
404 }
405
406 #[test]
407 fn the_caret_reaches_a_trailing_band_in_the_order_it_is_drawn() {
408 // The focus walk and the drawing have to agree about where a band is, or
409 // the caret lights the footer before the content it sits under.
410 let screen = Screen::sidebar_content("Test")
411 .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar")))
412 .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content")))
413 .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status")));
414
415 let order: Vec<&str> = crate::region::reachable(&screen)
416 .iter()
417 .map(|slot| slot.id.as_str())
418 .collect();
419 assert_eq!(order, ["bar", "main", "foot"]);
420 }
421
422 #[test]
423 fn a_row_draws_its_run_in_the_order_the_description_says_it() {
424 // The property the containment migration bought this renderer: a terminal
425 // reads the run rather than knowing the old fixed member sequence.
426 let out = drawn(
427 &Node::list([Row::new("Ship it")
428 .token(Tag::badge("beta"))
429 .meta("2 files")]),
430 40,
431 3,
432 );
433 let line = &out[0];
434 assert!(line.contains("Ship it"), "{out:?}");
435 assert!(
436 line.find("beta").unwrap() < line.find("2 files").unwrap(),
437 "{out:?}"
438 );
439 }
440
441 #[test]
442 fn a_selectable_row_draws_its_tick_and_a_current_row_its_marker() {
443 let ticked = drawn(&Node::list([Row::new("One").selectable(true)]), 20, 2);
444 assert!(ticked[0].starts_with("[x]"), "{ticked:?}");
445
446 let untickable = drawn(&Node::list([Row::new("One")]), 20, 2);
447 assert!(!untickable[0].contains("[x]"), "{untickable:?}");
448 }
449
450 #[test]
451 fn a_meter_is_a_bar_and_a_reading() {
452 let out = drawn(&Node::Meter(Meter::new(3, 6).label("subtasks")), 40, 2);
453 assert!(out[0].starts_with("#####-----"), "{out:?}");
454 assert!(out[0].contains("3/6 subtasks"), "{out:?}");
455 }
456
457 #[test]
458 fn a_figure_puts_the_number_over_what_it_counts() {
459 let out = drawn(&Node::Figure(Figure::new("17", "Current streak")), 30, 3);
460 assert_eq!(out[0], "17");
461 assert_eq!(out[1], "Current streak");
462 }
463
464 #[test]
465 fn a_strip_of_figures_stacks_rather_than_sitting_in_a_row() {
466 // The renderer deciding, which the node leaves it free to do: the strip
467 // says these belong together and not how wide they are.
468 let out = drawn(
469 &Node::stats([Figure::new("17", "Streak"), Figure::new("4", "Today")]),
470 30,
471 5,
472 );
473 assert_eq!(out[0], "17");
474 assert_eq!(out[2], "4");
475 }
476
477 #[test]
478 fn markdown_keeps_its_emphasis_and_loses_its_syntax() {
479 // The node carries source so every renderer can answer it its own way, and
480 // a terminal's own way is the run's marks on the cell: `**ship it**` is the
481 // words in bold, not the words with the asterisks still on them and not the
482 // words with the emphasis thrown away.
483 let buf = buffer(&Node::rich("**ship it** now"), 40, 2);
484 let out = rows(&buf);
485 assert_eq!(out[0], "ship it now");
486 assert_eq!(marked(&buf, 0, Modifier::BOLD), "ship it");
487 }
488
489 #[test]
490 fn each_inline_mark_reaches_the_cell_that_has_it() {
491 let buf = buffer(&Node::rich("*lean* and ~~gone~~"), 40, 2);
492 assert_eq!(rows(&buf)[0], "lean and gone");
493 assert_eq!(marked(&buf, 0, Modifier::ITALIC), "lean");
494 assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "gone");
495 }
496
497 #[test]
498 fn a_code_span_is_set_into_the_page_rather_than_marked() {
499 // Every cell is monospace, so the one thing a webview says with a typeface
500 // is the one mark a terminal cannot repeat. It takes the sunken surface
501 // instead. Asserted as a difference and not as a colour: which colour is
502 // the theme's answer, that there is one is this renderer's.
503 let buf = buffer(&Node::rich("run `cargo build` first"), 40, 2);
504 assert_eq!(rows(&buf)[0], "run cargo build first");
505 let prose = buf[(0, 0)].bg;
506 let code = buf[(4, 0)].bg;
507 assert_ne!(code, prose, "a code span should not sit on the page");
508 assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should");
509 }
510
511 #[test]
512 fn a_heading_inside_a_rich_node_is_drawn_heavier_than_the_prose_under_it() {
513 // The gap this closed: `render_plain` handed over a heading's text at the
514 // weight of everything around it, so a rich node's structure was gone by
515 // the time a cell saw it. Weight is a thing a cell has.
516 let buf = buffer(&Node::rich("# Title\n\nBody."), 40, 4);
517 let out = rows(&buf);
518 assert_eq!(out[0], "Title");
519 assert_eq!(out[2], "Body.");
520 assert_eq!(marked(&buf, 0, Modifier::BOLD), "Title");
521 assert_eq!(marked(&buf, 2, Modifier::BOLD), "");
522 }
523
524 #[test]
525 fn a_deep_heading_reads_as_the_shallowest_a_terminal_can_tell_apart() {
526 // Six markdown levels onto the three `layout::Heading` has. A cell has one
527 // size and only so much colour, so `###` and `######` land together rather
528 // than inventing distinctions nothing can draw.
529 let third = buffer(&Node::rich("### Third"), 40, 2);
530 let sixth = buffer(&Node::rich("###### Sixth"), 40, 2);
531 assert_eq!(third[(0, 0)].fg, sixth[(0, 0)].fg);
532 // And not the same as the prose it sits above, or the level bought nothing.
533 let prose = buffer(&Node::rich("Third"), 40, 2);
534 assert_ne!(third[(0, 0)].fg, prose[(0, 0)].fg);
535 }
536
537 #[test]
538 fn a_tight_run_is_one_line_and_says_it_was_cut() {
539 // The cap, which is what `Flow` buys a terminal. Before it this renderer
540 // gave a row as many lines as the words needed, so a long secondary took
541 // four rows of a list nobody asked to be four rows tall.
542 let long = "a headline long enough that it certainly does not fit in twenty columns";
543 let out = drawn(&Node::list([Row::new(long)]), 20, 6);
544
545 let painted = out.iter().filter(|row| !row.trim().is_empty()).count();
546 assert_eq!(painted, 1, "{out:?}");
547 assert!(out[0].contains('\u{2026}'), "{out:?}");
548 }
549
550 #[test]
551 fn a_relaxed_part_gets_the_second_line_it_asked_for() {
552 let long = "a headline long enough that it certainly does not fit in twenty columns";
553 let out = drawn(&Node::list([Row::new(long).relaxed()]), 20, 6);
554
555 let painted = out.iter().filter(|row| !row.trim().is_empty()).count();
556 assert_eq!(painted, 2, "{out:?}");
557 // Still cut, because two lines is a cap and not a promise of room.
558 assert!(out[1].contains('\u{2026}'), "{out:?}");
559 }
560
561 #[test]
562 fn a_run_that_fits_is_not_marked_as_cut() {
563 let out = drawn(&Node::list([Row::new("Ship it")]), 40, 3);
564 assert!(out[0].contains("Ship it"), "{out:?}");
565 assert!(!out[0].contains('\u{2026}'), "{out:?}");
566 }
567
568 #[test]
569 fn a_narrow_row_drops_what_it_can_spare_rather_than_its_tail() {
570 // The defect the ladder fixes. A capped run cuts from the end, so the badge
571 // and the count -- the two things a list is scanned for -- were the first
572 // to go while the title kept every column it wanted.
573 let row = || {
574 Row::new("Ship the release")
575 .token(Tag::badge("beta"))
576 .meta("2 files")
577 };
578
579 // Wide enough for all of it: nothing is dropped.
580 let roomy = drawn(&Node::list([row()]), 40, 4);
581 assert!(roomy[0].contains("beta"), "{roomy:?}");
582 assert!(roomy[0].contains("2 files"), "{roomy:?}");
583
584 // Narrow enough that the run wants a second line. Meta is Optional and goes
585 // first; the badge is Secondary and survives it.
586 let narrow = drawn(&Node::list([row()]), 25, 4);
587 assert!(!narrow[0].contains("2 files"), "{narrow:?}");
588 assert!(narrow[0].contains("beta"), "{narrow:?}");
589 }
590
591 #[test]
592 fn dropping_that_would_buy_nothing_is_not_done() {
593 // A title that overflows on its own. Dropping the trailing facts cannot
594 // make it fit, and they were past the cut either way, so the run is left
595 // whole rather than quietly edited for no picture.
596 let out = drawn(
597 &Node::list([Row::new("a headline that is far too long for this pane").meta("2 files")]),
598 24,
599 4,
600 );
601 assert!(out[0].starts_with("a headline"), "{out:?}");
602 assert!(out[0].contains('\u{2026}'), "{out:?}");
603 }
604
605 #[test]
606 fn a_part_can_say_it_is_worth_more_than_its_role() {
607 use quasi_router::layout::Priority;
608
609 // The role is the default and the description overrules it. Meta is
610 // Optional by role and would go first; said Essential, the badge goes
611 // instead.
612 let out = drawn(
613 &Node::list([Row::new("Ship the release")
614 .token(Tag::badge("beta"))
615 .meta("2 files")
616 .worth(Priority::Essential)]),
617 25,
618 4,
619 );
620 assert!(out[0].contains("2 files"), "{out:?}");
621 assert!(!out[0].contains("beta"), "{out:?}");
622 }
623
624 #[test]
625 fn a_control_survives_a_squeeze_whatever_it_is_worth() {
626 use quasi_router::layout::Priority;
627
628 // Focus is claimed per part before layout, so dropping a control would
629 // leave a claim pointing at something nobody drew.
630 let out = drawn(
631 &Node::list([Row::new("Ship the release")
632 .act(Act::new("Open", Action::get("/1")))
633 .worth(Priority::Optional)
634 .meta("2 files")]),
635 26,
636 4,
637 );
638 assert!(out.concat().contains("Open"), "{out:?}");
639 }
640
641 #[test]
642 fn a_list_gets_the_bullet_the_description_refused_to_carry() {
643 // docengine says "this run is an item" and stops there, because what a
644 // bullet looks like is the renderer's answer. This is a terminal's.
645 let out = drawn(&Node::rich("- one\n- two"), 40, 4);
646 assert_eq!(out[0], "- one");
647 assert_eq!(out[1], "- two");
648 }
649
650 #[test]
651 fn a_quote_is_marked_in_the_margin_and_only_on_its_first_line() {
652 // A marker belongs at the head of a line and nowhere else. A run knows its
653 // block but not its position, so the marker is placed off the break the
654 // previous run ended with.
655 let out = drawn(&Node::rich("> quoted\n\nafter"), 40, 4);
656 assert_eq!(out[0], "> quoted");
657 assert_eq!(out[2], "after");
658 }
659
660 #[test]
661 fn emphasis_inside_a_heading_is_added_to_its_weight_rather_than_swapped_for_it() {
662 // The order a stylesheet uses: the block decides the ground and the marks
663 // go on top. A struck word in a heading is struck AND a heading.
664 let buf = buffer(&Node::rich("## Ship ~~later~~"), 40, 2);
665 assert_eq!(rows(&buf)[0], "Ship later");
666 assert_eq!(marked(&buf, 0, Modifier::BOLD), "Ship later");
667 assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "later");
668 }
669
670 #[test]
671 fn a_rich_block_keeps_the_breaks_the_author_wrote() {
672 // The reason a rich node cannot go through `draw_line`: that one wraps a
673 // run that is one line by construction, and two paragraphs run together
674 // read as one sentence that does not parse.
675 let out = drawn(&Node::rich("one\n\ntwo"), 40, 4);
676 assert_eq!(out[0], "one");
677 assert_eq!(out[1], "");
678 assert_eq!(out[2], "two");
679 }
680
681 #[test]
682 fn a_rich_node_inside_a_row_keeps_its_emphasis_too() {
683 // The other path into the same runs. A row's parts are one line of spans,
684 // so this goes through `inline_spans` rather than the block wrap, and the
685 // marks have to survive both.
686 let buf = buffer(
687 &Node::list([Row::new("Ship it").part(layout::RowPart::Meta, Node::rich("**now**"))]),
688 40,
689 3,
690 );
691 // Two spaces in the run, one on the row: `draw_line` breaks on whitespace,
692 // so the gap between two parts is a separator and not a measure.
693 assert_eq!(rows(&buf)[0], "Ship it now");
694 assert_eq!(marked(&buf, 0, Modifier::BOLD), "now");
695 }
696
697 #[test]
698 fn a_rich_block_wraps_without_losing_which_words_were_marked() {
699 // The wrap breaks a run across rows, so the marks have to travel with the
700 // words rather than with the run they arrived in.
701 let buf = buffer(&Node::rich("plain **one two three** plain"), 12, 4);
702 let out = rows(&buf);
703 assert_eq!(out[0], "plain one");
704 assert_eq!(out[1], "two three");
705 assert_eq!(out[2], "plain");
706 assert_eq!(marked(&buf, 0, Modifier::BOLD), "one");
707 assert_eq!(marked(&buf, 1, Modifier::BOLD), "two three");
708 }
709
710 #[test]
711 fn a_hidden_field_draws_nothing_at_all() {
712 let field = Field::new(layout::FieldKind::Hidden, "token", "Token");
713 let out = drawn(&Node::Field(Box::new(field)), 30, 3);
714 assert!(out.iter().all(String::is_empty), "{out:?}");
715 }
716
717 #[test]
718 fn a_secret_field_has_nothing_to_draw_and_that_is_a_finding() {
719 // `Field::value` drops what it is handed when the kind is `Secret`, on
720 // purpose. A webview never noticed, because the browser owns the contents
721 // of an `input` and redraws them itself. A terminal owns nothing, so what
722 // the user typed lives in the runtime's buffer, and this is the first node
723 // whose drawing is not a function of the description alone.
724 let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2");
725 assert_eq!(field.value, None);
726
727 let out = drawn(&Node::Field(Box::new(field)), 30, 3);
728 assert_eq!(out[0], "Password");
729 assert!(!out[1].contains("hunter2"), "{out:?}");
730 }
731
732 #[test]
733 fn a_markdown_field_is_drawn_over_several_rows_like_a_textarea() {
734 // Task f8ad0b32's terminal half. A terminal does nothing with the markdown
735 // and draws the source as text, which loses none of it; what it must not do
736 // is give a document one row.
737 let field = Field::new(layout::FieldKind::Rich, "body", "Body").value("# Heading");
738 let out = drawn(&Node::Field(Box::new(field)), 30, 4);
739 assert_eq!(out[0], "Body");
740 assert!(out[1].contains("# Heading"), "{out:?}");
741 }
742
743 #[test]
744 fn a_choice_field_marks_the_chosen_option() {
745 let field = Field::select(
746 "priority",
747 "Priority",
748 vec![Choice::plain("high"), Choice::plain("low")],
749 )
750 .value("low");
751 let out = drawn(&Node::Field(Box::new(field)), 30, 4);
752 assert_eq!(out[1], "( ) high");
753 assert_eq!(out[2], "(*) low");
754 }
755
756 #[test]
757 fn an_act_draws_its_key_because_the_description_carries_one() {
758 // `Act::key` is the one place the vocabulary already anticipated a
759 // terminal, and this is the renderer that finally reads it.
760 let act = Act::new("Delete", Action::post("/tasks/1/delete")).key("d");
761 let out = drawn(&Node::Act(act), 30, 2);
762 assert!(out[0].contains("Delete"), "{out:?}");
763 assert!(out[0].contains("(d)"), "{out:?}");
764 }
765
766 #[test]
767 fn a_table_narrows_by_dropping_the_columns_that_said_they_could_go() {
768 let table = Node::Table {
769 marks: ::quasi_router::stage::Marks::none(),
770 columns: vec![
771 Column::new("Name").priority(layout::Priority::Essential),
772 Column::new("Added").priority(layout::Priority::Optional),
773 ],
774 rows: vec![Row::cells(["kick.wav", "2026-08-12"])],
775 more: None,
776 };
777
778 let wide = drawn(&table, 60, 3);
779 assert!(wide[0].contains("Added"), "{wide:?}");
780
781 let narrow = drawn(&table, 14, 3);
782 assert!(!narrow[0].contains("Added"), "{narrow:?}");
783 }
784
785 #[test]
786 fn a_notice_belongs_to_the_screen_and_lands_above_every_region() {
787 let screen = Screen::sidebar_content("Tasks")
788 .saying(Node::Notice {
789 kind: layout::Notice::Banner,
790 tone: layout::Tone::Success,
791 text: "Saved".into(),
792 act: None,
793 })
794 .with(Slot::new("main", RegionKind::Pane).with(Node::section("Today")));
795
796 let out = shown(&screen, 40, 6);
797 assert_eq!(out[0], "Saved");
798 assert!(out.iter().any(|row| row.contains("Today")), "{out:?}");
799 }
800
801 #[test]
802 fn a_pending_region_says_so_in_words() {
803 let slot = Slot::new("detail", RegionKind::Pane)
804 .with(Node::text("Ready"))
805 .pending();
806 let out = drawn(&Node::Region(slot), 30, 3);
807 assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}");
808 assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}");
809 }
810
811 // The interaction runtime. Everything below is the half the browser was
812 // supplying: focus order, what is typed, where a key goes, what comes back.
813
814 use crate::focus::{Reach, Spot};
815 use crate::{Delayed, Key, Runtime, Step};
816 use quasi_router::{
817 Address, Chrome, Frame, Jump, Message, Method, Outcome, Request, Response, Rest,
818 };
819
820 /// A screen with one region holding these nodes.
821 fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen {
822 Screen::sidebar_content("Test").with(
823 nodes
824 .into_iter()
825 .fold(Slot::new("main", RegionKind::Pane), Slot::with),
826 )
827 }
828
829 /// The path a step is calling, for a step that calls one.
830 fn calling(step: &Step) -> Option<&str> {
831 match step {
832 Step::Call(request) => Some(request.path.as_str()),
833 _ => None,
834 }
835 }
836
837 #[test]
838 fn the_focus_walk_and_the_drawing_count_the_same_things() {
839 // The one invariant holding the two walks together. `focus.rs` decides how
840 // many reachable things a screen has and `node.rs` counts them as it draws,
841 // and if they ever disagree the caret lights a different control from the
842 // one Enter would call. Asserted over a screen carrying one of everything
843 // that can be reached.
844 let screen = screen_of([
845 Node::Act(Act::new("Save", Action::post("/save"))),
846 Node::Act(Act::new("Gone", Action::post("/gone")).disabled()),
847 Node::Link {
848 text: "Docs".into(),
849 action: Action::get("/docs"),
850 },
851 Node::field(Field::new(layout::FieldKind::Text, "name", "Name")),
852 Node::field(Field::new(layout::FieldKind::Hidden, "csrf", "")),
853 Node::Form {
854 marks: ::quasi_router::stage::Marks::none(),
855 action: Action::post("/new"),
856 submit: "Create".into(),
857 fields: vec![
858 Field::new(layout::FieldKind::Text, "title", "Title"),
859 Field::new(layout::FieldKind::Secret, "password", "Password"),
860 ],
861 },
862 Node::list([
863 Row::new("Open me").activate(Action::get("/one")),
864 Row::new("Just words"),
865 ])
866 .and_more(Rest::more(2, Action::get("/more"))),
867 Node::Table {
868 marks: ::quasi_router::stage::Marks::none(),
869 columns: vec![Column::new("Name")],
870 rows: vec![Row::cells(["one"]).activate(Action::get("/row"))],
871 more: None,
872 },
873 ]);
874
875 let expected = crate::focus::spots(&screen, &Local::none()).len();
876
877 // The count the drawing keeps is private, so it is read through the only
878 // thing it drives: focusing the nth reachable thing has to change the
879 // picture. The baseline is focus one past the end, where nothing is lit.
880 //
881 // What this catches is the walks drifting apart. If the drawing counted
882 // fewer things than `spots` records, the last indices would light nothing
883 // and come back identical to the baseline; if it counted them in another
884 // order, the caret would still move but a later test would find it on the
885 // wrong control. This is the cheap half, and it is the half that breaks
886 // silently.
887 let area = Rect::new(0, 0, 60, 40);
888 let draw = |view: &View| {
889 let mut buf = Buffer::empty(area);
890 tui().screen(&screen, view, area, &mut buf);
891 buf
892 };
893
894 let mut past = View::new();
895 past.focus_on(expected, expected + 1);
896 let unlit = draw(&past);
897
898 for at in 0..expected {
899 let mut view = View::new();
900 view.focus_on(at, expected);
901 assert_ne!(
902 draw(&view),
903 unlit,
904 "focusing {at} of {expected} changed nothing on the screen"
905 );
906 }
907 }
908
909 #[test]
910 fn a_secret_field_draws_what_was_typed_and_the_description_never_carries_it() {
911 // `39057019`. The description refuses to hold a password, so the dots can
912 // only come from the view, and this is the node that would be undrawable
913 // without the second argument.
914 let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2");
915 assert_eq!(field.value, None, "a secret refuses a described value");
916
917 let node = Node::field(field);
918 let area = Rect::new(0, 0, 30, 4);
919
920 let mut buf = Buffer::empty(area);
921 tui().node(&node, &View::new(), area, &mut buf);
922 assert!(
923 !rows(&buf).iter().any(|row| row.contains('*')),
924 "nothing typed yet"
925 );
926
927 let mut view = View::new();
928 view.set("password", "hunter2");
929 let mut buf = Buffer::empty(area);
930 tui().node(&node, &view, area, &mut buf);
931 assert!(
932 rows(&buf).iter().any(|row| row.contains("*******")),
933 "{:?}",
934 rows(&buf)
935 );
936 }
937
938 /// `drums` shut over one child, `genre` open over one.
939 fn outline() -> Node {
940 Node::list([
941 Row::new("drums")
942 .disclosing(false)
943 .activate(Action::get("/tags/drums")),
944 Row::new("drums.kick")
945 .depth(quasi_router::layout::Nesting::at(1))
946 .activate(Action::get("/k")),
947 Row::new("genre")
948 .disclosing(true)
949 .activate(Action::get("/g")),
950 Row::new("genre.house")
951 .depth(quasi_router::layout::Nesting::at(1))
952 .activate(Action::get("/h")),
953 ])
954 }
955
956 #[test]
957 fn a_shut_branch_draws_neither_its_children_nor_room_for_them() {
958 let drawn = rows(&buffer(&outline(), 40, 6));
959 let text = drawn.join("\n");
960 assert!(text.contains("drums"), "{drawn:?}");
961 assert!(!text.contains("drums.kick"), "{drawn:?}");
962 assert!(text.contains("genre.house"), "{drawn:?}");
963 // Shut and open say so, in the column before the words.
964 assert!(text.contains('\u{25b6}'), "{drawn:?}");
965 assert!(text.contains('\u{25bc}'), "{drawn:?}");
966 }
967
968 #[test]
969 fn a_child_is_indented_under_the_branch_that_holds_it() {
970 let drawn = rows(&buffer(&outline(), 40, 6));
971 let parent = drawn
972 .iter()
973 .find(|row| row.contains("genre") && !row.contains("house"))
974 .expect("the branch");
975 let child = drawn
976 .iter()
977 .find(|row| row.contains("genre.house"))
978 .expect("the child");
979 // In cells, not bytes: the chevron is one column and three bytes.
980 let column = |row: &str, text: &str| {
981 row.chars().count() - row[row.find(text).expect("the words")..].chars().count()
982 };
983 assert!(
984 column(child, "genre.house") > column(parent, "genre"),
985 "{drawn:?}"
986 );
987 }
988
989 #[test]
990 fn the_right_key_opens_a_branch_and_the_left_key_shuts_it() {
991 // `ccaa7e4b`. The keys every tree in a terminal already answers, and the
992 // rows they reveal were in the description all along -- folding asks the
993 // app nothing. See `Row::open`.
994 let mut runtime = Runtime::new(screen_of([outline()]));
995
996 // The caret starts on the shut branch, so Enter is the branch's own route
997 // and not its child's.
998 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/tags/drums"));
999 // One stop past it is the next row drawn, which is `genre` while `drums`
1000 // is shut.
1001 runtime.key(Key::Tab);
1002 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/g"));
1003
1004 // Open it, and its child is a stop.
1005 runtime.key(Key::BackTab);
1006 assert_eq!(runtime.key(Key::Right), Step::Idle);
1007 runtime.key(Key::Tab);
1008 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/k"));
1009
1010 // Shut it again, and the child goes with it.
1011 runtime.key(Key::BackTab);
1012 runtime.key(Key::Left);
1013 runtime.key(Key::Tab);
1014 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/g"));
1015 }
1016
1017 #[test]
1018 fn a_branch_is_reachable_even_when_nothing_else_about_it_is() {
1019 // The chevron is the affordance, so a row that only holds one still takes a
1020 // stop -- otherwise the branch is drawn and cannot be opened.
1021 let mut runtime = Runtime::new(screen_of([Node::list([
1022 Row::new("drums").disclosing(false),
1023 Row::new("drums.kick")
1024 .depth(quasi_router::layout::Nesting::at(1))
1025 .activate(Action::get("/k")),
1026 ])]));
1027 runtime.key(Key::Right);
1028 assert_eq!(calling(&runtime.key(Key::Enter)), None);
1029 runtime.key(Key::Tab);
1030 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/k"));
1031 }
1032
1033 #[test]
1034 fn a_list_with_no_branch_spends_no_room_on_one() {
1035 // Every list described before `Row::open` existed, drawn as it was.
1036 let drawn = rows(&buffer(
1037 &Node::list([Row::new("one"), Row::new("two")]),
1038 20,
1039 3,
1040 ));
1041 assert_eq!(drawn[0].trim_start(), "one", "{drawn:?}");
1042 }
1043
1044 #[test]
1045 fn tab_walks_the_screen_and_enter_calls_what_it_lands_on() {
1046 let mut runtime = Runtime::new(screen_of([
1047 Node::Act(Act::new("First", Action::post("/first"))),
1048 Node::Act(Act::new("Second", Action::post("/second"))),
1049 ]));
1050
1051 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
1052 assert_eq!(runtime.key(Key::Tab), Step::Idle);
1053 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/second"));
1054 // Wrapping, because a dead stop at the end reads as a broken key.
1055 runtime.key(Key::Tab);
1056 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
1057 }
1058
1059 #[test]
1060 fn a_disabled_control_is_drawn_and_never_landed_on() {
1061 let mut runtime = Runtime::new(screen_of([
1062 Node::Act(Act::new("Gone", Action::post("/gone")).disabled()),
1063 Node::Act(Act::new("Live", Action::post("/live"))),
1064 ]));
1065 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/live"));
1066 }
1067
1068 #[test]
1069 fn the_runtime_starts_on_the_first_reach_and_the_description_gets_no_say() {
1070 // The guarantee that replaced `layout::State::Focus`. A description used to
1071 // be able to claim the starting control; focus is this renderer's now, and
1072 // the rule is the plain one: first thing you can reach.
1073 let mut runtime = Runtime::new(screen_of([
1074 Node::Act(Act::new("First", Action::post("/first"))),
1075 Node::Act(Act::new("Second", Action::post("/second"))),
1076 ]));
1077 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
1078 }
1079
1080 #[test]
1081 fn a_key_the_description_named_reaches_its_control_from_anywhere() {
1082 // `Act::key` is the one place the vocabulary already anticipated a
1083 // terminal, and this is the renderer that binds it.
1084 let mut runtime = Runtime::new(screen_of([
1085 Node::Act(Act::new("First", Action::post("/first"))),
1086 Node::Act(Act::new("New", Action::get("/new")).key("n")),
1087 ]));
1088 assert_eq!(calling(&runtime.key(Key::Char('n'))), Some("/new"));
1089 // A key nothing claimed does nothing rather than something surprising.
1090 assert_eq!(runtime.key(Key::Char('z')), Step::Idle);
1091 }
1092
1093 #[test]
1094 fn a_control_that_asks_first_is_not_called_until_it_is_answered() {
1095 let mut runtime = Runtime::new(screen_of([Node::Act(
1096 Act::new("Delete", Action::post("/delete")).confirm("Delete this?"),
1097 )]));
1098
1099 assert_eq!(
1100 runtime.key(Key::Enter),
1101 Step::Ask("Delete this?".to_string())
1102 );
1103 assert!(runtime.asking());
1104 assert_eq!(runtime.key(Key::Char('n')), Step::Idle);
1105
1106 assert!(matches!(runtime.key(Key::Enter), Step::Ask(_)));
1107 assert_eq!(calling(&runtime.key(Key::Char('y'))), Some("/delete"));
1108 }
1109
1110 #[test]
1111 fn the_caret_starts_in_the_question_the_screen_named() {
1112 // The whole of what `Screen::opens_at` buys a terminal: the sessionless
1113 // form where the only thing to do is type, and the caret starting anywhere
1114 // else is a keystroke spent before the reader can begin.
1115 let screen = screen_of([Node::Form {
1116 marks: ::quasi_router::stage::Marks::none(),
1117 action: Action::post("/login"),
1118 submit: "Log in".into(),
1119 fields: vec![
1120 Field::new(layout::FieldKind::Text, "email", "Email"),
1121 Field::new(layout::FieldKind::Secret, "password", "Password"),
1122 ],
1123 }])
1124 .opening_at("password");
1125 let mut runtime = Runtime::new(screen);
1126 for ch in "hunter2".chars() {
1127 runtime.key(Key::Char(ch));
1128 }
1129 // The submit is the third stop, so two tabs from the second box.
1130 runtime.key(Key::Tab);
1131 let Step::Call(request) = runtime.key(Key::Enter) else {
1132 panic!("the submit calls its route");
1133 };
1134 assert_eq!(request.payload.get("password"), Some("hunter2"));
1135 assert_eq!(request.payload.get("email"), Some(""));
1136 }
1137
1138 #[test]
1139 fn a_screen_that_names_nothing_starts_where_it_always_did() {
1140 let mut runtime = Runtime::new(screen_of([Node::Form {
1141 marks: ::quasi_router::stage::Marks::none(),
1142 action: Action::post("/login"),
1143 submit: "Log in".into(),
1144 fields: vec![
1145 Field::new(layout::FieldKind::Text, "email", "Email"),
1146 Field::new(layout::FieldKind::Secret, "password", "Password"),
1147 ],
1148 }]));
1149 for ch in "max".chars() {
1150 runtime.key(Key::Char(ch));
1151 }
1152 runtime.key(Key::Tab);
1153 runtime.key(Key::Tab);
1154 let Step::Call(request) = runtime.key(Key::Enter) else {
1155 panic!("the submit calls its route");
1156 };
1157 assert_eq!(request.payload.get("email"), Some("max"));
1158 }
1159
1160 #[test]
1161 fn a_name_no_question_carries_leaves_the_caret_at_the_first_stop() {
1162 // The member's own bargain: the screen is the app's and so is the name.
1163 let mut runtime = Runtime::new(
1164 screen_of([Node::Form {
1165 marks: ::quasi_router::stage::Marks::none(),
1166 action: Action::post("/login"),
1167 submit: "Log in".into(),
1168 fields: vec![Field::new(layout::FieldKind::Text, "email", "Email")],
1169 }])
1170 .opening_at("nothing-is-called-this"),
1171 );
1172 for ch in "max".chars() {
1173 runtime.key(Key::Char(ch));
1174 }
1175 runtime.key(Key::Tab);
1176 let Step::Call(request) = runtime.key(Key::Enter) else {
1177 panic!("the submit calls its route");
1178 };
1179 assert_eq!(request.payload.get("email"), Some("max"));
1180 }
1181
1182 #[test]
1183 fn typing_fills_a_box_and_a_form_submits_what_is_in_it() {
1184 let mut runtime = Runtime::new(screen_of([Node::Form {
1185 marks: ::quasi_router::stage::Marks::none(),
1186 action: Action::post("/new"),
1187 submit: "Create".into(),
1188 fields: vec![
1189 Field::new(layout::FieldKind::Text, "title", "Title"),
1190 Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent"),
1191 ],
1192 }]));
1193
1194 assert!(runtime.editing());
1195 for ch in "Ship".chars() {
1196 runtime.key(Key::Char(ch));
1197 }
1198 runtime.key(Key::Backspace);
1199
1200 // Onto the checkbox, which takes any key as a flip rather than as a
1201 // character, then onto the submit.
1202 runtime.key(Key::Tab);
1203 runtime.key(Key::Char(' '));
1204 runtime.key(Key::Tab);
1205
1206 let Step::Call(request) = runtime.key(Key::Enter) else {
1207 panic!("the submit calls its route");
1208 };
1209 assert_eq!(request.path, "/new");
1210 assert_eq!(request.method, Method::Post);
1211 assert_eq!(request.payload.get("title"), Some("Shi"));
1212 assert_eq!(request.payload.get("urgent"), Some(Node::SELECTED));
1213 }
1214
1215 #[test]
1216 fn an_unticked_box_sends_nothing_the_way_a_browser_sends_nothing() {
1217 let mut runtime = Runtime::new(screen_of([Node::Form {
1218 marks: ::quasi_router::stage::Marks::none(),
1219 action: Action::post("/new"),
1220 submit: "Create".into(),
1221 fields: vec![Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent")],
1222 }]));
1223 runtime.key(Key::Tab);
1224 let Step::Call(request) = runtime.key(Key::Enter) else {
1225 panic!("the submit calls its route");
1226 };
1227 assert!(!request.payload.contains("urgent"), "{:?}", request.payload);
1228 }
1229
1230 #[test]
1231 fn a_field_that_writes_as_it_changes_writes_when_the_value_is_complete() {
1232 // `8032fe61`. This asserted the opposite until 2026-08-27: every keystroke
1233 // was one call, so a search box was one request per letter. `Field::writes`
1234 // means the change is *complete*, which is what `quasi-webview` has always
1235 // emitted it as, and on a terminal a typed value is complete when the caret
1236 // walks off it.
1237 let mut runtime = Runtime::new(screen_of([Node::field(
1238 Field::new(layout::FieldKind::Text, "query", "Search").writes(Action::post("/search")),
1239 )]));
1240 assert!(
1241 matches!(runtime.key(Key::Char('a')), Step::Idle),
1242 "a keystroke is not a write"
1243 );
1244 assert!(matches!(runtime.key(Key::Char('b')), Step::Idle));
1245
1246 let Step::Call(request) = runtime.key(Key::Tab) else {
1247 panic!("leaving the box is the write");
1248 };
1249 assert_eq!(request.path, "/search");
1250 assert_eq!(request.payload.get("query"), Some("ab"), "the whole value");
1251 }
1252
1253 #[test]
1254 fn walking_through_a_box_nobody_altered_writes_nothing() {
1255 // The other half of the rule, and what a browser's `change` already
1256 // promises: leaving is not a write, changing and then leaving is.
1257 let mut runtime = Runtime::new(screen_of([Node::field(
1258 Field::new(layout::FieldKind::Text, "query", "Search")
1259 .value("kick")
1260 .writes(Action::post("/search")),
1261 )]));
1262 assert!(matches!(runtime.key(Key::Tab), Step::Idle));
1263 }
1264
1265 #[test]
1266 fn a_field_that_consults_asks_once_the_value_has_stood_still() {
1267 // The other half of the comment above. A consult carries the wait, so this
1268 // renderer no longer has to choose between calling on every keystroke and
1269 // inventing a delay the webview would disagree with.
1270 let mut runtime = Runtime::new(screen_of([Node::field(
1271 Field::new(layout::FieldKind::Text, "username", "Username")
1272 .consults(Action::get("/api/validate/username")),
1273 )]));
1274 let Step::CallAfter { asks } = runtime.key(Key::Char('m')) else {
1275 panic!("a consult waits");
1276 };
1277 let [Delayed { request, after }] = asks.as_slice() else {
1278 panic!("one question, one wait");
1279 };
1280 assert_eq!(request.path, "/api/validate/username");
1281 assert_eq!(request.payload.get("username"), Some("m"));
1282 assert_eq!(*after, Consult::SETTLES);
1283 }
1284
1285 #[test]
1286 fn a_consult_with_a_floor_stays_quiet_until_the_value_is_long_enough() {
1287 let mut runtime = Runtime::new(screen_of([Node::field(
1288 Field::new(layout::FieldKind::Text, "q", "Find a tag").consulting(
1289 Consult::new(Action::get("/discover/tag-suggest"))
1290 .after(std::time::Duration::from_millis(150))
1291 .at_least(2),
1292 ),
1293 )]));
1294
1295 // One letter is under the floor, and the floor is a fact about the
1296 // question rather than about the host, so every renderer owes the same
1297 // silence.
1298 assert!(matches!(runtime.key(Key::Char('e')), Step::Idle));
1299
1300 let Step::CallAfter { asks } = runtime.key(Key::Char('l')) else {
1301 panic!("two characters clears the floor");
1302 };
1303 let [Delayed { request, after }] = asks.as_slice() else {
1304 panic!("one question, one wait");
1305 };
1306 assert_eq!(request.payload.get("q"), Some("el"));
1307 assert_eq!(*after, std::time::Duration::from_millis(150));
1308
1309 // And deleting back under it asks nothing again.
1310 assert!(matches!(runtime.key(Key::Backspace), Step::Idle));
1311 }
1312
1313 #[test]
1314 fn one_keystroke_can_pose_two_questions_at_two_waits() {
1315 // `N8`. MNW's discover search asks a suggestion route and a results route
1316 // about one value. The runtime still owns no scheduler: this is one
1317 // keystroke producing one `Step`, and what varies is how many questions it
1318 // posed. The host runs the timer it was already running, once per entry.
1319 let mut runtime = Runtime::new(screen_of([Node::field(
1320 Field::new(layout::FieldKind::Text, "q", "Search")
1321 .consulting(
1322 Consult::new(Action::get("/discover/suggestions"))
1323 .after(std::time::Duration::from_millis(200)),
1324 )
1325 .consulting(
1326 Consult::new(Action::get("/discover/results"))
1327 .after(std::time::Duration::from_millis(150)),
1328 ),
1329 )]));
1330
1331 let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else {
1332 panic!("two consults still wait");
1333 };
1334 assert_eq!(asks.len(), 2);
1335 assert_eq!(asks[0].request.path, "/discover/suggestions");
1336 assert_eq!(asks[0].after, std::time::Duration::from_millis(200));
1337 assert_eq!(asks[1].request.path, "/discover/results");
1338 assert_eq!(asks[1].after, std::time::Duration::from_millis(150));
1339 // Both carry the value they are about.
1340 assert_eq!(asks[0].request.payload.get("q"), Some("a"));
1341 assert_eq!(asks[1].request.payload.get("q"), Some("a"));
1342 }
1343
1344 #[test]
1345 fn a_floor_is_per_question_and_not_per_field() {
1346 // The two questions about one box carry their own floors, so a value long
1347 // enough for one and not the other asks one of them.
1348 let mut runtime = Runtime::new(screen_of([Node::field(
1349 Field::new(layout::FieldKind::Text, "q", "Search")
1350 .consulting(Consult::new(Action::get("/discover/results")))
1351 .consulting(Consult::new(Action::get("/discover/suggestions")).at_least(2)),
1352 )]));
1353
1354 let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else {
1355 panic!("the unfloored question is asked");
1356 };
1357 assert_eq!(asks.len(), 1);
1358 assert_eq!(asks[0].request.path, "/discover/results");
1359
1360 let Step::CallAfter { asks } = runtime.key(Key::Char('b')) else {
1361 panic!("two characters clears the floor");
1362 };
1363 assert_eq!(asks.len(), 2);
1364 }
1365
1366 #[test]
1367 fn a_question_carries_the_controls_it_says_it_carries() {
1368 // Discover's results route answers about the current filters, so asking it
1369 // without them answers about a screen the user is not looking at. The
1370 // untouched select still sends what it is showing, which is the order a
1371 // submit reads values in.
1372 let mut runtime = Runtime::new(screen_of([
1373 Node::field(
1374 Field::select(
1375 "mode",
1376 "Mode",
1377 vec![Choice::new("all", "All"), Choice::new("mine", "Mine")],
1378 )
1379 .value("mine"),
1380 ),
1381 Node::field(
1382 Field::new(layout::FieldKind::Text, "q", "Search").consulting(
1383 Consult::new(Action::get("/discover/results")).sending(["mode", "absent"]),
1384 ),
1385 ),
1386 ]));
1387
1388 // The caret starts on the select; one Tab is into the search box.
1389 runtime.key(Key::Tab);
1390 let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else {
1391 panic!("the caret is in the field");
1392 };
1393 assert_eq!(asks[0].request.payload.get("q"), Some("a"));
1394 assert_eq!(asks[0].request.payload.get("mode"), Some("mine"));
1395 // A name nothing on the screen carries sends nothing, rather than an empty
1396 // value, so a route can tell "not on this screen" from "on it and blank".
1397 assert_eq!(asks[0].request.payload.get("absent"), None);
1398 }
1399
1400 #[test]
1401 fn a_panel_recomputes_from_every_dial_the_region_holds() {
1402 // `cb62a9dc`. MNW's fee calculator. The browser puts one trigger on the
1403 // region and lets the document gather what it contains; here the
1404 // containment is walked, and both hosts read the same walk.
1405 let mut runtime = Runtime::new(
1406 Screen::sidebar_content("Pricing").with(
1407 Slot::group("pricing-calculator")
1408 .with(Node::field(
1409 Field::new(layout::FieldKind::Number, "item_price", "Price").value("10"),
1410 ))
1411 .with(Node::field(Field::new(
1412 layout::FieldKind::Number,
1413 "sales",
1414 "Sales per month",
1415 )))
1416 // Nested, because the dials sit in sections of the calculator
1417 // rather than directly in it, and a walk that stopped at the
1418 // first region would recompute from half of them.
1419 .with(Node::Region(Slot::group("other").with(Node::field(
1420 Field::new(layout::FieldKind::Number, "other_pct", "Their cut").value("30"),
1421 ))))
1422 .consulting(
1423 Consult::new(Action::get("/pricing/compare").replacing("results-panel"))
1424 .after(std::time::Duration::from_millis(300)),
1425 ),
1426 ),
1427 );
1428
1429 // The caret starts on the price box; one Tab is into the second dial.
1430 runtime.key(Key::Tab);
1431 let Step::CallAfter { asks } = runtime.key(Key::Char('4')) else {
1432 panic!("the region asks when a dial inside it moves");
1433 };
1434 assert_eq!(asks.len(), 1);
1435 assert_eq!(asks[0].request.path, "/pricing/compare");
1436 assert_eq!(asks[0].after, std::time::Duration::from_millis(300));
1437 // Every dial, at every depth, and the untouched ones send what they are
1438 // showing -- the order a submit reads a form in.
1439 assert_eq!(asks[0].request.payload.get("sales"), Some("4"));
1440 assert_eq!(asks[0].request.payload.get("item_price"), Some("10"));
1441 assert_eq!(asks[0].request.payload.get("other_pct"), Some("30"));
1442 }
1443
1444 #[test]
1445 fn a_dial_outside_the_region_it_recomputes_names_itself() {
1446 // What is inside is gathered by containment; what is outside says so, the
1447 // same way a field's consult names a sibling.
1448 let mut runtime = Runtime::new(
1449 Screen::sidebar_content("Pricing")
1450 .with(
1451 Slot::group("dials").with(Node::field(
1452 Field::select(
1453 "tier",
1454 "Tier",
1455 vec![Choice::new("16", "Basic"), Choice::new("24", "Small")],
1456 )
1457 .value("16"),
1458 )),
1459 )
1460 .with(
1461 Slot::group("calculator")
1462 .with(Node::field(Field::new(
1463 layout::FieldKind::Number,
1464 "sales",
1465 "Sales",
1466 )))
1467 .consulting(
1468 Consult::new(Action::get("/pricing/compare").replacing("results"))
1469 .sending(["tier"]),
1470 ),
1471 ),
1472 );
1473
1474 runtime.key(Key::Tab);
1475 let Step::CallAfter { asks } = runtime.key(Key::Char('4')) else {
1476 panic!("the region asks");
1477 };
1478 assert_eq!(asks[0].request.payload.get("sales"), Some("4"));
1479 assert_eq!(asks[0].request.payload.get("tier"), Some("16"));
1480 }
1481
1482 #[test]
1483 fn a_region_asks_only_about_the_dials_it_holds() {
1484 // A box somewhere else on the screen is not one of this panel's dials, so
1485 // typing into it recomputes nothing.
1486 let mut runtime = Runtime::new(
1487 Screen::sidebar_content("Pricing")
1488 .with(
1489 Slot::group("calculator")
1490 .with(Node::field(Field::new(
1491 layout::FieldKind::Number,
1492 "sales",
1493 "Sales",
1494 )))
1495 .consulting(Consult::new(
1496 Action::get("/pricing/compare").replacing("results"),
1497 )),
1498 )
1499 .with(Slot::group("notes").with(Node::field(Field::new(
1500 layout::FieldKind::Text,
1501 "note",
1502 "Note",
1503 )))),
1504 );
1505
1506 runtime.key(Key::Tab);
1507 assert!(matches!(runtime.key(Key::Char('x')), Step::Idle));
1508 }
1509
1510 #[test]
1511 fn a_region_floor_is_read_against_the_value_that_moved() {
1512 // Never against the gathered set: five dials holding one character each are
1513 // not five characters, which is what `Consult::asks_about` says and what
1514 // the browser's `event.target.value` filter says in its own words.
1515 let mut runtime = Runtime::new(
1516 Screen::sidebar_content("Search").with(
1517 Slot::group("results")
1518 .with(Node::field(Field::new(
1519 layout::FieldKind::Text,
1520 "q",
1521 "Query",
1522 )))
1523 .consulting(Consult::new(Action::get("/search").replacing("hits")).at_least(2)),
1524 ),
1525 );
1526
1527 assert!(matches!(runtime.key(Key::Char('a')), Step::Idle));
1528 let Step::CallAfter { asks } = runtime.key(Key::Char('b')) else {
1529 panic!("two characters clears the floor");
1530 };
1531 assert_eq!(asks[0].request.payload.get("q"), Some("ab"));
1532 }
1533
1534 #[test]
1535 fn a_screen_is_a_place_and_a_write_is_not() {
1536 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
1537
1538 // A read that answered a screen is somewhere to come back to.
1539 runtime.apply(
1540 &Request::get("/two"),
1541 Response {
1542 outcome: Outcome::Screen(screen_of([Node::text("second")])),
1543 notice: None,
1544 address: None,
1545 invalidates: Vec::new(),
1546 },
1547 );
1548 runtime.apply(
1549 &Request::get("/three"),
1550 Response {
1551 outcome: Outcome::Screen(screen_of([Node::text("third")])),
1552 notice: None,
1553 address: None,
1554 invalidates: Vec::new(),
1555 },
1556 );
1557
1558 assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two"));
1559
1560 // A write is not a place, so it leaves nothing behind to go back to.
1561 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
1562 runtime.apply(
1563 &Request::post("/save"),
1564 Response {
1565 outcome: Outcome::Screen(screen_of([Node::text("saved")])),
1566 notice: None,
1567 address: None,
1568 invalidates: Vec::new(),
1569 },
1570 );
1571 assert_eq!(runtime.key(Key::Escape), Step::Idle);
1572 }
1573
1574 #[test]
1575 fn a_response_can_say_it_is_not_a_place_when_the_derivation_would_say_it_is() {
1576 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
1577 runtime.apply(
1578 &Request::get("/transient"),
1579 Response {
1580 outcome: Outcome::Screen(screen_of([Node::text("transient")])),
1581 notice: None,
1582 address: Some(Address::Unchanged),
1583 invalidates: Vec::new(),
1584 },
1585 );
1586 assert_eq!(runtime.key(Key::Escape), Step::Idle);
1587 }
1588
1589 #[test]
1590 fn a_fragment_replaces_one_region_and_keeps_the_rest_of_the_screen() {
1591 let screen = Screen::sidebar_content("Test")
1592 .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("kept")))
1593 .with(Slot::new("main", RegionKind::Pane).with(Node::text("old")));
1594 let mut runtime = Runtime::new(screen);
1595
1596 let follow = runtime.apply(
1597 &Request::post("/change"),
1598 Response {
1599 outcome: Outcome::Fragment {
1600 region: "main".into(),
1601 node: Node::text("new"),
1602 },
1603 notice: None,
1604 address: None,
1605 invalidates: Vec::new(),
1606 },
1607 );
1608 assert!(follow.is_none());
1609
1610 let out = shown(runtime.screen(), 40, 8);
1611 assert!(out.iter().any(|row| row.contains("kept")), "{out:?}");
1612 assert!(out.iter().any(|row| row.contains("new")), "{out:?}");
1613 assert!(!out.iter().any(|row| row.contains("old")), "{out:?}");
1614 }
1615
1616 #[test]
1617 fn a_fragment_naming_a_region_that_is_not_there_says_so() {
1618 // `Screen::replace` answers false rather than panicking, and the caller is
1619 // the one that can act on it. A terminal drawing nothing would look like a
1620 // control that does nothing at all.
1621 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
1622 runtime.apply(
1623 &Request::post("/change"),
1624 Response {
1625 outcome: Outcome::Fragment {
1626 region: "nowhere".into(),
1627 node: Node::text("new"),
1628 },
1629 notice: None,
1630 address: None,
1631 invalidates: Vec::new(),
1632 },
1633 );
1634 let out = shown(runtime.screen(), 60, 8);
1635 assert!(out.iter().any(|row| row.contains("nowhere")), "{out:?}");
1636 }
1637
1638 #[test]
1639 fn going_somewhere_else_is_a_second_request_the_host_performs() {
1640 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
1641 let follow = runtime.apply(
1642 &Request::post("/delete"),
1643 Response {
1644 outcome: Outcome::Goto(Action::get("/list")),
1645 notice: None,
1646 address: None,
1647 invalidates: Vec::new(),
1648 },
1649 );
1650 assert_eq!(
1651 follow.map(|request| request.path),
1652 Some("/list".to_string())
1653 );
1654 }
1655
1656 #[test]
1657 fn what_a_response_says_lands_on_the_screen_it_belongs_to() {
1658 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
1659 runtime.apply(
1660 &Request::post("/save"),
1661 Response {
1662 outcome: Outcome::Screen(screen_of([Node::text("after")])),
1663 notice: Some(Message {
1664 kind: layout::Notice::Banner,
1665 tone: layout::Tone::Success,
1666 text: "Saved".into(),
1667 undo: None,
1668 }),
1669 address: None,
1670 invalidates: Vec::new(),
1671 },
1672 );
1673 let out = shown(runtime.screen(), 40, 6);
1674 assert_eq!(out[0], "Saved");
1675 }
1676
1677 #[test]
1678 fn the_way_back_a_response_offered_survives_the_conversion() {
1679 // `bde35298`. A retained-screen host turns a `Message` into a
1680 // `Node::Notice`, and until the node grew an act this dropped the undo:
1681 // "deleted, and here is how to put it back" arrived as "deleted".
1682 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
1683 runtime.apply(
1684 &Request::post("/tasks/7/delete"),
1685 Response {
1686 outcome: Outcome::Screen(screen_of([Node::text("after")])),
1687 notice: Some(Message {
1688 kind: layout::Notice::Toast,
1689 tone: layout::Tone::Success,
1690 text: "Deleted".into(),
1691 undo: Some(Action::post("/tasks/7/restore")),
1692 }),
1693 address: None,
1694 invalidates: Vec::new(),
1695 },
1696 );
1697
1698 let notice = runtime
1699 .screen()
1700 .notices
1701 .first()
1702 .expect("the notice arrived with the screen");
1703 let Node::Notice { act: Some(act), .. } = notice else {
1704 panic!("the undo did not survive: {notice:?}");
1705 };
1706 // The word is `Message::UNDO` and not this renderer's: three hosts naming
1707 // the control separately is three chances to disagree about the copy.
1708 assert_eq!(act.label, Message::UNDO);
1709 assert_eq!(act.action.route(), Some("/tasks/7/restore"));
1710
1711 // On the screen, under the sentence it belongs to, and somewhere the caret
1712 // can reach -- a control drawn but unreachable is the same as no control.
1713 let out = shown(runtime.screen(), 40, 6);
1714 assert!(out[0].contains("Deleted"), "{out:?}");
1715 assert!(out[1].contains("Undo"), "{out:?}");
1716 assert!(
1717 crate::focus::spots(runtime.screen(), &Local::none())
1718 .iter()
1719 .any(|spot| matches!(spot, Spot::Act { action, .. }
1720 if action.route() == Some("/tasks/7/restore"))),
1721 "the undo is drawn but the caret cannot get to it"
1722 );
1723 }
1724
1725 #[test]
1726 fn a_control_that_goes_back_asks_for_where_the_reader_came_from() {
1727 // `33c27e81`. The address is this runtime's history and not anything the
1728 // description could have named, which is the whole reason it is a
1729 // destination rather than a path some screen computes.
1730 let mut runtime = Runtime::new(screen_of([Node::text("the list")]));
1731 runtime.apply(
1732 &Request::get("/tasks"),
1733 Response::screen(screen_of([Node::text("the list")])),
1734 );
1735 runtime.apply(
1736 &Request::get("/settings"),
1737 Response::screen(screen_of([Node::Act(Act::new("Close", Action::back()))])),
1738 );
1739
1740 // The caret starts on the first reachable thing, which is the one control.
1741 assert_eq!(
1742 runtime.key(Key::Enter),
1743 Step::Call(Request::get("/tasks")),
1744 "back did not ask for the place before this one"
1745 );
1746 }
1747
1748 #[test]
1749 fn back_from_the_first_screen_goes_nowhere_rather_than_somewhere_wrong() {
1750 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Close", Action::back()))]));
1751 assert_eq!(runtime.key(Key::Enter), Step::Idle);
1752 }
1753
1754 #[test]
1755 fn the_slots_of_a_repeating_question_are_numbered_and_reachable() {
1756 // `f7abbc08`. The numbering is the renderer's, and the two controls are
1757 // reachable: a control that is drawn and that the caret cannot get to is
1758 // the same as no control.
1759 let mut group = Slot::new("conditions", RegionKind::Group).repeating(
1760 quasi_router::Repeating::new(
1761 "Condition",
1762 Act::new("Add condition", Action::post("/rules/conditions/add")),
1763 )
1764 .least(1),
1765 );
1766 for at in 0..2 {
1767 group = group.with(Node::Region(
1768 Slot::new(format!("condition-{at}"), RegionKind::Group)
1769 .with(Node::text(format!("condition {at}")))
1770 .removes(Act::new(
1771 "Remove",
1772 Action::post(format!("/rules/conditions/{at}/remove")),
1773 )),
1774 ));
1775 }
1776 let screen = Screen::sidebar_content("Rules")
1777 .with(Slot::new("main", RegionKind::Pane).with(Node::Region(group)));
1778
1779 let out = shown(&screen, 40, 24).join("\n");
1780 assert!(out.contains("Condition 1"), "{out}");
1781 assert!(out.contains("Condition 2"), "{out}");
1782 assert!(!out.contains("Condition 0"), "{out}");
1783 assert!(out.contains("Add condition"), "{out}");
1784
1785 let routes: Vec<String> = crate::focus::spots(&screen, &Local::none())
1786 .iter()
1787 .filter_map(|spot| match spot {
1788 Spot::Act { action, .. } => action.route().map(ToOwned::to_owned),
1789 _ => None,
1790 })
1791 .collect();
1792 assert_eq!(
1793 routes,
1794 [
1795 "/rules/conditions/0/remove",
1796 "/rules/conditions/1/remove",
1797 "/rules/conditions/add",
1798 ],
1799 "the walk and the drawing disagree about the repeating chrome"
1800 );
1801 }
1802
1803 #[test]
1804 fn a_readers_value_survives_a_fragment_because_the_view_holds_it() {
1805 // `a135f898` says the webview has to be told this and that a terminal was
1806 // already right. Confirmed rather than changed: what is typed lives in the
1807 // `View` under the field's name, and a fragment replaces a region rather
1808 // than the buffer.
1809 let field = || Field::new(layout::FieldKind::Text, "tag", "Tag").keeping_value();
1810 let mut runtime = Runtime::new(
1811 Screen::sidebar_content("Discover")
1812 .with(Slot::new("side", RegionKind::Sidebar).with(Node::field(field()))),
1813 );
1814 for ch in "dru".chars() {
1815 runtime.key(Key::Char(ch));
1816 }
1817
1818 // Something else on the screen answers, and the region the box sits in is
1819 // redrawn from a description that carries no value.
1820 runtime.apply(
1821 &Request::post("/discover/facet"),
1822 Response::from(Outcome::Fragment {
1823 region: "side".to_owned(),
1824 node: Node::field(field()),
1825 }),
1826 );
1827
1828 // The buffer is drawn from the view, so the view is where the claim is.
1829 // `shown` uses a fresh one and would prove nothing about what was kept.
1830 assert_eq!(
1831 runtime.view().edit("tag"),
1832 Some("dru"),
1833 "the reader's value was thrown away by a fragment"
1834 );
1835 let out = shown_under(runtime.screen(), runtime.view(), 40, 6).join("\n");
1836 assert!(out.contains("dru"), "kept, and not drawn: {out}");
1837 }
1838
1839 #[test]
1840 fn a_new_screen_forgets_what_was_typed_into_the_old_one() {
1841 // Two screens can name the same field, and carrying a buffer across would
1842 // put what was typed into one box into a different box that happens to
1843 // share its name.
1844 let mut runtime = Runtime::new(screen_of([Node::field(Field::new(
1845 layout::FieldKind::Text,
1846 "name",
1847 "Name",
1848 ))]));
1849 runtime.key(Key::Char('a'));
1850 assert_eq!(runtime.view().edit("name"), Some("a"));
1851
1852 runtime.apply(
1853 &Request::get("/other"),
1854 Response {
1855 outcome: Outcome::Screen(screen_of([Node::field(Field::new(
1856 layout::FieldKind::Text,
1857 "name",
1858 "Different question, same name",
1859 ))])),
1860 notice: None,
1861 address: None,
1862 invalidates: Vec::new(),
1863 },
1864 );
1865 assert_eq!(runtime.view().edit("name"), None);
1866 }
1867
1868 #[test]
1869 fn a_scrolled_region_shows_the_rows_under_the_ones_it_started_with() {
1870 let slot =
1871 Slot::new("main", RegionKind::Pane).extend((0..10).map(|n| Node::text(format!("row {n}"))));
1872 let node = Node::Region(slot);
1873 let area = Rect::new(0, 0, 20, 4);
1874
1875 // Row 0 of the buffer is the region's own frame, so the contents start on
1876 // row 1 and the window is what is left after the frame takes two.
1877 let mut buf = Buffer::empty(area);
1878 tui().node(&node, &View::new(), area, &mut buf);
1879 assert!(rows(&buf)[1].contains("row 0"), "{:?}", rows(&buf));
1880
1881 let mut view = View::new();
1882 view.scrolled_to("main", 3);
1883 let mut buf = Buffer::empty(area);
1884 tui().node(&node, &view, area, &mut buf);
1885 let out = rows(&buf);
1886 assert!(out[1].contains("row 3"), "{out:?}");
1887 assert!(!out.iter().any(|row| row.contains("row 0")), "{out:?}");
1888 }
1889
1890 #[test]
1891 fn scrolling_stops_at_the_bottom_of_what_there_is() {
1892 // The view holds a number and the drawing clamps it, because how far a
1893 // region can scroll is how tall it is at the width it was handed, and the
1894 // width is not known until it is drawn.
1895 let slot =
1896 Slot::new("main", RegionKind::Pane).extend((0..6).map(|n| Node::text(format!("row {n}"))));
1897 let node = Node::Region(slot);
1898 let area = Rect::new(0, 0, 20, 4);
1899
1900 // Six rows into a window of two, so the furthest down it can go is row 4
1901 // at the top: an offset past the end shows the last screenful and not a
1902 // blank region.
1903 let mut view = View::new();
1904 view.scrolled_to("main", 99);
1905 let mut buf = Buffer::empty(area);
1906 tui().node(&node, &view, area, &mut buf);
1907 let out = rows(&buf);
1908 assert!(out[1].contains("row 4"), "{out:?}");
1909 assert!(out[2].contains("row 5"), "{out:?}");
1910 }
1911
1912 #[test]
1913 fn a_page_key_scrolls_the_region_the_caret_is_in() {
1914 let screen = Screen::sidebar_content("Test")
1915 .with(
1916 Slot::new("side", RegionKind::Sidebar)
1917 .with(Node::Act(Act::new("Side", Action::get("/side")))),
1918 )
1919 .with(
1920 Slot::new("main", RegionKind::Pane)
1921 .with(Node::Act(Act::new("Main", Action::get("/main")))),
1922 );
1923 let mut runtime = Runtime::new(screen);
1924
1925 runtime.key(Key::PageDown);
1926 assert!(runtime.view().scroll("side") > 0);
1927 assert_eq!(runtime.view().scroll("main"), 0);
1928
1929 runtime.key(Key::Tab);
1930 runtime.key(Key::PageDown);
1931 assert!(runtime.view().scroll("main") > 0);
1932 }
1933
1934 #[test]
1935 fn a_modal_keeps_the_keyboard_until_it_is_gone() {
1936 // A dialog you can tab out of is not a dialog. The screen behind it is
1937 // still drawn, because covering it costs rows and says nothing.
1938 let screen = Screen::sidebar_content("Test")
1939 .with(
1940 Slot::new("main", RegionKind::Pane)
1941 .with(Node::Act(Act::new("Behind", Action::post("/behind")))),
1942 )
1943 .with(Slot::new("ask", RegionKind::Modal).with(Node::Act(Act::new(
1944 "In the dialog",
1945 Action::post("/dialog"),
1946 ))));
1947 let mut runtime = Runtime::new(screen);
1948
1949 assert_eq!(runtime.reaches().len(), 1);
1950 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog"));
1951 runtime.key(Key::Tab);
1952 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog"));
1953 }
1954
1955 #[test]
1956 fn an_external_address_is_handed_back_to_the_host() {
1957 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new(
1958 "Docs",
1959 Action::external("https://example.invalid/docs"),
1960 ))]));
1961 assert_eq!(
1962 runtime.key(Key::Enter),
1963 Step::Open("https://example.invalid/docs".to_string())
1964 );
1965 }
1966
1967 #[test]
1968 fn a_local_action_is_not_an_address_handed_to_the_host() {
1969 // `210574ca`. This renderer is `Renderer::Client` and may ignore the mark,
1970 // but ignoring is not mistaking: the external branch reads "no route" as
1971 // "somewhere outside", and before the mark was handled it would have asked
1972 // the host to open the empty string.
1973 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Dismiss", Action::local()))]));
1974 assert_eq!(runtime.key(Key::Enter), Step::Idle);
1975 }
1976
1977 #[test]
1978 fn a_tab_that_is_not_showing_holds_nothing_the_caret_can_reach() {
1979 // The tabbed cut is one function, so the drawing and the focus walk cannot
1980 // disagree about which region is on the screen.
1981 let screen = Screen::list_detail("Test", true)
1982 .with(
1983 Slot::new("list", RegionKind::Pane)
1984 .with(Node::Act(Act::new("Showing", Action::get("/showing")))),
1985 )
1986 .with(
1987 Slot::new("detail", RegionKind::Pane)
1988 .with(Node::Act(Act::new("Hidden", Action::get("/hidden")))),
1989 );
1990 let runtime = Runtime::new(screen);
1991 assert_eq!(runtime.reaches().len(), 1);
1992 assert!(matches!(
1993 runtime.focused(),
1994 Some(Spot::Act { ref action, .. }) if action.destination.as_str() == "/showing"
1995 ));
1996 }
1997
1998 #[test]
1999 fn a_reach_says_which_region_it_is_in() {
2000 let screen = Screen::sidebar_content("Test")
2001 .with(
2002 Slot::new("side", RegionKind::Sidebar)
2003 .with(Node::Act(Act::new("Side", Action::get("/side")))),
2004 )
2005 .with(
2006 Slot::new("main", RegionKind::Pane)
2007 .with(Node::Act(Act::new("Main", Action::get("/main")))),
2008 );
2009 let reaches: Vec<String> = crate::focus::reaches(&screen, &Local::none())
2010 .into_iter()
2011 .map(|Reach { region, .. }| region)
2012 .collect();
2013 assert_eq!(reaches, vec!["side".to_string(), "main".to_string()]);
2014 }
2015
2016 #[test]
2017 fn a_pending_region_holds_nothing_the_caret_can_reach() {
2018 let screen = Screen::sidebar_content("Test").with(
2019 Slot::new("main", RegionKind::Pane)
2020 .with(Node::Act(Act::new("Later", Action::get("/later"))))
2021 .pending(),
2022 );
2023 assert!(crate::focus::spots(&screen, &Local::none()).is_empty());
2024 }
2025
2026 #[test]
2027 fn a_row_and_the_controls_on_it_are_two_places_to_stand() {
2028 let mut runtime = Runtime::new(screen_of([Node::list([Row::new("Open me")
2029 .activate(Action::get("/open"))
2030 .act(Act::new("Remove", Action::delete("/remove")))])]));
2031
2032 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/open"));
2033 runtime.key(Key::Tab);
2034 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/remove"));
2035 }
2036
2037 #[test]
2038 fn ticking_a_row_calls_what_the_description_says_ticking_calls() {
2039 let mut runtime = Runtime::new(screen_of([Node::list([
2040 Row::new("Buy milk").toggling(false, Action::post("/done/1"))
2041 ])]));
2042 assert_eq!(calling(&runtime.key(Key::Char(' '))), Some("/done/1"));
2043 }
2044
2045 #[test]
2046 fn a_field_refuses_the_keystroke_past_the_length_the_description_set() {
2047 let mut field = Field::new(layout::FieldKind::Text, "code", "Code");
2048 field.max_length = Some(3);
2049 let mut runtime = Runtime::new(screen_of([Node::field(field)]));
2050 for ch in "abcdef".chars() {
2051 runtime.key(Key::Char(ch));
2052 }
2053 assert_eq!(runtime.view().edit("code"), Some("abc"));
2054 }
2055
2056 #[test]
2057 fn an_invalidated_slot_is_on_the_screen_beside_the_one_that_was_replaced() {
2058 // The row the write was aimed at, and the count above it that also moved.
2059 // On a terminal this is the whole of what an invalidation means: the next
2060 // frame redraws everything, so the answer only has to reach the screen.
2061 let mut runtime = Runtime::new(
2062 Screen::sidebar_content("Tasks")
2063 .with(Slot::new("row-7", RegionKind::Pane).with(Node::text("Open")))
2064 .with(Slot::new("task-count", RegionKind::Band).with(Node::text("5 left"))),
2065 );
2066
2067 runtime.apply(
2068 &Request::post("/tasks/7/done"),
2069 Response::fragment("row-7", Node::text("Done")).also("task-count", Node::text("4 left")),
2070 );
2071
2072 let out = shown(runtime.screen(), 40, 12);
2073 assert!(out.iter().any(|row| row.contains("Done")), "{out:?}");
2074 assert!(out.iter().any(|row| row.contains("4 left")), "{out:?}");
2075 assert!(!out.iter().any(|row| row.contains("5 left")), "{out:?}");
2076 }
2077
2078 #[test]
2079 fn an_invalidation_naming_no_region_is_reported_with_the_others() {
2080 // A description bug, and one banner naming every region that was missing
2081 // rather than a banner per region where only the last would survive.
2082 let mut runtime = Runtime::new(screen_of([Node::text("Open")]));
2083
2084 runtime.apply(
2085 &Request::post("/tasks/7/done"),
2086 Response::fragment("main", Node::text("Done"))
2087 .also("task-count", Node::text("4"))
2088 .also("sidebar-badge", Node::text("4")),
2089 );
2090
2091 let out = shown(runtime.screen(), 60, 12).join(" ");
2092 assert!(out.contains("task-count"), "{out}");
2093 assert!(out.contains("sidebar-badge"), "{out}");
2094 assert!(out.contains("are called"), "{out}");
2095 }
2096
2097 #[test]
2098 fn two_ticks_and_a_commit_control_send_both_values() {
2099 // The end of `5f2b8753`. The screen names the set, each row says what its
2100 // tick contributes, and the control says it acts over the set -- so a bulk
2101 // action works from one description with nothing gathering the ticks by
2102 // hand on either host.
2103 let mut runtime = Runtime::new(
2104 Screen::sidebar_content("Mail").selecting("chosen").with(
2105 Slot::new("main", RegionKind::Pane)
2106 .with(Node::list([
2107 Row::new("First").ticking("m-1", false),
2108 Row::new("Second").ticking("m-2", false),
2109 Row::new("Third").ticking("m-3", false),
2110 ]))
2111 .with(Node::Act(
2112 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
2113 )),
2114 ),
2115 );
2116
2117 // Walk to the first row and tick it, then the second.
2118 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
2119 runtime.key(Key::Tab);
2120 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
2121
2122 // Past the third row, onto the control.
2123 runtime.key(Key::Tab);
2124 runtime.key(Key::Tab);
2125 let Step::Call(request) = runtime.key(Key::Enter) else {
2126 panic!("the commit control calls its route");
2127 };
2128
2129 assert_eq!(request.path, "/mail/archive");
2130 assert_eq!(
2131 request
2132 .payload
2133 .get_all(quasi_router::Node::TICKED)
2134 .collect::<Vec<_>>(),
2135 ["m-1", "m-2"]
2136 );
2137 }
2138
2139 #[test]
2140 fn a_table_row_ticks_the_way_a_list_row_does() {
2141 // The same three keys and the same set. A table row is reachable because it
2142 // is tickable here, where before this it was reachable only if something
2143 // opened it -- a row drawing a box nobody can reach is the dead affordance
2144 // `5f2b8753` was filed for, one node over.
2145 let mut runtime = Runtime::new(
2146 Screen::sidebar_content("Tasks").selecting("chosen").with(
2147 Slot::new("main", RegionKind::Pane)
2148 .with(Node::Table {
2149 marks: ::quasi_router::stage::Marks::none(),
2150 columns: vec![quasi_router::Column::new("title")],
2151 rows: vec![
2152 Row::cells(["First"]).ticking("t-1", false),
2153 Row::cells(["Second"]).ticking("t-2", false),
2154 ],
2155 more: None,
2156 })
2157 .with(Node::Act(
2158 Act::new("Complete", Action::post("/tasks/complete")).over("chosen"),
2159 )),
2160 ),
2161 );
2162
2163 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
2164 runtime.key(Key::Tab);
2165 runtime.key(Key::Tab);
2166 let Step::Call(request) = runtime.key(Key::Enter) else {
2167 panic!("the commit control calls its route");
2168 };
2169 assert_eq!(
2170 request
2171 .payload
2172 .get_all(quasi_router::Node::TICKED)
2173 .collect::<Vec<_>>(),
2174 ["t-1"]
2175 );
2176 }
2177
2178 #[test]
2179 fn a_table_row_that_only_offers_a_menu_can_still_be_reached() {
2180 // `Row::menu`. In a terminal the menu is reached from the row and nowhere
2181 // else, so a row the caret cannot land on holds acts nothing can get at --
2182 // the same argument that made a tickable row reachable, one member along.
2183 let screen = Screen::sidebar_content("Files").with(Slot::new("main", RegionKind::Pane).with(
2184 Node::Table {
2185 marks: ::quasi_router::stage::Marks::none(),
2186 columns: vec![Column::new("Name")],
2187 rows: vec![
2188 Row::cells(["kick.wav"]).offers(Act::new("Preview", Action::post("/files/1/play"))),
2189 Row::cells(["snare.wav"]),
2190 ],
2191 more: None,
2192 },
2193 ));
2194
2195 let spots = crate::focus::spots(&screen, &Local::none());
2196 // One stop, for the one row that offers something. A row that neither opens,
2197 // ticks nor offers is passed over, which is what makes a table of readouts
2198 // something the caret does not walk through.
2199 assert_eq!(spots.len(), 1, "{spots:?}");
2200 let crate::focus::Spot::Row { menu, activate, .. } = &spots[0] else {
2201 panic!("a table row is a row: {spots:?}");
2202 };
2203 assert!(activate.is_none(), "{spots:?}");
2204 assert_eq!(menu.len(), 1, "{spots:?}");
2205 assert_eq!(menu[0].label, "Preview");
2206 }
2207
2208 #[test]
2209 fn the_drawing_counts_the_same_table_rows_the_walk_stops_on() {
2210 // A regression, and it predates `Row::menu`. `focus.rs` pushes a stop for
2211 // a row that opens, ticks or offers; `draw_table` claimed only the ones that
2212 // open. The claim counter is what decides which control draws as focused, so
2213 // a table of tickable rows left every control below it drawing the caret one
2214 // place early -- here, the second act lighting up while the runtime's caret
2215 // was on the first.
2216 let mut runtime = Runtime::new(
2217 Screen::sidebar_content("Tasks").selecting("chosen").with(
2218 Slot::new("main", RegionKind::Pane)
2219 .with(Node::Table {
2220 marks: ::quasi_router::stage::Marks::none(),
2221 columns: vec![Column::new("title")],
2222 rows: vec![
2223 Row::cells(["First"]).ticking("t-1", false),
2224 Row::cells(["Second"]).ticking("t-2", false),
2225 ],
2226 more: None,
2227 })
2228 .with(Node::Act(Act::new(
2229 "Archive",
2230 Action::post("/tasks/archive"),
2231 )))
2232 .with(Node::Act(Act::new("Purge", Action::post("/tasks/purge")))),
2233 ),
2234 );
2235
2236 // The caret starts on the first row, so two Tabs is past both and onto the
2237 // first act.
2238 runtime.key(Key::Tab);
2239 runtime.key(Key::Tab);
2240
2241 let area = Rect::new(0, 0, 40, 12);
2242 let mut buf = Buffer::empty(area);
2243 runtime.draw(&tui(), area, &mut buf);
2244 let lit: String = (0..area.height)
2245 .map(|y| marked(&buf, y, Modifier::REVERSED))
2246 .collect();
2247
2248 assert!(
2249 lit.contains("Archive"),
2250 "the caret's own control is lit: {lit:?}"
2251 );
2252 assert!(
2253 !lit.contains("Purge"),
2254 "and the one after it is not: {lit:?}"
2255 );
2256
2257 // The caret is where the drawing says it is: Enter calls Archive.
2258 let Step::Call(request) = runtime.key(Key::Enter) else {
2259 panic!("the focused control calls its route");
2260 };
2261 assert_eq!(request.path, "/tasks/archive");
2262 }
2263
2264 #[test]
2265 fn a_control_that_asks_for_a_value_draws_the_box_and_sends_what_is_typed() {
2266 // `033ff3ca`. MNW's bulk bar reveals a box behind "Set Price"; a terminal
2267 // has no disclosure to press, so the box stands above the control and the
2268 // press sends what is in it along with the ticks.
2269 let mut runtime = Runtime::new(
2270 Screen::sidebar_content("Items").selecting("chosen").with(
2271 Slot::new("main", RegionKind::Pane)
2272 .with(Node::list([Row::new("First").ticking("i-1", false)]))
2273 .with(Node::Act(
2274 Act::new("Set Price", Action::post("/items/price"))
2275 .over("chosen")
2276 .asking(Field::new(
2277 layout::FieldKind::Number,
2278 "price",
2279 "New price ($)",
2280 )),
2281 )),
2282 ),
2283 );
2284
2285 let drawn = held(&runtime, 60, 12);
2286 assert!(drawn.contains("New price ($)"), "{drawn}");
2287
2288 // Tick the row, then walk onto the box, type into it, and press the verb.
2289 runtime.key(Key::Char(' '));
2290 runtime.key(Key::Tab);
2291 for ch in "12".chars() {
2292 runtime.key(Key::Char(ch));
2293 }
2294 runtime.key(Key::Tab);
2295 let Step::Call(request) = runtime.key(Key::Enter) else {
2296 panic!("the control fires with what it asked for");
2297 };
2298
2299 assert_eq!(request.path, "/items/price");
2300 assert_eq!(request.payload.get("price"), Some("12"));
2301 assert_eq!(
2302 request
2303 .payload
2304 .get_all(quasi_router::Node::TICKED)
2305 .collect::<Vec<_>>(),
2306 ["i-1"]
2307 );
2308 }
2309
2310 #[test]
2311 fn a_commit_control_says_how_many_it_would_act_on() {
2312 // Not sayable in the description: the ticks are the host's until something
2313 // submits them, so a screen built from the store cannot carry the number.
2314 // `bulk-actions.js` writes "3 selected" into its bar; this renderer holds
2315 // the set itself and puts the count on the control it belongs to.
2316 let mut runtime = Runtime::new(
2317 Screen::sidebar_content("Tasks").selecting("chosen").with(
2318 Slot::new("main", RegionKind::Pane)
2319 .with(Node::list([
2320 Row::new("First").ticking("t-1", false),
2321 Row::new("Second").ticking("t-2", false),
2322 ]))
2323 .with(Node::Act(
2324 Act::new("Complete", Action::post("/tasks/complete")).over("chosen"),
2325 )),
2326 ),
2327 );
2328
2329 // Nothing ticked: the control says its own words and no number.
2330 let empty = held(&runtime, 60, 12);
2331 assert!(empty.contains("Complete"), "{empty}");
2332 assert!(!empty.contains("Complete ("), "{empty}");
2333
2334 runtime.key(Key::Char(' '));
2335 let one = held(&runtime, 60, 12);
2336 assert!(one.contains("Complete (1)"), "{one}");
2337
2338 runtime.key(Key::Tab);
2339 runtime.key(Key::Char(' '));
2340 let two = held(&runtime, 60, 12);
2341 assert!(two.contains("Complete (2)"), "{two}");
2342 }
2343
2344 #[test]
2345 fn a_commit_control_over_an_empty_selection_does_nothing_when_pressed() {
2346 let mut runtime = Runtime::new(
2347 Screen::sidebar_content("Tasks").selecting("chosen").with(
2348 Slot::new("main", RegionKind::Pane)
2349 .with(Node::list([Row::new("First").ticking("t-1", false)]))
2350 .with(Node::Act(
2351 Act::new("Complete", Action::post("/tasks/complete"))
2352 .key("c")
2353 .over("chosen"),
2354 )),
2355 ),
2356 );
2357
2358 // Onto the control, past the row, and press it with nothing ticked. Before
2359 // this the route was called and the handler answered "0 tasks completed",
2360 // which is a screen letting the reader find out by trying.
2361 runtime.key(Key::Tab);
2362 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
2363 // The key that reaches it is refused for the same reason.
2364 assert!(matches!(runtime.key(Key::Char('c')), Step::Idle));
2365
2366 // Tick one and it works again.
2367 runtime.key(Key::Tab);
2368 runtime.key(Key::Char(' '));
2369 assert_eq!(
2370 calling(&runtime.key(Key::Char('c'))),
2371 Some("/tasks/complete")
2372 );
2373 }
2374
2375 #[test]
2376 fn a_tick_is_staged_and_never_a_write() {
2377 // Wiki `explicit-commit-affordance`: a change that happens with no obvious
2378 // indication is confusing, so space stages and the commit control locks it
2379 // in. A row carrying `toggle` is the other case and still writes.
2380 let mut runtime = Runtime::new(
2381 Screen::sidebar_content("Mail").selecting("chosen").with(
2382 Slot::new("main", RegionKind::Pane)
2383 .with(Node::list([Row::new("First").ticking("m-1", false)])),
2384 ),
2385 );
2386
2387 assert!(
2388 matches!(runtime.key(Key::Char(' ')), Step::Idle),
2389 "a tick calls no route"
2390 );
2391 let out = held(&runtime, 40, 6);
2392 assert!(out.contains("[x]"), "{out}");
2393 }
2394
2395 #[test]
2396 fn a_tickable_row_that_names_nothing_still_binds_no_key() {
2397 // The dead affordance `5f2b8753` was filed for, one step earlier: a row
2398 // that can be ticked and says nothing about what the tick contributes has
2399 // nowhere to put it, so the key stays unbound rather than being bound to
2400 // nothing.
2401 let mut runtime = Runtime::new(Screen::sidebar_content("Mail").selecting("chosen").with(
2402 Slot::new("main", RegionKind::Pane).with(Node::list([Row::new("First").selectable(false)])),
2403 ));
2404
2405 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
2406 let out = held(&runtime, 40, 6);
2407 assert!(!out.contains("[x]"), "{out}");
2408 }
2409
2410 #[test]
2411 fn a_described_tick_starts_the_set_off_and_the_user_can_take_it_back() {
2412 // A description can say a row arrives ticked, and after that the user's
2413 // ticks are the truth -- the same rule `39057019` settled for a field.
2414 let mut runtime = Runtime::new(
2415 Screen::sidebar_content("Mail").selecting("chosen").with(
2416 Slot::new("main", RegionKind::Pane)
2417 .with(Node::list([Row::new("First").ticking("m-1", true)]))
2418 .with(Node::Act(
2419 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
2420 )),
2421 ),
2422 );
2423
2424 let out = held(&runtime, 40, 6);
2425 assert!(out.contains("[x]"), "{out}");
2426
2427 // Untick it and the set is empty, not the description's -- which the
2428 // commit control now shows by going inert rather than by calling its route
2429 // with nothing in it. Both are the view winning over the description; this
2430 // is the one that does not make the reader find out by pressing.
2431 runtime.key(Key::Char(' '));
2432 let empty = held(&runtime, 40, 6);
2433 assert!(!empty.contains("[x]"), "{empty}");
2434 assert!(!empty.contains("Archive ("), "{empty}");
2435
2436 runtime.key(Key::Tab);
2437 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
2438 }
2439
2440 #[test]
2441 fn a_terminal_and_a_webview_agree_about_a_screens_proportions() {
2442 // The done-condition of `e0fd485e`: a screen described once, rendered by
2443 // two hosts, agreeing about its proportions. The assertion that did not
2444 // exist while each renderer held its own number.
2445 let screen = Screen::sidebar_content("Mail")
2446 .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("Folders")))
2447 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages")));
2448
2449 // A quarter of 100 columns is 25, and the webview writes the same quarter
2450 // into its grid. Read off the description rather than off either renderer.
2451 let share = screen
2452 .arrangement
2453 .share()
2454 .expect("a sidebar divides a width");
2455 assert_eq!(share.as_percent(), 25);
2456 assert_eq!(share.of(100), 25);
2457
2458 // And the terminal honours it rather than a number of its own: "Folders"
2459 // fits in 25 columns and "Messages" starts after them.
2460 let out = shown(&screen, 100, 6);
2461 assert!(column_of(&out, "Folders") == Some(0), "{out:?}");
2462 assert!(
2463 column_of(&out, "Messages").is_some_and(|at| at >= 25),
2464 "the content should start after the sidebar's quarter: {out:?}"
2465 );
2466 }
2467
2468 #[test]
2469 fn a_narrower_share_moves_the_boundary_on_the_terminal_too() {
2470 let screen = Screen::new(
2471 "Mail",
2472 layout::Arrangement::sidebar_content().with_share(layout::Share::percent(10)),
2473 )
2474 .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("F")))
2475 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages")));
2476
2477 let out = shown(&screen, 100, 6);
2478 assert!(
2479 column_of(&out, "Messages").is_some_and(|at| (10..25).contains(&at)),
2480 "the boundary should follow the described share: {out:?}"
2481 );
2482 }
2483
2484 #[test]
2485 fn a_reading_measure_narrows_the_screen_and_centres_it() {
2486 // The terminal's answer to `Measure`, and the one with a reason outside
2487 // taste: past roughly 75 characters a line costs the reader the return
2488 // sweep.
2489 let wide = Screen::sidebar_content("Doc")
2490 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words")));
2491 let reading = wide.clone().measured(layout::Measure::Reading);
2492
2493 let full = shown(&wide, 120, 4);
2494 let narrowed = shown(&reading, 120, 4);
2495
2496 let at = |rows: &[String]| column_of(rows, "Words");
2497 assert!(
2498 at(&narrowed) > at(&full),
2499 "a narrowed screen is centred, so its content starts further in: \
2500 {full:?} then {narrowed:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn a_terminal_narrower_than_the_measure_is_left_alone() {
2506 // There is no measure to enforce when the window is already tighter than
2507 // it, and padding one would waste the only columns there are.
2508 let screen = Screen::sidebar_content("Doc")
2509 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words")))
2510 .measured(layout::Measure::Reading);
2511
2512 let out = shown(&screen, 40, 4);
2513 // Drawn at all, and not squeezed into a centred column of a window that is
2514 // already narrower than the cap.
2515 assert!(
2516 column_of(&out, "Words").is_some_and(|at| at < 15),
2517 "{out:?}"
2518 );
2519 }
2520
2521 #[test]
2522 fn an_app_binding_fires_from_a_screen_that_knows_nothing_about_it() {
2523 // The whole claim chrome makes: the key works here, and here never
2524 // declared it.
2525 let mut runtime = Runtime::new(screen_of([Node::text("anywhere")]))
2526 .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette")));
2527 assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette"));
2528 }
2529
2530 #[test]
2531 fn an_app_binding_beats_a_control_that_wanted_the_same_key() {
2532 // Order, not preference: a screen that could capture the palette's key is
2533 // a screen on which the palette is not available everywhere.
2534 let mut runtime = Runtime::new(screen_of([Node::Act(
2535 Act::new("New", Action::get("/new")).key("k"),
2536 )]))
2537 .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette")));
2538 assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette"));
2539 }
2540
2541 #[test]
2542 fn a_field_keeps_the_printable_key_a_binding_wanted() {
2543 // A binding cannot make a letter untypeable. The field has the keyboard,
2544 // so the character goes in the box.
2545 let mut runtime = Runtime::new(screen_of([Node::Field(Box::new(Field::new(
2546 layout::FieldKind::Text,
2547 "q",
2548 "Query",
2549 )))]))
2550 .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette")));
2551 assert!(matches!(runtime.key(Key::Char('k')), Step::Idle));
2552 // Through the runtime's own view: what was typed lives there, not in the
2553 // description.
2554 let drawn = held(&runtime, 40, 6);
2555 assert!(drawn.contains('k'), "{drawn:?}");
2556 }
2557
2558 #[test]
2559 fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() {
2560 let mut runtime = Runtime::new(screen_of([Node::text("underneath")]));
2561 // Two navigations, because history holds where you *were*: the first
2562 // records where we are and the second pushes it behind us. Now Escape has
2563 // something to consume if an overlay is wrongly treated as a navigation.
2564 for path in ["/two", "/three"] {
2565 runtime.apply(
2566 &Request::get(path),
2567 Response {
2568 outcome: Outcome::Screen(screen_of([Node::text("a place")])),
2569 notice: None,
2570 address: None,
2571 invalidates: Vec::new(),
2572 },
2573 );
2574 }
2575
2576 runtime.apply(
2577 &Request::get("/palette"),
2578 Response {
2579 outcome: Outcome::Over(screen_of([Node::text("palette")])),
2580 notice: None,
2581 address: None,
2582 invalidates: Vec::new(),
2583 },
2584 );
2585 assert!(runtime.overlaid());
2586
2587 // Escape closes the overlay and calls nothing: history was never touched.
2588 assert!(matches!(runtime.key(Key::Escape), Step::Idle));
2589 assert!(!runtime.overlaid());
2590 // And the history the overlay did not consume is still there.
2591 assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two"));
2592 }
2593
2594 #[test]
2595 fn dismissing_an_overlay_leaves_the_screen_under_it_exactly_as_it_was() {
2596 // The regression a shared `View` would produce: the user's typing and the
2597 // control they had walked to would come back changed, or not at all.
2598 let mut runtime = Runtime::new(screen_of([
2599 Node::Field(Box::new(Field::new(
2600 layout::FieldKind::Text,
2601 "title",
2602 "Title",
2603 ))),
2604 Node::Act(Act::new("Save", Action::post("/save"))),
2605 ]));
2606 runtime.key(Key::Char('h'));
2607 runtime.key(Key::Char('i'));
2608 // Walk off the field, so focus is somewhere the overlay could disturb.
2609 runtime.key(Key::Tab);
2610
2611 runtime.apply(
2612 &Request::get("/palette"),
2613 Response {
2614 outcome: Outcome::Over(screen_of([Node::Field(Box::new(Field::new(
2615 layout::FieldKind::Text,
2616 "q",
2617 "Query",
2618 )))])),
2619 notice: None,
2620 address: None,
2621 invalidates: Vec::new(),
2622 },
2623 );
2624 // The overlay's own view: typing here must not reach the screen beneath.
2625 runtime.key(Key::Char('z'));
2626 runtime.key(Key::Escape);
2627
2628 // Focus came back where it was left: Enter calls Save rather than sitting
2629 // in the field.
2630 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save"));
2631 let drawn = held(&runtime, 40, 8);
2632 assert!(
2633 drawn.contains("hi"),
2634 "the typing survived the overlay: {drawn:?}"
2635 );
2636 assert!(
2637 !drawn.contains('z'),
2638 "the overlay's typing stayed in the overlay: {drawn:?}"
2639 );
2640 }
2641
2642 #[test]
2643 fn a_navigation_takes_the_overlay_with_it() {
2644 // Arriving somewhere new with a palette still floating over it is the
2645 // state nobody asked for.
2646 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
2647 runtime.apply(
2648 &Request::get("/palette"),
2649 Response {
2650 outcome: Outcome::Over(screen_of([Node::text("palette")])),
2651 notice: None,
2652 address: None,
2653 invalidates: Vec::new(),
2654 },
2655 );
2656 runtime.apply(
2657 &Request::get("/two"),
2658 Response {
2659 outcome: Outcome::Screen(screen_of([Node::text("second")])),
2660 notice: None,
2661 address: None,
2662 invalidates: Vec::new(),
2663 },
2664 );
2665 assert!(!runtime.overlaid());
2666 }
2667
2668 /// A carousel: three captioned frames, the first up, no label anywhere.
2669 fn gallery() -> Slot {
2670 Slot::widget("shots", "carousel")
2671 .extend((0..3).map(|n| {
2672 Node::Image(quasi_router::Image::new(
2673 format!("/shot-{n}.png"),
2674 format!("shot {n}"),
2675 ))
2676 }))
2677 .showing_one(0)
2678 }
2679
2680 /// Draw a screen with a view that has been moved.
2681 fn with_view(screen: &Screen, view: &View, width: u16, height: u16) -> Vec<String> {
2682 let area = Rect::new(0, 0, width, height);
2683 let mut buf = Buffer::empty(area);
2684 tui().screen(screen, view, area, &mut buf);
2685 rows(&buf)
2686 }
2687
2688 #[test]
2689 fn a_carousel_is_one_frame_and_a_row_rather_than_a_stack() {
2690 // `c0b63ea9`'s terminal half, and it was never a drawing change: until
2691 // `Showing` existed a terminal had no way to learn that a stack of pictures
2692 // was meant to be one picture, so it honestly drew the stack.
2693 let screen = Screen::list_detail("Product", false).with(gallery());
2694 let out = shown(&screen, 90, 12).join("\n");
2695
2696 assert!(out.contains("shot 0"), "{out}");
2697 assert!(!out.contains("shot 1"), "{out}");
2698 assert!(out.contains("< Prev >"), "{out}");
2699 assert!(out.contains("1 / 3"), "{out}");
2700 assert!(out.contains("< Next >"), "{out}");
2701 }
2702
2703 #[test]
2704 fn the_row_is_under_the_frame_and_overlays_nothing() {
2705 // The reason the chrome ports at all. A terminal cannot honestly overlay
2706 // anything, so the arrows a browser drew over the picture had no form here
2707 // -- and Max had already called them cluttered in the browser.
2708 let screen = Screen::list_detail("Product", false).with(gallery());
2709 let out = shown(&screen, 90, 12);
2710
2711 let frame = out.iter().position(|row| row.contains("shot 0"));
2712 let row = out.iter().position(|row| row.contains("< Prev >"));
2713 assert!(frame < row, "{out:?}");
2714 }
2715
2716 #[test]
2717 fn a_terminal_draws_the_same_chrome_without_knowing_what_a_carousel_is() {
2718 // The whole design in one assertion: the widget's name is changed and the
2719 // chrome is identical, because nothing in this renderer reads it.
2720 let named = Screen::list_detail("Product", false).with(gallery());
2721 let mut other = gallery();
2722 other.kind = RegionKind::Widget {
2723 name: "lookbook".into(),
2724 };
2725 let unnamed = Screen::list_detail("Product", false).with(other);
2726
2727 assert_eq!(shown(&named, 90, 12), shown(&unnamed, 90, 12));
2728 }
2729
2730 #[test]
2731 fn the_arrow_keys_move_a_carousel_the_caret_can_never_be_inside() {
2732 // A frame is a picture, so nothing in a carousel is reachable and focus can
2733 // never land in it. That is why `moving` falls back to the first such region
2734 // rather than only ever asking where the caret is.
2735 let screen = Screen::list_detail("Product", false).with(gallery());
2736 let mut view = View::new();
2737
2738 let slot = screen.slots[0]
2739 .find("shots")
2740 .expect("the carousel is there");
2741 view.show_by(slot, 1);
2742 let out = with_view(&screen, &view, 90, 12).join("\n");
2743
2744 assert!(out.contains("shot 1"), "{out}");
2745 assert!(out.contains("2 / 3"), "{out}");
2746 }
2747
2748 #[test]
2749 fn moving_past_the_last_frame_wraps() {
2750 // `View::advance`'s reason: a terminal has nothing to show you that you are
2751 // at the end, so a next key that stops dead reads as a broken key.
2752 let screen = Screen::list_detail("Product", false).with(gallery());
2753 let slot = screen.slots[0]
2754 .find("shots")
2755 .expect("the carousel is there");
2756 let mut view = View::new();
2757
2758 view.show_by(slot, -1);
2759 assert_eq!(view.shown(slot), Some(2));
2760 view.show_by(slot, 1);
2761 assert_eq!(view.shown(slot), Some(0));
2762 }
2763
2764 #[test]
2765 fn labelled_children_draw_a_strip_above_the_pane_it_opens() {
2766 // `6af6810e`. A tab group had a kind and no way to say which tab was open or
2767 // what it was called, so this drew the first and used the slot id as a
2768 // heading. Both halves are answered by the same member.
2769 let screen = Screen::list_detail("Project", false).with(
2770 Slot::new("detail", RegionKind::TabGroup)
2771 .frame(
2772 "Overview",
2773 Node::Region(
2774 Slot::new("overview", RegionKind::Pane).with(Node::text("the summary")),
2775 ),
2776 )
2777 .frame(
2778 "Files",
2779 Node::Region(Slot::new("files", RegionKind::Pane).with(Node::text("the files"))),
2780 )
2781 .showing_one(1),
2782 );
2783 let out = shown(&screen, 60, 16);
2784 let joined = out.join("\n");
2785
2786 assert!(joined.contains("Overview"), "{joined}");
2787 assert!(joined.contains("Files"), "{joined}");
2788 // The open tab's pane, and only it.
2789 assert!(joined.contains("the files"), "{joined}");
2790 assert!(!joined.contains("the summary"), "{joined}");
2791 // A strip, not a counter row.
2792 assert!(!joined.contains("< Prev >"), "{joined}");
2793
2794 let strip = out.iter().position(|row| row.contains("Overview"));
2795 let pane = out.iter().position(|row| row.contains("the files"));
2796 assert!(strip < pane, "{out:?}");
2797 }
2798
2799 #[test]
2800 fn a_closed_disclosure_draws_its_name_and_nothing_under_it() {
2801 // `871e7f21`, which turns out to be `AtMostOne` and not a member of its own.
2802 let disclosure = |shown: Option<usize>| {
2803 Screen::list_detail("Item", false).with(
2804 Slot::widget("more", "disclosure")
2805 .frame(
2806 "Technical details",
2807 Node::Region(Slot::new("body", RegionKind::Pane).with(Node::text("the rest"))),
2808 )
2809 .showing_at_most_one(shown),
2810 )
2811 };
2812
2813 let closed = shown(&disclosure(None), 60, 12).join("\n");
2814 assert!(closed.contains("Technical details"), "{closed}");
2815 assert!(!closed.contains("the rest"), "{closed}");
2816
2817 let open = shown(&disclosure(Some(0)), 60, 12).join("\n");
2818 assert!(open.contains("the rest"), "{open}");
2819 }
2820
2821 #[test]
2822 fn a_region_showing_everything_draws_what_it_always_drew() {
2823 // The additive claim, checked from the other renderer's side too. Nothing
2824 // written before `Showing` existed changes.
2825 let screen = Screen::list_detail("Tasks", false).with(
2826 Slot::new("main", RegionKind::Pane)
2827 .with(Node::text("first"))
2828 .with(Node::text("second")),
2829 );
2830 let out = shown(&screen, 40, 12).join("\n");
2831
2832 assert!(out.contains("first") && out.contains("second"), "{out}");
2833 assert!(!out.contains("< Prev >"), "{out}");
2834 }
2835
2836 /// One of every shape that could plausibly cache a size.
2837 ///
2838 /// A field at each [`layout::Width`], a table with mixed
2839 /// [`layout::Priority`], a list that says how much more there is, and a nested
2840 /// region. Written out here rather than shared with the other two renderers'
2841 /// copies of it: the fixture is a few lines and sharing it would mean a new
2842 /// public surface on a crate for the sake of a test.
2843 fn every_shape_that_could_cache() -> Screen {
2844 Screen::sidebar_content("Any width").with(
2845 Slot::new("main", RegionKind::Pane)
2846 .with(Node::Field(Box::new(Field::new(
2847 layout::FieldKind::Text,
2848 "wide",
2849 "Wide",
2850 ))))
2851 .with(Node::Field(Box::new(
2852 Field::new(layout::FieldKind::Text, "tight", "Tight").width(layout::Width::Content),
2853 )))
2854 .with(Node::Field(Box::new(
2855 Field::new(layout::FieldKind::Text, "held", "Held").width(layout::Width::Fixed),
2856 )))
2857 .with(Node::Table {
2858 marks: ::quasi_router::stage::Marks::none(),
2859 columns: vec![
2860 Column::new("Name").priority(layout::Priority::Essential),
2861 Column::new("Kind").priority(layout::Priority::Secondary),
2862 Column::new("Added").priority(layout::Priority::Optional),
2863 ],
2864 rows: vec![Row::cells(["kick.wav", "sample", "2026-08-12"])],
2865 more: Some(Rest::more(1, Action::get("/samples?from=1"))),
2866 })
2867 .with(Node::Table {
2868 marks: ::quasi_router::stage::Marks::none(),
2869 columns: Vec::new(),
2870 rows: vec![Row::new("one"), Row::new("two")],
2871 more: Some(Rest::more(2, Action::get("/rows?from=2"))),
2872 })
2873 .with(Node::Region(
2874 Slot::group("nested").with(Node::text("inside")),
2875 )),
2876 )
2877 }
2878
2879 #[test]
2880 fn a_narrow_terminal_draws_the_same_thing_however_it_got_narrow() {
2881 // "Any width, one answer", `makeover-layout` 0.27.4. The same description
2882 // at the same width is the same picture, whatever widths came before it.
2883 //
2884 // The renderer and the view are made once and reused across the sequence,
2885 // which is the half that matters: a fresh `Tui` per draw could not fail
2886 // this test however much geometry it kept. What it catches is a renderer
2887 // that remembers -- a cutoff cached on the first pass, a column measure
2888 // stored beside the theme -- and that is exactly the shape of the bug that
2889 // makes an ordinary page's sidebar depend on the order you dragged the
2890 // window.
2891 let screen = every_shape_that_could_cache();
2892 let tui = tui();
2893 let view = View::new();
2894
2895 let draw = |width: u16| {
2896 let area = Rect::new(0, 0, width, 24);
2897 let mut buf = Buffer::empty(area);
2898 tui.screen(&screen, &view, area, &mut buf);
2899 rows(&buf)
2900 };
2901
2902 let cold = draw(40);
2903 for width in [120, 200, 40, 12, 400] {
2904 let _ = draw(width);
2905 }
2906 assert_eq!(draw(40), cold);
2907
2908 // And the fixture is one that narrowing actually bites, or the assertion
2909 // above would be true of an empty screen.
2910 assert_ne!(draw(120), cold);
2911 }
2912
2913 #[test]
2914 fn a_region_narrows_by_dropping_the_members_that_said_they_could_go() {
2915 // What a table has been able to say since the beginning, said by a band.
2916 // The shipped audiofiles toolbar hand-rolled this with `width < 900` and
2917 // `width < 700`, and hand-rolling is what makes a layout depend on the
2918 // width it came from.
2919 let screen = Screen::sidebar_content("Toolbar").with(
2920 Slot::new("bar", RegionKind::Band)
2921 .with(Node::text("Library"))
2922 .with_ranked(Node::text("Filter"), layout::Priority::Secondary)
2923 .with_ranked(Node::text("Sort"), layout::Priority::Optional),
2924 );
2925
2926 let wide = shown(&screen, 100, 8).join("|");
2927 assert!(wide.contains("Sort"), "{wide}");
2928 assert!(wide.contains("Filter"), "{wide}");
2929
2930 let middling = shown(&screen, 70, 8).join("|");
2931 assert!(!middling.contains("Sort"), "{middling}");
2932 assert!(middling.contains("Filter"), "{middling}");
2933
2934 let narrow = shown(&screen, 30, 8).join("|");
2935 assert!(!narrow.contains("Sort"), "{narrow}");
2936 assert!(!narrow.contains("Filter"), "{narrow}");
2937 // Essential never drops, whatever it costs. A region that cannot say what
2938 // it is is not a narrower region.
2939 assert!(narrow.contains("Library"), "{narrow}");
2940 }
2941
2942 #[test]
2943 fn a_member_inserted_above_the_cut_does_not_change_what_drops() {
2944 // The terminal half of the property. `makeover-tui`'s table has had this
2945 // test for columns; positional narrowing passes the first assertion and
2946 // fails this one, which is why it is worth writing twice.
2947 let bar = |extra: bool| {
2948 let mut slot = Slot::new("bar", RegionKind::Band).with(Node::text("Library"));
2949 if extra {
2950 slot = slot.with(Node::text("Inserted"));
2951 }
2952 Screen::sidebar_content("Toolbar")
2953 .with(slot.with_ranked(Node::text("Sort"), layout::Priority::Optional))
2954 };
2955
2956 let without = shown(&bar(false), 70, 8).join("|");
2957 let with = shown(&bar(true), 70, 8).join("|");
2958
2959 assert!(!without.contains("Sort"), "{without}");
2960 assert!(!with.contains("Sort"), "{with}");
2961 assert!(with.contains("Inserted"), "{with}");
2962 }
2963
2964 #[test]
2965 fn an_awaiting_control_is_pressed_once_and_refuses_the_second_press() {
2966 // `d8d6f380`. A terminal has no browser to lock a button for it, and the
2967 // second press is the one that buys the same thing twice.
2968 let mut runtime = Runtime::new(screen_of([
2969 Node::Act(Act::new("Buy", Action::post("/checkout").awaiting())),
2970 Node::Act(Act::new("Cancel", Action::post("/cancel"))),
2971 ]));
2972
2973 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/checkout"));
2974 assert_eq!(
2975 runtime.key(Key::Enter),
2976 Step::Idle,
2977 "the same control, still waiting"
2978 );
2979
2980 // The rest of the screen keeps working: one control is busy, the app is
2981 // not.
2982 runtime.key(Key::Tab);
2983 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/cancel"));
2984
2985 // The answer arrives and the control is offered again.
2986 runtime.apply(
2987 &Request::post("/checkout"),
2988 Response::fragment("main", Node::text("Bought")),
2989 );
2990 assert!(runtime.awaiting().is_none());
2991 }
2992
2993 #[test]
2994 fn a_control_with_no_mark_is_never_locked() {
2995 // Nothing here decides that a route is slow. Only a described wait locks
2996 // anything, so every screen written before this existed behaves as it did.
2997 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new(
2998 "Save",
2999 Action::post("/save"),
3000 ))]));
3001 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save"));
3002 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save"));
3003 }
3004
3005 #[test]
3006 fn a_call_the_host_makes_is_refused_here_rather_than_made_wrongly() {
3007 // `a81384d4`. The address behind a described upload is a signing endpoint
3008 // that answers JSON, reached by a browser that then PUTs the file
3009 // somewhere else. This host knows none of that, so it says so instead of
3010 // asking for a screen it would be handed a signature for.
3011 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new(
3012 "Upload",
3013 Action::post("/api/upload/presign").by_host(),
3014 ))]));
3015
3016 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
3017 let said = shown(runtime.screen(), 60, 8).join(" ");
3018 assert!(said.contains("cannot do that here"), "[{said}]");
3019 }
3020
3021 #[test]
3022 fn a_navigating_act_pushes_a_screen_and_takes_the_overlay_with_it() {
3023 // `00ee7af5`. The webview's anchor and this are the same sentence: the whole
3024 // screen is being replaced, so the call is made and whatever is floating
3025 // over the place being left is put away on the way out rather than left
3026 // hanging over wherever the answer lands.
3027 let mut runtime = Runtime::new(screen_of([Node::text("discover")]));
3028 runtime.apply(
3029 &Request::get("/discover/suggest"),
3030 Response {
3031 outcome: Outcome::Over(screen_of([Node::Act(Act::new(
3032 "Slow Reader",
3033 Action::get("/p/slow-reader").navigating(),
3034 ))])),
3035 notice: None,
3036 address: None,
3037 invalidates: Vec::new(),
3038 },
3039 );
3040 assert!(runtime.overlaid());
3041
3042 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/p/slow-reader"));
3043 // Before the answer, which is the point: nothing has come back yet and the
3044 // overlay is already gone.
3045 assert!(!runtime.overlaid());
3046 }
3047
3048 #[test]
3049 fn an_ordinary_act_leaves_the_overlay_where_it_is() {
3050 // The mark is what puts the overlay away, and an act without one is the act
3051 // it was: a call from inside a palette that answers a fragment still
3052 // answers it into the palette.
3053 let mut runtime = Runtime::new(screen_of([Node::text("discover")]));
3054 runtime.apply(
3055 &Request::get("/discover/suggest"),
3056 Response {
3057 outcome: Outcome::Over(screen_of([Node::Act(Act::new(
3058 "Refine",
3059 Action::get("/discover/refine"),
3060 ))])),
3061 notice: None,
3062 address: None,
3063 invalidates: Vec::new(),
3064 },
3065 );
3066
3067 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/discover/refine"));
3068 assert!(runtime.overlaid());
3069 }
3070
3071 #[test]
3072 fn a_call_that_goes_elsewhere_is_performed_where_it_stands() {
3073 // goingson `3fb2526a`. A mount of its own in a terminal would be a split or
3074 // a tab, which is this renderer's furniture rather than the description's,
3075 // so the mark is read and the call is made here. Asserted rather than left
3076 // implicit: the alternative failure is the `by_host` one above -- a control
3077 // that is drawn, is reachable and does nothing when pressed.
3078 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new(
3079 "Open in a window",
3080 Action::get("/compose/7").elsewhere(),
3081 ))]));
3082
3083 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/compose/7"));
3084 }
3085
3086 #[test]
3087 fn pressing_a_tab_whose_panel_is_a_route_asks_for_it() {
3088 // `dfbc88ce`, the terminal half. The browser puts the address on the strip
3089 // button and htmx fires on the press; here the press is a key and the host
3090 // performs what comes back, which is the same division of labour.
3091 let mut runtime = Runtime::new(
3092 Screen::sidebar_content("Library").with(
3093 Slot::new("tab-content", RegionKind::TabGroup)
3094 // The shown panel came with the screen, so it names no call:
3095 // `9b958e7b` forbids the placeholder, and a region that has its
3096 // content is not waiting for any.
3097 .frame(
3098 "Purchases",
3099 Node::Region(
3100 Slot::new("purchases", RegionKind::Pane)
3101 .with(Node::text("what you bought")),
3102 ),
3103 )
3104 .frame(
3105 "Feed",
3106 Node::Region(
3107 Slot::new("feed", RegionKind::Pane)
3108 .fed_by(Action::get("/library/tabs/feed")),
3109 ),
3110 )
3111 .showing_one(0),
3112 ),
3113 );
3114
3115 // Nothing is asked for on the way up: the shown panel came with the screen
3116 // and the other one is unpressed, not waiting.
3117 assert!(runtime.feeds().is_empty(), "{:?}", runtime.feeds());
3118
3119 let step = runtime.key(Key::Right);
3120 let Step::Call(request) = step else {
3121 panic!("pressing a tab asks for its panel, got {step:?}");
3122 };
3123 assert_eq!(request.path, "/library/tabs/feed");
3124
3125 runtime.apply(
3126 &request,
3127 Response::fragment("feed", Node::text("what your creators posted")),
3128 );
3129 // Asserted on the screen rather than on the drawing: `shown` here paints
3130 // through a fresh `View`, so it shows the description's own current child
3131 // and not the one this runtime moved to.
3132 let panel = runtime
3133 .screen()
3134 .slots
3135 .iter()
3136 .find_map(|slot| slot.find("feed"))
3137 .expect("the panel is still on the screen");
3138 assert!(
3139 panel.asked_for().is_none(),
3140 "a panel that arrived is not asked for again"
3141 );
3142
3143 // Back to a tab already read, and back again: neither asks. A panel that
3144 // has arrived is not asked for a second time, which is what going back to
3145 // a tab means.
3146 assert!(matches!(runtime.key(Key::Left), Step::Idle));
3147 assert!(matches!(runtime.key(Key::Right), Step::Idle));
3148 }
3149
3150 #[test]
3151 fn a_region_fed_by_a_call_is_asked_for_and_then_stops_asking() {
3152 // What the browser does with a trigger per region, done by the host here
3153 // because a terminal has nobody to do it.
3154 let mut runtime = Runtime::new(Screen::sidebar_content("Payments").with(
3155 Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()),
3156 ));
3157
3158 let feeds = runtime.feeds();
3159 assert_eq!(feeds.len(), 1);
3160 assert_eq!(feeds[0].path, "/dashboard/payouts");
3161
3162 // The stand-in is on the screen while it is out.
3163 let waiting = shown(runtime.screen(), 40, 8).join(" ");
3164 assert!(waiting.contains("Loading"), "{waiting}");
3165
3166 runtime.apply(
3167 &feeds[0].clone(),
3168 Response::fragment("payouts", Node::text("$12.00")),
3169 );
3170 let filled = shown(runtime.screen(), 40, 8).join(" ");
3171 assert!(filled.contains("$12.00"), "{filled}");
3172 assert!(runtime.feeds().is_empty(), "a filled region asks again");
3173 }
3174
3175 #[test]
3176 fn work_handed_off_leaves_the_region_waiting_and_the_screen_where_it_was() {
3177 // `dc2f2b46`. goingson's "Create Backup": the write is offloaded, the route
3178 // answers that it started, and the region's own live call is what reports
3179 // the finish. Nothing about the rest of the screen moves.
3180 let mut runtime = Runtime::new(
3181 Screen::sidebar_content("Import & Export").with(
3182 Slot::new("backups", RegionKind::Pane)
3183 .fed_by(Action::get("/backups"))
3184 .live(),
3185 ),
3186 );
3187 // The region arrives the ordinary way first, so what this test starts from
3188 // is a region holding content rather than one that never had any.
3189 runtime.apply(
3190 &Request::get("/backups"),
3191 Response::fragment("backups", Node::text("3 backups")),
3192 );
3193 let before = shown(runtime.screen(), 40, 8).join(" ");
3194 assert!(before.contains("3 backups"), "{before}");
3195
3196 runtime.apply(
3197 &Request::post("/backups/create"),
3198 Response::started("backups", "Creating backup…"),
3199 );
3200
3201 // A terminal draws its wait in words, off the readiness axis, the same as
3202 // for a region that has simply not arrived yet.
3203 let waiting = shown(runtime.screen(), 40, 8).join(" ");
3204 assert!(waiting.contains("Loading"), "{waiting}");
3205 assert!(!waiting.contains("3 backups"), "{waiting}");
3206 // Still on the same screen: this is not a navigation and not an overlay.
3207 assert_eq!(runtime.screen().title, "Import & Export");
3208
3209 // The cadence survived, which is the half that makes the finish reportable
3210 // at all.
3211 let refreshes = runtime.screen().refreshes();
3212 assert_eq!(refreshes.len(), 1);
3213 assert_eq!(refreshes[0].destination.route(), Some("/backups"));
3214
3215 // And the finish is an ordinary fragment.
3216 runtime.apply(
3217 &Request::get("/backups"),
3218 Response::fragment("backups", Node::text("4 backups")),
3219 );
3220 let done = shown(runtime.screen(), 40, 8).join(" ");
3221 assert!(done.contains("4 backups"), "{done}");
3222 }
3223
3224 #[test]
3225 fn work_handed_off_to_a_region_that_is_not_there_says_so() {
3226 // The same treatment a fragment naming a missing region gets, because it is
3227 // the same description bug: a route naming a slot the screen does not have.
3228 let mut runtime = Runtime::new(
3229 Screen::sidebar_content("Import & Export")
3230 .with(Slot::new("backups", RegionKind::Pane).with(Node::text("3 backups"))),
3231 );
3232 runtime.apply(
3233 &Request::post("/backups/create"),
3234 Response::started("archives", "Creating backup…"),
3235 );
3236
3237 let area = Rect::new(0, 0, 60, 10);
3238 let mut buf = Buffer::empty(area);
3239 runtime.draw(&tui(), area, &mut buf);
3240 let out = rows(&buf);
3241 assert!(
3242 out.iter().any(|row| row.contains("3 backups")),
3243 "the screen still draws: {out:?}"
3244 );
3245 assert!(
3246 out.iter().any(|row| row.contains("archives")),
3247 "the miss is reported rather than swallowed: {out:?}"
3248 );
3249 }
3250
3251 #[test]
3252 fn a_live_region_is_re_asked_on_the_cadence_and_not_faster() {
3253 // The half `feeds` deliberately does not do. A feed is cleared as it lands
3254 // and a cadence is not, so the pacing has to live somewhere, and it lives
3255 // here rather than in every host that draws a live screen.
3256 let mut runtime = Runtime::new(
3257 Screen::sidebar_content("Admin").with(
3258 Slot::new("queue", RegionKind::Pane)
3259 .fed_by(Action::get("/admin/queue"))
3260 .live(),
3261 ),
3262 );
3263
3264 // A live call is never a feed, so a host performing feeds asks for nothing.
3265 assert!(runtime.feeds().is_empty());
3266 assert!(runtime.is_live());
3267
3268 let start = std::time::Instant::now();
3269 let first = runtime.refreshes_at(start);
3270 assert_eq!(first.len(), 1);
3271 assert_eq!(first[0].path, "/admin/queue");
3272
3273 // Asked again a frame later, which is what an event loop does: nothing, or
3274 // the rate would be the loop's rather than this crate's.
3275 assert!(
3276 runtime
3277 .refreshes_at(start + std::time::Duration::from_millis(16))
3278 .is_empty()
3279 );
3280
3281 // The answer landing leaves the region live, which is what `replace`
3282 // learned, so the next period asks again.
3283 runtime.apply(
3284 &first[0].clone(),
3285 Response::fragment("queue", Node::text("4 waiting")),
3286 );
3287 assert_eq!(runtime.refreshes_at(start + crate::CADENCE).len(), 1);
3288 }
3289
3290 #[test]
3291 fn a_still_screen_refreshes_nothing() {
3292 let mut runtime = Runtime::new(
3293 Screen::sidebar_content("Payments")
3294 .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts"))),
3295 );
3296
3297 assert!(!runtime.is_live());
3298 assert!(runtime.refreshes().is_empty());
3299 assert_eq!(runtime.feeds().len(), 1, "a still region is still a feed");
3300 }
3301
3302 #[test]
3303 fn a_live_region_with_no_call_re_asks_the_screens_own_address() {
3304 // The audiofiles sync panel: state the host already holds, moved by an
3305 // OAuth callback landing in another process. There is no fragment to fetch,
3306 // so re-reading it is building the description again, which is the address
3307 // the screen came from.
3308 let mut runtime = Runtime::new(
3309 Screen::sidebar_content("Sync").with(
3310 Slot::new("sync", RegionKind::Pane)
3311 .live()
3312 .with(Node::text("Authenticating")),
3313 ),
3314 );
3315
3316 assert!(runtime.is_live());
3317 // Nothing to ask until the runtime knows where the screen came from, which
3318 // is what `apply` records. A first screen handed straight to `new` has no
3319 // address, and inventing one would be a route this crate made up.
3320 assert!(runtime.refreshes().is_empty());
3321
3322 let home = Request::get("/sync");
3323 runtime.apply(
3324 &home,
3325 Response::from(
3326 Screen::sidebar_content("Sync").with(
3327 Slot::new("sync", RegionKind::Pane)
3328 .live()
3329 .with(Node::text("Needs encryption")),
3330 ),
3331 ),
3332 );
3333
3334 let due = runtime.refreshes_at(std::time::Instant::now() + crate::CADENCE);
3335 assert_eq!(due.len(), 1);
3336 assert_eq!(due[0].path, "/sync");
3337 }
3338
3339 #[test]
3340 fn the_control_that_is_waiting_keeps_its_label_and_gains_the_mark() {
3341 // Disabled, plus the activity mark: `5db1e0ed`, wiki
3342 // `loading-and-progress-standard`. The lock is rule 4 and stays -- a
3343 // control that refuses a second press is already saying something. What it
3344 // could not say is that a wait is running at all, which on a slow call is
3345 // the difference between a control working and a control dead.
3346 //
3347 // The **label** is what must not change. This test used to assert the whole
3348 // drawing kept its width, which the mark deliberately breaks; what that was
3349 // protecting is that a pressed control does not re-word itself under the
3350 // reader's cursor, and that still holds.
3351 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new(
3352 "Buy",
3353 Action::post("/checkout").awaiting(),
3354 ))]));
3355
3356 let before = shown(runtime.screen(), 40, 6);
3357 assert!(
3358 before.iter().any(|row| row.contains("< Buy >")),
3359 "{before:?}"
3360 );
3361 runtime.key(Key::Enter);
3362
3363 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6));
3364 runtime.draw(&tui(), Rect::new(0, 0, 40, 6), &mut buf);
3365 let after = rows(&buf);
3366 assert!(
3367 after.iter().any(|row| row.contains("< Buy > #")),
3368 "the label is untouched and the mark sits beside it: {after:?}"
3369 );
3370 }
3371
3372 #[test]
3373 fn a_wait_with_a_size_draws_more_than_a_wait_without_one() {
3374 // The done condition of `5db1e0ed`: the amount was described, carried
3375 // through the runtime, and dropped at the draw, so `Awaiting::of` and
3376 // `Awaiting::unmeasured` produced identical output.
3377 let sized = |action: Action| {
3378 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Upload", action))]));
3379 runtime.key(Key::Enter);
3380 let mut buf = Buffer::empty(Rect::new(0, 0, 60, 6));
3381 runtime.draw(&tui(), Rect::new(0, 0, 60, 6), &mut buf);
3382 rows(&buf).join("\n")
3383 };
3384
3385 let unmeasured = sized(Action::post("/upload").awaiting());
3386 let measured = sized(Action::post("/upload").awaiting_amount(41_943_040));
3387 assert_ne!(unmeasured, measured, "two waits, two drawings");
3388 assert!(measured.contains("41943040"), "{measured}");
3389 assert!(!unmeasured.contains("41943040"), "{unmeasured}");
3390 }
3391
3392 // ── The frame a mount puts around a screen ──
3393
3394 #[test]
3395 fn a_mount_that_declares_no_frame_draws_what_it_always_drew() {
3396 // The default has to be the old picture, or every host that puts a screen
3397 // up changes what it paints when this arrives.
3398 let screen = screen_of([Node::text("body")]);
3399 let area = Rect::new(0, 0, 40, 8);
3400
3401 let mut plain = Buffer::empty(area);
3402 tui().screen(&screen, &View::new(), area, &mut plain);
3403
3404 let mut framed = Buffer::empty(area);
3405 tui().framed(&screen, &Frame::new(), &View::new(), area, &mut framed);
3406
3407 assert_eq!(plain, framed);
3408 }
3409
3410 #[test]
3411 fn a_frames_verbs_are_drawn_under_the_screen_and_can_be_reached() {
3412 // goingson's compose window. The verbs belong to the mount, so they are
3413 // reachable from the screen inside it without the screen describing them.
3414 let mut runtime = Runtime::new(screen_of([Node::field(Field::new(
3415 layout::FieldKind::Text,
3416 "subject",
3417 "Subject",
3418 ))]))
3419 .with_frame(
3420 Frame::new()
3421 .offering(Act::new("Send", Action::post("/compose/send")))
3422 .offering(Act::new("Discard", Action::post("/compose/discard"))),
3423 );
3424
3425 // The screen's own control first, then the frame's, which is the order the
3426 // drawing counts in.
3427 let reaches = runtime.reaches();
3428 assert_eq!(reaches.len(), 3);
3429 assert_eq!(reaches[1].region, crate::focus::FRAME_REGION);
3430 assert_eq!(reaches[2].region, crate::focus::FRAME_REGION);
3431
3432 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8));
3433 runtime.draw(&tui(), Rect::new(0, 0, 40, 8), &mut buf);
3434 let painted = rows(&buf).join(" ");
3435 assert!(painted.contains("Send"), "{painted}");
3436 assert!(painted.contains("Discard"), "{painted}");
3437
3438 // And pressing one calls it. Two Tabs from the field is the first verb.
3439 runtime.key(Key::Tab);
3440 assert_eq!(
3441 calling(&runtime.key(Key::Enter)),
3442 Some("/compose/send"),
3443 "the caret reached the frame's verb"
3444 );
3445 }
3446
3447 #[test]
3448 fn a_banner_rests_in_a_reporting_frame_and_a_toast_still_floats() {
3449 // The status line without a channel: `Screen::notices` already carries the
3450 // messages, and a reporting mount changes where one of the two kinds lands.
3451 let mut screen = screen_of([Node::text("body")]);
3452 screen
3453 .notices
3454 .push(Node::banner(layout::Tone::Danger, "Not sent"));
3455
3456 let area = Rect::new(0, 0, 40, 10);
3457 let frame = Frame::new().reporting();
3458 let mut buf = Buffer::empty(area);
3459 tui().framed(&screen, &frame, &View::new(), area, &mut buf);
3460 let painted = rows(&buf);
3461
3462 // Drawn, and at the bottom rather than at the top where an unframed
3463 // screen's notices go.
3464 let at = painted
3465 .iter()
3466 .position(|row| row.contains("Not sent"))
3467 .expect("the banner is drawn");
3468 let body = painted
3469 .iter()
3470 .position(|row| row.contains("body"))
3471 .expect("the screen is drawn");
3472 assert!(at > body, "{painted:?}");
3473 }
3474
3475 #[test]
3476 fn the_focus_walk_and_the_drawing_count_the_same_things_with_a_frame() {
3477 // The same invariant as the unframed walk, extended over the member that
3478 // put a second walk in reach of it. One `Pass` draws both, so a restart
3479 // between them would light a verb whenever the caret was on the screen's
3480 // first control.
3481 let screen = screen_of([
3482 Node::Act(Act::new("Save", Action::post("/save"))),
3483 Node::field(Field::new(layout::FieldKind::Text, "name", "Name")),
3484 ]);
3485 let frame = Frame::new()
3486 .offering(Act::new("Send", Action::post("/send")))
3487 .offering(Act::new("Gone", Action::post("/gone")).disabled())
3488 .offering(Act::new("Discard", Action::post("/discard")));
3489
3490 let expected = crate::focus::reaches_framed(&screen, &frame, &Local::none()).len();
3491 // Two on the screen and two of the three verbs: a disabled verb is drawn
3492 // and not stopped on, which is the rule every other control follows.
3493 assert_eq!(expected, 4);
3494
3495 let area = Rect::new(0, 0, 60, 20);
3496 let draw = |view: &View| {
3497 let mut buf = Buffer::empty(area);
3498 tui().framed(&screen, &frame, view, area, &mut buf);
3499 buf
3500 };
3501
3502 let mut past = View::new();
3503 past.focus_on(expected, expected + 1);
3504 let unlit = draw(&past);
3505
3506 for at in 0..expected {
3507 let mut view = View::new();
3508 view.focus_on(at, expected);
3509 assert_ne!(
3510 draw(&view),
3511 unlit,
3512 "focusing {at} of {expected} changed nothing"
3513 );
3514 }
3515 }
3516
3517 // ── The panel the app keeps on screen ──
3518
3519 #[test]
3520 fn an_app_that_declares_no_panel_draws_what_it_always_drew() {
3521 // The default has to be the old picture, or every host paints something new
3522 // the moment this member arrives.
3523 let screen = screen_of([Node::text("body")]);
3524 let area = Rect::new(0, 0, 40, 8);
3525
3526 let mut plain = Buffer::empty(area);
3527 tui().framed(&screen, &Frame::new(), &View::new(), area, &mut plain);
3528
3529 let mut chromed = Buffer::empty(area);
3530 tui().chromed(
3531 &screen,
3532 &Frame::new(),
3533 &Chrome::new(),
3534 &View::new(),
3535 area,
3536 &mut chromed,
3537 );
3538
3539 assert_eq!(plain, chromed);
3540 }
3541
3542 #[test]
3543 fn the_tab_line_is_the_top_row_and_marks_where_the_screen_says_it_is() {
3544 // `71aa29b4`. A terminal has no tab bar, so the renderer decides, and the
3545 // decision is the very top: the places read before anything under them.
3546 use quasi_router::Place;
3547
3548 let chrome = Chrome::new()
3549 .offering(Place::new("work", "Work", Action::get("/tasks")).within([
3550 Place::new("tasks", "Tasks", Action::get("/tasks")),
3551 Place::new("board", "Board", Action::get("/board")),
3552 ]))
3553 .offering(Place::new("time", "Time", Action::get("/day")));
3554
3555 let runtime =
3556 Runtime::new(screen_of([Node::text("body")]).at_place("board")).with_chrome(chrome);
3557
3558 let area = Rect::new(0, 0, 60, 12);
3559 let mut buf = Buffer::empty(area);
3560 runtime.draw(&tui(), area, &mut buf);
3561 let out = rows(&buf);
3562
3563 assert!(out[0].contains("Work"), "{out:?}");
3564 assert!(out[0].contains("Time"), "{out:?}");
3565 // Two levels are two rows, and the second holds the current tab's places
3566 // and nobody else's: every sub-place of every tab would spend a terminal's
3567 // rows on navigation.
3568 assert!(out[1].contains("Board"), "{out:?}");
3569 assert!(out[1].contains("Tasks"), "{out:?}");
3570 // Above the screen, which is the whole placement decision.
3571 let body = out
3572 .iter()
3573 .position(|row| row.contains("body"))
3574 .expect("the screen is drawn");
3575 assert!(body > 1, "{out:?}");
3576 }
3577
3578 #[test]
3579 fn a_band_puts_the_app_name_on_the_tab_line_and_its_search_box_under_it() {
3580 use quasi_router::{Band, Brand, Place};
3581
3582 let chrome = Chrome::new()
3583 .offering(Place::new("discover", "Discover", Action::get("/discover")))
3584 .banded(
3585 Band::new()
3586 .branded(Brand::new("Makenot.work", Action::get("/")).marking("."))
3587 .searching(Field::new(layout::FieldKind::Text, "q", "Search"))
3588 // Ignored here, which is what the vocabulary says a renderer
3589 // with no notion of "not enough room" does.
3590 .disclosing(quasi_router::Disclose::Narrow),
3591 );
3592 let runtime =
3593 Runtime::new(screen_of([Node::text("body")]).at_place("discover")).with_chrome(chrome);
3594
3595 let area = Rect::new(0, 0, 60, 12);
3596 let mut buf = Buffer::empty(area);
3597 runtime.draw(&tui(), area, &mut buf);
3598 let out = rows(&buf);
3599
3600 // The name at the head of the tab line, whole: a terminal has one typeface
3601 // and no way to make a glyph graphic.
3602 assert!(out[0].contains("Makenot.work"), "{out:?}");
3603 assert!(out[0].contains("Discover"), "{out:?}");
3604 // The box under it, and the screen under that.
3605 assert!(out[1].contains("Search"), "{out:?}");
3606 let body = out
3607 .iter()
3608 .position(|row| row.contains("body"))
3609 .expect("the screen is drawn");
3610 assert!(body > 1, "{out:?}");
3611 }
3612
3613 #[test]
3614 fn a_band_is_walked_in_the_order_it_is_drawn() {
3615 // The invariant this renderer keeps everywhere: the caret walk and the
3616 // drawing read one order, so the highlighted thing is the thing the reader
3617 // is looking at. Brand, places, then the box.
3618 use quasi_router::{Band, Brand, Place};
3619
3620 let chrome = Chrome::new()
3621 .offering(Place::new("discover", "Discover", Action::get("/discover")))
3622 .banded(
3623 Band::new()
3624 .branded(Brand::new("Makenot.work", Action::get("/")))
3625 .searching(Field::new(layout::FieldKind::Text, "q", "Search")),
3626 );
3627 let mut runtime = Runtime::new(screen_of([Node::text("body")])).with_chrome(chrome);
3628
3629 // The first stop is the brand, which goes home.
3630 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/"));
3631 runtime.key(Key::Tab);
3632 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/discover"));
3633 // And the third is the box, which takes characters rather than firing.
3634 runtime.key(Key::Tab);
3635 assert!(runtime.editing(), "the search box is not a stop");
3636 }
3637
3638 #[test]
3639 fn an_app_with_no_nav_gets_the_rows_it_always_got() {
3640 // The default has to be the old behaviour or every terminal app loses a row
3641 // the moment this member arrives.
3642 let area = Rect::new(0, 0, 60, 12);
3643 let painted = |runtime: &Runtime| {
3644 let mut buf = Buffer::empty(area);
3645 runtime.draw(&tui(), area, &mut buf);
3646 rows(&buf)
3647 };
3648
3649 let plain = painted(&Runtime::new(screen_of([Node::text("body")])));
3650 let chromed =
3651 painted(&Runtime::new(screen_of([Node::text("body")])).with_chrome(Chrome::new()));
3652 assert_eq!(plain, chromed);
3653 }
3654
3655 #[test]
3656 fn a_place_is_somewhere_the_caret_can_stop() {
3657 // A tab line the user can read and cannot reach would be worse than no tab
3658 // line: the walk that reaches every other control has to reach these too.
3659 use quasi_router::Place;
3660
3661 let mut runtime = Runtime::new(screen_of([Node::text("body")]).at_place("time"))
3662 .with_chrome(Chrome::new().offering(Place::new("time", "Time", Action::get("/day"))));
3663
3664 runtime.key(Key::Tab);
3665 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/day"));
3666 }
3667
3668 #[test]
3669 fn two_panels_stack_with_the_apps_condition_nearest_the_edge() {
3670 // Declaration order within a role, activity above status. A terminal's
3671 // status line is the bottom row, so that is where a `Status` panel goes.
3672 let runtime = Runtime::new(screen_of([Node::text("body")])).with_chrome(
3673 Chrome::new()
3674 .presenting("sync", quasi_router::Role::Status, Node::text("Synced"))
3675 .presenting(
3676 "timer",
3677 quasi_router::Role::Activity,
3678 Node::text("00:12:04"),
3679 ),
3680 );
3681
3682 let area = Rect::new(0, 0, 60, 12);
3683 let mut buf = Buffer::empty(area);
3684 runtime.draw(&tui(), area, &mut buf);
3685 let out = rows(&buf);
3686
3687 let timer = out
3688 .iter()
3689 .position(|row| row.contains("00:12:04"))
3690 .expect("the activity band is drawn");
3691 let sync = out
3692 .iter()
3693 .position(|row| row.contains("Synced"))
3694 .expect("the status band is drawn");
3695 // Declared the other way round, so this is the role deciding and not the
3696 // order.
3697 assert!(timer < sync, "{out:?}");
3698 }
3699
3700 #[test]
3701 fn the_panel_is_drawn_under_the_frame_and_survives_a_navigation() {
3702 // goingson's running-timer widget. It belongs to the app, so it is on the
3703 // screen without any screen describing it, and it is still there after the
3704 // screen under it has been replaced.
3705 let mut runtime = Runtime::new(screen_of([Node::text("body")]))
3706 .with_frame(Frame::new().offering(Act::new("Send", Action::post("/compose/send"))))
3707 .with_chrome(Chrome::new().presenting(
3708 "timer",
3709 quasi_router::Role::Activity,
3710 Node::text("00:12:04"),
3711 ));
3712
3713 let area = Rect::new(0, 0, 60, 12);
3714 let painted = |runtime: &Runtime| {
3715 let mut buf = Buffer::empty(area);
3716 runtime.draw(&tui(), area, &mut buf);
3717 rows(&buf)
3718 };
3719
3720 let out = painted(&runtime);
3721 let panel = out
3722 .iter()
3723 .position(|row| row.contains("00:12:04"))
3724 .expect("the panel is drawn");
3725 let verb = out
3726 .iter()
3727 .position(|row| row.contains("Send"))
3728 .expect("the frame is drawn");
3729 let body = out
3730 .iter()
3731 .position(|row| row.contains("body"))
3732 .expect("the screen is drawn");
3733 // The lifetimes stack: the screen, the mount's frame, then the app's panel.
3734 assert!(body < verb && verb < panel, "{out:?}");
3735
3736 runtime.apply(
3737 &Request::get("/elsewhere"),
3738 Screen::sidebar_content("Elsewhere")
3739 .with(Slot::new("main", RegionKind::Pane).with(Node::text("elsewhere")))
3740 .into(),
3741 );
3742 let out = painted(&runtime);
3743 assert!(out.iter().any(|row| row.contains("elsewhere")), "{out:?}");
3744 assert!(out.iter().any(|row| row.contains("00:12:04")), "{out:?}");
3745 }
3746
3747 #[test]
3748 fn a_panels_control_is_reachable_after_the_frames_verbs_and_calls_what_it_says() {
3749 let mut runtime = Runtime::new(screen_of([Node::field(Field::new(
3750 layout::FieldKind::Text,
3751 "subject",
3752 "Subject",
3753 ))]))
3754 .with_frame(Frame::new().offering(Act::new("Send", Action::post("/compose/send"))))
3755 .with_chrome(Chrome::new().presenting(
3756 "timer",
3757 quasi_router::Role::Activity,
3758 Node::Act(Act::new("Stop", Action::post("/timer/stop"))),
3759 ));
3760
3761 // The screen's field, the frame's verb, then the panel's control, which is
3762 // the order the drawing counts in.
3763 let reaches = runtime.reaches();
3764 assert_eq!(reaches.len(), 3);
3765 assert_eq!(reaches[1].region, crate::focus::FRAME_REGION);
3766 // Under the panel's own id rather than a reserved name: a panel has an
3767 // address because an answer aims at it.
3768 assert_eq!(reaches[2].region, "timer");
3769
3770 runtime.key(Key::Tab);
3771 runtime.key(Key::Tab);
3772 assert_eq!(
3773 calling(&runtime.key(Key::Enter)),
3774 Some("/timer/stop"),
3775 "the caret reached the panel's control"
3776 );
3777 }
3778
3779 #[test]
3780 fn an_answer_aimed_at_the_panel_lands_in_it_rather_than_being_reported_missing() {
3781 // How a timer ever moves: the panel carries an address, so a route that has
3782 // changed what it says reaches it the way it reaches any other region.
3783 let mut runtime =
3784 Runtime::new(screen_of([Node::text("body")])).with_chrome(Chrome::new().presenting(
3785 "timer",
3786 quasi_router::Role::Activity,
3787 Node::text("00:12:04"),
3788 ));
3789
3790 runtime.apply(
3791 &Request::post("/timer/tick"),
3792 Response::fragment("timer", Node::text("00:12:05")),
3793 );
3794
3795 let area = Rect::new(0, 0, 40, 8);
3796 let mut buf = Buffer::empty(area);
3797 runtime.draw(&tui(), area, &mut buf);
3798 let out = rows(&buf);
3799 assert!(out.iter().any(|row| row.contains("00:12:05")), "{out:?}");
3800 // A description bug is still reported: the panel is one address, not a
3801 // catch-all for everything the screen does not have.
3802 assert!(
3803 !out.iter().any(|row| row.contains("nothing on this screen")),
3804 "{out:?}"
3805 );
3806 }
3807
3808 /// A fill that paints one word per row, for as many rows as it was made with.
3809 ///
3810 /// Stands in for what a host actually puts in a bespoke region -- a media
3811 /// transport, a canvas -- with the one property the tests are about: it draws
3812 /// where it was put and it knows its own height.
3813 struct Words {
3814 text: &'static str,
3815 rows: u16,
3816 }
3817
3818 impl crate::Fill for Words {
3819 fn rows(&self, _tui: &Tui, _width: u16) -> u16 {
3820 self.rows
3821 }
3822
3823 fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) -> u16 {
3824 let mut used = 0;
3825 while used < self.rows && used < area.height {
3826 makeover_tui::text::draw(
3827 self.text,
3828 ratatui::style::Style::default().fg(tui.theme().content_primary),
3829 crate::below(area, used),
3830 buf,
3831 );
3832 used += 1;
3833 }
3834 used
3835 }
3836 }
3837
3838 /// A screen holding one bespoke region with a described heading in it.
3839 fn with_a_transport() -> Screen {
3840 Screen::sidebar_content("Library")
3841 .with(Slot::handover("player", "media-transport").with(Node::section("Episode 4")))
3842 }
3843
3844 #[test]
3845 fn a_bespoke_region_draws_the_host_fill_under_the_blocks_the_description_owns() {
3846 // Decision 4's terminal half, and the counterpart to `Webview::with_fill`:
3847 // the renderer hands the space over. The ordering is the arrangement
3848 // `Containment::Opaque` describes -- a heading the description owns above a
3849 // canvas it does not.
3850 let screen = with_a_transport();
3851 let area = Rect::new(0, 0, 40, 10);
3852 let mut buf = Buffer::empty(area);
3853 tui()
3854 .with_fill(
3855 "player",
3856 Words {
3857 text: "PLAYING",
3858 rows: 2,
3859 },
3860 )
3861 .screen(&screen, &View::new(), area, &mut buf);
3862 let out = rows(&buf);
3863
3864 let heading = out
3865 .iter()
3866 .position(|row| row.contains("Episode 4"))
3867 .expect("the described heading draws");
3868 let fill = out
3869 .iter()
3870 .position(|row| row.contains("PLAYING"))
3871 .expect("the host's fill draws");
3872 assert!(heading < fill, "the fill goes under the described blocks");
3873 assert_eq!(
3874 out.iter().filter(|row| row.contains("PLAYING")).count(),
3875 2,
3876 "the fill drew the rows it asked for"
3877 );
3878 }
3879
3880 #[test]
3881 fn a_bespoke_region_with_no_fill_draws_what_the_description_says_and_stops() {
3882 // What every host that offers no fill gets, and what this renderer did for
3883 // every host before `with_fill` existed.
3884 let out = shown(&with_a_transport(), 40, 10);
3885 assert!(out.iter().any(|row| row.contains("Episode 4")));
3886 assert!(!out.iter().any(|row| row.contains("PLAYING")));
3887 }
3888
3889 #[test]
3890 fn a_fill_named_against_a_pane_is_ignored() {
3891 // A host reaching into a region the description already owns. The webview
3892 // keeps the same rule, and it is why the fill is not simply "markup for
3893 // this id".
3894 let screen = Screen::sidebar_content("Library")
3895 .with(Slot::new("player", RegionKind::Pane).with(Node::text("described")));
3896 let area = Rect::new(0, 0, 40, 10);
3897 let mut buf = Buffer::empty(area);
3898 tui()
3899 .with_fill(
3900 "player",
3901 Words {
3902 text: "PLAYING",
3903 rows: 1,
3904 },
3905 )
3906 .screen(&screen, &View::new(), area, &mut buf);
3907
3908 assert!(!rows(&buf).iter().any(|row| row.contains("PLAYING")));
3909 }
3910
3911 #[test]
3912 fn a_fill_naming_a_slot_the_screen_does_not_have_draws_nowhere() {
3913 let screen = with_a_transport();
3914 let area = Rect::new(0, 0, 40, 10);
3915 let mut buf = Buffer::empty(area);
3916 tui()
3917 .with_fill(
3918 "elsewhere",
3919 Words {
3920 text: "PLAYING",
3921 rows: 1,
3922 },
3923 )
3924 .screen(&screen, &View::new(), area, &mut buf);
3925
3926 assert!(!rows(&buf).iter().any(|row| row.contains("PLAYING")));
3927 }
3928
3929 #[test]
3930 fn a_fill_counts_toward_the_rows_its_region_wants() {
3931 // The reason `Fill` answers a height rather than only drawing: the scroll
3932 // arithmetic reads `height`, and a region reported shorter than what is on
3933 // the screen stops scrolling before the fill's last row.
3934 // Ceded rather than a handover, so the baseline is a region that really
3935 // does draw nothing when it has no fill. An unfilled handover spends rows
3936 // saying the fill is missing, which the test below is about.
3937 let slot = Slot::ceded("player", "revenue-chart").with(Node::section("Episode 4"));
3938 let bare = crate::region::height(&tui(), &slot, 40, &Local::none());
3939 let filled = crate::region::height(
3940 &tui().with_fill(
3941 "player",
3942 Words {
3943 text: "PLAYING",
3944 rows: 3,
3945 },
3946 ),
3947 &slot,
3948 40,
3949 &Local::none(),
3950 );
3951
3952 assert_eq!(filled, bare + 3);
3953 }
3954
3955 #[test]
3956 fn an_unfilled_handover_says_so_and_an_unfilled_ceded_region_stays_quiet() {
3957 // The whole of why `RegionKind` has two opaque members. Both are filled by
3958 // the host; only one of them is owed a fill, and a renderer that has none
3959 // owes the reader different answers.
3960 let handover = Slot::handover("player", "media-transport");
3961 let ceded = Slot::ceded("chart", "revenue-chart");
3962
3963 let handover_rows = crate::region::height(&tui(), &handover, 40, &Local::none());
3964 let ceded_rows = crate::region::height(&tui(), &ceded, 40, &Local::none());
3965 assert!(
3966 handover_rows > ceded_rows,
3967 "an unfilled handover spends rows saying the fill is missing; \
3968 a ceded region has nothing missing to say"
3969 );
3970
3971 // And the notice goes away once the host supplies what it owed.
3972 let supplied = crate::region::height(
3973 &tui().with_fill(
3974 "player",
3975 Words {
3976 text: "PLAYING",
3977 rows: 3,
3978 },
3979 ),
3980 &handover,
3981 40,
3982 &Local::none(),
3983 );
3984 assert_eq!(supplied, ceded_rows + 3);
3985 }
3986
3987 #[test]
3988 fn a_stopwatch_draws_the_time_that_has_passed() {
3989 // The clock is the renderer's, so the description carries only the instant
3990 // and the test moves it rather than moving time.
3991 let started = std::time::SystemTime::now() - std::time::Duration::from_secs(3845);
3992 let lines = drawn(&Node::since(started), 20, 1);
3993
3994 assert_eq!(lines[0], "1:04:05");
3995 }
3996
3997 #[test]
3998 fn a_readout_sits_in_a_row_beside_the_rest_of_it() {
3999 // goingson's measured shape: the elapsed time is one part of a task row
4000 // next to its title, not a block of its own.
4001 let started = std::time::SystemTime::now() - std::time::Duration::from_secs(65);
4002 let row = Row::new("Write the brief").part(layout::RowPart::Meta, Node::since(started));
4003 let lines = drawn(&Node::list([row]), 40, 1);
4004
4005 assert!(
4006 lines[0].contains("Write the brief") && lines[0].contains("0:01:05"),
4007 "{lines:?}"
4008 );
4009 }
4010
4011 #[test]
4012 fn a_stamp_reads_coarsely_and_a_countdown_reads_down() {
4013 let ago = std::time::SystemTime::now() - std::time::Duration::from_secs(10_800);
4014 assert_eq!(drawn(&Node::age(ago), 20, 1)[0], "3h ago");
4015
4016 let due = std::time::SystemTime::now() + std::time::Duration::from_secs(59);
4017 // One second of slack: the clock moves between building the instant and
4018 // drawing it, and a test that demanded the exact second would fail on a
4019 // busy machine roughly once a minute.
4020 assert!(
4021 ["0:00:59", "0:00:58"].contains(&drawn(&Node::until(due), 20, 1)[0].as_str()),
4022 "{:?}",
4023 drawn(&Node::until(due), 20, 1)
4024 );
4025 }
4026
4027 #[test]
4028 fn a_toast_goes_away_on_its_own_and_a_banner_stays() {
4029 // `4453bf82`. The description says which of the two a message is and never
4030 // says how long a toast keeps: the when is this renderer's, so this is the
4031 // terminal being the thing that takes it away.
4032 let mut runtime = Runtime::new(screen_of([Node::text("Tasks")]));
4033 let request = Request::post("/tasks/1/done");
4034 runtime.apply(
4035 &request,
4036 Response::from(Outcome::Fragment {
4037 region: "main".into(),
4038 node: Node::text("Done"),
4039 })
4040 .toast(layout::Tone::Success, "Task completed"),
4041 );
4042 runtime.apply(
4043 &request,
4044 Response::from(Outcome::Fragment {
4045 region: "main".into(),
4046 node: Node::text("Done"),
4047 })
4048 .banner(layout::Tone::Danger, "Sync is failing"),
4049 );
4050 assert_eq!(runtime.screen().notices.len(), 2);
4051
4052 let start = std::time::Instant::now();
4053 // Nothing goes early, and the host is told when to come back.
4054 assert!(!runtime.expires_at(start));
4055 assert_eq!(runtime.screen().notices.len(), 2);
4056 assert!(
4057 runtime
4058 .tick_in_at(start)
4059 .is_some_and(|wait| wait <= crate::LINGER)
4060 );
4061
4062 assert!(runtime.expires_at(start + crate::LINGER + std::time::Duration::from_secs(1)));
4063 let left = &runtime.screen().notices;
4064 assert_eq!(left.len(), 1, "{left:?}");
4065 assert!(
4066 matches!(&left[0], Node::Notice { text, .. } if text == "Sync is failing"),
4067 "the banner is the one that stays: {left:?}"
4068 );
4069 // Nothing left on a clock, so a host with a still screen may block again.
4070 assert!(!runtime.expires_at(start + crate::LINGER * 4));
4071 assert_eq!(runtime.tick_in_at(start), None);
4072 }
4073
4074 #[test]
4075 fn a_toast_arriving_on_a_screen_lingers_from_when_the_screen_did() {
4076 // The other way a toast joins a screen: described onto one rather than said
4077 // by a response. It starts its linger when the screen arrives, which is
4078 // when the user could first have read it.
4079 let mut runtime = Runtime::new(screen_of([Node::text("Tasks")]));
4080 let arriving = Screen::sidebar_content("Tasks")
4081 .saying(Node::Notice {
4082 kind: layout::Notice::Toast,
4083 tone: layout::Tone::Info,
4084 text: "Welcome back".into(),
4085 act: None,
4086 })
4087 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Today")));
4088 runtime.apply(&Request::get("/tasks"), Response::screen(arriving));
4089
4090 let landed = std::time::Instant::now();
4091 assert!(!runtime.expires_at(landed));
4092 assert_eq!(runtime.screen().notices.len(), 1);
4093 assert!(runtime.expires_at(landed + crate::LINGER + std::time::Duration::from_secs(1)));
4094 assert!(runtime.screen().notices.is_empty());
4095 assert_eq!(runtime.tick_in_at(landed), None);
4096 }
4097
4098 #[test]
4099 fn a_screen_says_how_long_a_host_may_wait_before_drawing_it_again() {
4100 let at = std::time::SystemTime::UNIX_EPOCH;
4101 let still = Screen::sidebar_content("Tasks")
4102 .with(Slot::new("body", RegionKind::Pane).with(Node::text("Write the brief")));
4103 assert_eq!(crate::Runtime::new(still).tick_in(), None);
4104
4105 let stamped = Screen::sidebar_content("Tasks")
4106 .with(Slot::new("body", RegionKind::Pane).with(Node::age(at)));
4107 assert_eq!(crate::Runtime::new(stamped).tick_in(), Some(crate::COARSE));
4108
4109 // The finest of the kinds on the screen, so one timeout serves both and
4110 // neither readout is drawn late.
4111 let both = Screen::sidebar_content("Tasks").with(
4112 Slot::new("body", RegionKind::Pane)
4113 .with(Node::age(at))
4114 .with(Node::since(at)),
4115 );
4116 assert_eq!(crate::Runtime::new(both).tick_in(), Some(crate::TICK));
4117 }
4118
4119 /// The table a row's controls are reached from: two rows that open, each with
4120 /// an Edit and a Remove in its last cell.
4121 fn table_with_controls() -> Screen {
4122 let row = |id: &str, name: &str| {
4123 Row::cells([
4124 Cell::new(name),
4125 Cell::acts([
4126 Act::new("Edit", Action::post(format!("/files/{id}/edit"))),
4127 Act::new("Remove", Action::post(format!("/files/{id}/remove"))),
4128 ]),
4129 ])
4130 .activate(Action::get(format!("/files/{id}")))
4131 };
4132 Screen::sidebar_content("Files").with(Slot::new("main", RegionKind::Pane).with(Node::Table {
4133 marks: ::quasi_router::stage::Marks::none(),
4134 columns: vec![Column::new("name"), Column::new("")],
4135 rows: vec![row("1", "kick.wav"), row("2", "snare.wav")],
4136 more: None,
4137 }))
4138 }
4139
4140 #[test]
4141 fn a_control_in_a_cell_is_reached_by_stepping_into_the_row() {
4142 // `27f2331e`, the whole of it. Thirty rows across the MNW templates carry a
4143 // control in a cell, and a terminal could draw every one of them and reach
4144 // none: the table is laid out by `makeover_tui::table`, which answers no
4145 // coordinates back, so a cell can never be a stop of its own. Max ruled the
4146 // row is one stop and a key gets inside it.
4147 let mut runtime = Runtime::new(table_with_controls());
4148
4149 // The caret starts on the first row, and the row still opens.
4150 let Step::Call(request) = runtime.key(Key::Enter) else {
4151 panic!("the row under the caret opens");
4152 };
4153 assert_eq!(request.path, "/files/1");
4154
4155 // Right steps in, onto the first control in the row and not the second.
4156 runtime.key(Key::Right);
4157 let Step::Call(request) = runtime.key(Key::Enter) else {
4158 panic!("the control the caret stepped onto fires");
4159 };
4160 assert_eq!(request.path, "/files/1/edit");
4161 }
4162
4163 #[test]
4164 fn the_controls_inside_a_row_cycle_and_escape_steps_back_out() {
4165 let mut runtime = Runtime::new(table_with_controls());
4166
4167 runtime.key(Key::Right);
4168 runtime.key(Key::Right);
4169 let Step::Call(request) = runtime.key(Key::Enter) else {
4170 panic!("the second control fires");
4171 };
4172 assert_eq!(request.path, "/files/1/remove");
4173
4174 // Cycling, not stopping. A terminal has nothing to show you that you are on
4175 // the last control, so a key that stopped dead would read as a broken key.
4176 runtime.key(Key::Right);
4177 let Step::Call(request) = runtime.key(Key::Enter) else {
4178 panic!("the run wraps round to its first control");
4179 };
4180 assert_eq!(request.path, "/files/1/edit");
4181
4182 // Left is the same run the other way.
4183 runtime.key(Key::Left);
4184 let Step::Call(request) = runtime.key(Key::Enter) else {
4185 panic!("Left cycles back");
4186 };
4187 assert_eq!(request.path, "/files/1/remove");
4188
4189 // And Escape puts the caret back on the row, which opens again.
4190 runtime.key(Key::Escape);
4191 let Step::Call(request) = runtime.key(Key::Enter) else {
4192 panic!("the row opens once the caret has stepped out");
4193 };
4194 assert_eq!(request.path, "/files/1");
4195 }
4196
4197 #[test]
4198 fn leaving_a_row_leaves_the_control_the_caret_was_on() {
4199 // Stepping in is a move within one stop, so the moment the caret is on
4200 // another stop there is nothing to be inside of. Without this, tabbing off
4201 // a row and back onto it would land on the button rather than the row, and
4202 // Enter would remove a file the reader meant to open.
4203 let mut runtime = Runtime::new(table_with_controls());
4204
4205 runtime.key(Key::Right);
4206 runtime.key(Key::Tab);
4207 runtime.key(Key::BackTab);
4208 let Step::Call(request) = runtime.key(Key::Enter) else {
4209 panic!("the row is what the caret came back to");
4210 };
4211 assert_eq!(request.path, "/files/1");
4212 }
4213
4214 #[test]
4215 fn each_row_holds_its_own_controls() {
4216 // The row is the stop and the controls are inside it, so stepping into the
4217 // second row reaches the second row's Remove and not the first's.
4218 let mut runtime = Runtime::new(table_with_controls());
4219
4220 runtime.key(Key::Tab);
4221 runtime.key(Key::Right);
4222 runtime.key(Key::Right);
4223 let Step::Call(request) = runtime.key(Key::Enter) else {
4224 panic!("the second row's second control fires");
4225 };
4226 assert_eq!(request.path, "/files/2/remove");
4227 }
4228
4229 #[test]
4230 fn a_row_whose_only_affordance_is_a_control_in_a_cell_is_reachable() {
4231 // The third case `focus.rs` counts, after opening and ticking. A row that
4232 // neither opens nor ticks used to contribute no stop at all, which put its
4233 // Remove button behind a row the caret could not land on.
4234 let mut runtime = Runtime::new(Screen::sidebar_content("Files").with(
4235 Slot::new("main", RegionKind::Pane).with(Node::Table {
4236 marks: ::quasi_router::stage::Marks::none(),
4237 columns: vec![Column::new("name"), Column::new("")],
4238 rows: vec![Row::cells([
4239 Cell::new("kick.wav"),
4240 Cell::acts([Act::new("Remove", Action::post("/files/1/remove"))]),
4241 ])],
4242 more: None,
4243 }),
4244 ));
4245
4246 runtime.key(Key::Right);
4247 let Step::Call(request) = runtime.key(Key::Enter) else {
4248 panic!("a row that only carries a control is still a stop");
4249 };
4250 assert_eq!(request.path, "/files/1/remove");
4251 }
4252
4253 #[test]
4254 fn the_control_the_caret_stepped_onto_is_the_one_drawn_lit() {
4255 // The two-step order has no ring around a rect to give, because there is no
4256 // rect: the row takes the table's own highlight and the control inside it
4257 // takes the focus style. A reader who cannot see which of Edit and Remove
4258 // Enter would press has been given a keystroke and no answer.
4259 let mut runtime = Runtime::new(table_with_controls());
4260 runtime.key(Key::Right);
4261 runtime.key(Key::Right);
4262
4263 // Wide enough that the cell is not truncated. What is being asserted is
4264 // which control the style landed on, and a narrow window would have the
4265 // assertion failing over the table's own eliding.
4266 let area = Rect::new(0, 0, 80, 10);
4267 let mut buf = Buffer::empty(area);
4268 runtime.draw(&tui(), area, &mut buf);
4269 let lit: String = (0..area.height)
4270 .map(|y| marked(&buf, y, Modifier::REVERSED))
4271 .collect();
4272
4273 assert!(
4274 lit.contains("Remove"),
4275 "the caret's control is lit: {lit:?}"
4276 );
4277 assert!(!lit.contains("Edit"), "and its neighbour is not: {lit:?}");
4278 }
4279
4280 #[test]
4281 fn a_list_row_keeps_its_controls_beside_it_rather_than_inside_it() {
4282 // The other half of the ruling, and a regression: a terminal draws a list
4283 // itself and knows where every part of a line ended up, so a list row's
4284 // controls are stops of their own and Tab still reaches them. Only a table
4285 // has an inside.
4286 let mut runtime = Runtime::new(Screen::sidebar_content("Files").with(
4287 Slot::new("main", RegionKind::Pane).with(Node::Table {
4288 marks: ::quasi_router::stage::Marks::none(),
4289 columns: Vec::new(),
4290 rows: vec![
4291 Row::new("kick.wav")
4292 .activate(Action::get("/files/1"))
4293 .act(Act::new("Remove", Action::post("/files/1/remove"))),
4294 ],
4295 more: None,
4296 }),
4297 ));
4298
4299 // One Tab, not a step inside.
4300 runtime.key(Key::Tab);
4301 let Step::Call(request) = runtime.key(Key::Enter) else {
4302 panic!("a list row's control is the next stop");
4303 };
4304 assert_eq!(request.path, "/files/1/remove");
4305
4306 // And Right on the row does nothing, because there is nothing to step into.
4307 runtime.key(Key::BackTab);
4308 runtime.key(Key::Right);
4309 let Step::Call(request) = runtime.key(Key::Enter) else {
4310 panic!("the row still opens");
4311 };
4312 assert_eq!(request.path, "/files/1");
4313 }
4314
4315 #[test]
4316 fn a_disabled_control_in_a_cell_is_drawn_and_stepped_past() {
4317 // What `disabled` means on every host, said one node further in. The row is
4318 // still a stop because it opens; the control in it is not one of the places
4319 // the caret can step onto.
4320 let mut runtime = Runtime::new(Screen::sidebar_content("Files").with(
4321 Slot::new("main", RegionKind::Pane).with(Node::Table {
4322 marks: ::quasi_router::stage::Marks::none(),
4323 columns: vec![Column::new("name"), Column::new("")],
4324 rows: vec![
4325 Row::cells([
4326 Cell::new("kick.wav"),
4327 Cell::acts([
4328 Act::new("Restore", Action::post("/files/1/restore")).disabled(),
4329 Act::new("Remove", Action::post("/files/1/remove")),
4330 ]),
4331 ])
4332 .activate(Action::get("/files/1")),
4333 ],
4334 more: None,
4335 }),
4336 ));
4337
4338 runtime.key(Key::Right);
4339 let Step::Call(request) = runtime.key(Key::Enter) else {
4340 panic!("the first control the caret can stand on fires");
4341 };
4342 assert_eq!(request.path, "/files/1/remove");
4343 }
4344
4345 #[test]
4346 fn the_drawing_counts_a_row_the_walk_stops_on_for_its_controls_alone() {
4347 // `draw_table` and `focus.rs` decide reachability with one function now, and
4348 // this is the case that would have made them disagree: a row that neither
4349 // opens nor ticks is a stop because of what is in its cells, and a drawing
4350 // that did not count it would light every control below the table one place
4351 // early.
4352 let mut runtime = Runtime::new(
4353 Screen::sidebar_content("Files").with(
4354 Slot::new("main", RegionKind::Pane)
4355 .with(Node::Table {
4356 marks: ::quasi_router::stage::Marks::none(),
4357 columns: vec![Column::new("name"), Column::new("")],
4358 rows: vec![Row::cells([
4359 Cell::new("kick.wav"),
4360 Cell::acts([Act::new("Remove", Action::post("/files/1/remove"))]),
4361 ])],
4362 more: None,
4363 })
4364 .with(Node::Act(Act::new("Import", Action::post("/files/import")))),
4365 ),
4366 );
4367
4368 // Past the row and onto the act under the table.
4369 runtime.key(Key::Tab);
4370
4371 let area = Rect::new(0, 0, 44, 10);
4372 let mut buf = Buffer::empty(area);
4373 runtime.draw(&tui(), area, &mut buf);
4374 let lit: String = (0..area.height)
4375 .map(|y| marked(&buf, y, Modifier::REVERSED))
4376 .collect();
4377 assert!(
4378 lit.contains("Import"),
4379 "the caret's control is lit: {lit:?}"
4380 );
4381 }
4382
4383 /// The question a field owns is asked like any other; what differs is where
4384 /// the answer goes — onto the view, beside what has been typed, rather than
4385 /// into the screen.
4386 #[test]
4387 fn a_field_that_owns_a_list_asks_for_it_and_holds_the_answer() {
4388 let mut runtime = Runtime::new(screen_of([Node::field(
4389 Field::new(layout::FieldKind::Text, "q", "Search").suggesting(
4390 Consult::new(Action::get("/discover/suggestions"))
4391 .after(std::time::Duration::from_millis(200))
4392 .at_least(2),
4393 ),
4394 )]));
4395
4396 assert!(matches!(runtime.key(Key::Char('r')), Step::Idle));
4397 let Step::CallAfter { asks } = runtime.key(Key::Char('u')) else {
4398 panic!("two characters clears the floor");
4399 };
4400 assert_eq!(asks[0].request.path, "/discover/suggestions");
4401
4402 runtime.apply(
4403 &asks[0].request,
4404 Response::suggestions(
4405 "q",
4406 vec![
4407 Candidate::new("rust-lang", "Rust"),
4408 Candidate::plain("ruby"),
4409 ],
4410 ),
4411 );
4412 let open = runtime.view().suggesting("q").expect("a list is open");
4413 assert_eq!(open.options.len(), 2);
4414 // Nothing is highlighted until an arrow says so, which is what leaves Enter
4415 // to the form until the reader has walked into the list.
4416 assert_eq!(open.at, None);
4417 }
4418
4419 /// The four keys a list owns while it is open, and what picking costs.
4420 #[test]
4421 fn a_pick_writes_the_value_and_closes_the_list() {
4422 let mut runtime = Runtime::new(screen_of([Node::field(
4423 Field::new(layout::FieldKind::Text, "q", "Search")
4424 .suggests(Action::get("/discover/suggestions")),
4425 )]));
4426 let Step::CallAfter { asks } = runtime.key(Key::Char('r')) else {
4427 panic!("the field asks");
4428 };
4429 runtime.apply(
4430 &asks[0].request,
4431 Response::suggestions(
4432 "q",
4433 vec![
4434 Candidate::new("rust-lang", "Rust"),
4435 Candidate::plain("ruby"),
4436 ],
4437 ),
4438 );
4439
4440 // Down from nothing lands on the first, and wraps rather than stopping.
4441 runtime.key(Key::Down);
4442 assert_eq!(runtime.view().suggesting("q").expect("open").at, Some(0));
4443 runtime.key(Key::Up);
4444 assert_eq!(runtime.view().suggesting("q").expect("open").at, Some(1));
4445
4446 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
4447 // The value, not the label: the pair a candidate carries stays two.
4448 assert_eq!(runtime.view().edit("q"), Some("ruby"));
4449 assert!(runtime.view().suggesting("q").is_none());
4450 }
4451
4452 /// Escape puts the list away before it means anything else, which is the
4453 /// innermost-thing-first rule Escape already follows.
4454 #[test]
4455 fn escape_closes_the_list_before_it_goes_back() {
4456 let mut runtime = Runtime::new(screen_of([Node::field(
4457 Field::new(layout::FieldKind::Text, "q", "Search")
4458 .suggests(Action::get("/discover/suggestions")),
4459 )]));
4460 let Step::CallAfter { asks } = runtime.key(Key::Char('r')) else {
4461 panic!("the field asks");
4462 };
4463 runtime.apply(
4464 &asks[0].request,
4465 Response::suggestions("q", vec![Candidate::plain("ruby")]),
4466 );
4467
4468 assert!(matches!(runtime.key(Key::Escape), Step::Idle));
4469 assert!(runtime.view().suggesting("q").is_none());
4470 // And the arrows are the focus's again the moment the list is gone.
4471 assert!(matches!(runtime.key(Key::Down), Step::Idle));
4472 }
4473
4474 /// A route with nothing to suggest and a route that was never asked leave the
4475 /// screen in the same state.
4476 #[test]
4477 fn an_empty_answer_opens_no_list() {
4478 let mut runtime = Runtime::new(screen_of([Node::field(
4479 Field::new(layout::FieldKind::Text, "q", "Search")
4480 .suggests(Action::get("/discover/suggestions")),
4481 )]));
4482 let Step::CallAfter { asks } = runtime.key(Key::Char('r')) else {
4483 panic!("the field asks");
4484 };
4485 runtime.apply(&asks[0].request, Response::suggestions("q", Vec::new()));
4486 assert!(runtime.view().suggesting("q").is_none());
4487 }
4488
4489 /// Deleting back under the floor takes the candidates with it: they were about
4490 /// a value that no longer earns them.
4491 #[test]
4492 fn dropping_under_the_floor_closes_the_list() {
4493 let mut runtime = Runtime::new(screen_of([Node::field(
4494 Field::new(layout::FieldKind::Text, "q", "Search")
4495 .suggesting(Consult::new(Action::get("/discover/suggestions")).at_least(2)),
4496 )]));
4497 runtime.key(Key::Char('r'));
4498 let Step::CallAfter { asks } = runtime.key(Key::Char('u')) else {
4499 panic!("two characters clears the floor");
4500 };
4501 runtime.apply(
4502 &asks[0].request,
4503 Response::suggestions("q", vec![Candidate::plain("ruby")]),
4504 );
4505 assert!(runtime.view().suggesting("q").is_some());
4506
4507 runtime.key(Key::Backspace);
4508 assert!(runtime.view().suggesting("q").is_none());
4509 }
4510
4511 /// A file leaves by `handed` rather than by the return value, and the screen
4512 /// the control was pressed on is still the screen showing.
4513 #[test]
4514 fn a_file_answer_is_handed_over_and_changes_nothing_on_screen() {
4515 let mut runtime = Runtime::new(screen_of([Node::text("settings")]));
4516 let follow_up = runtime.apply(
4517 &Request::get("/data/export/json"),
4518 Response::file(
4519 "goingson-export.json",
4520 quasi_router::Accepted::media_type("application/json"),
4521 br#"{"tasks":[]}"#.to_vec(),
4522 ),
4523 );
4524
4525 assert_eq!(follow_up, None);
4526 let handed = runtime.handed().expect("the answer handed a file over");
4527 assert_eq!(handed.name, "goingson-export.json");
4528 assert_eq!(handed.bytes, br#"{"tasks":[]}"#);
4529 assert_eq!(
4530 handed.kind,
4531 quasi_router::Accepted::Type("application/json".into())
4532 );
4533 // Nothing on the screen moved: a file is not a region and not a place.
4534 assert_eq!(runtime.handed(), None);
4535 }
4536
4537 /// An ask for a place leaves by `locating`, drains once, and leaves the screen
4538 /// the control was pressed on showing.
4539 #[test]
4540 fn an_ask_for_a_place_leaves_by_locating_and_changes_nothing_on_screen() {
4541 let mut runtime = Runtime::new(screen_of([Node::text("import")]));
4542 let follow_up = runtime.apply(
4543 &Request::post("/import/open"),
4544 Response::locate(quasi_router::Locating::folder(
4545 "Import folder",
4546 Action::post("/import/from"),
4547 "folder",
4548 )),
4549 );
4550
4551 assert_eq!(follow_up, None);
4552 let asking = runtime.locating().expect("the answer asked for a place");
4553 assert_eq!(asking.sought, quasi_router::Sought::Folder);
4554 assert_eq!(asking.prompt, "Import folder");
4555 // The picker is the host's furniture: nothing was drawn, nothing navigated,
4556 // and a second drain gets the ask no second time.
4557 assert_eq!(runtime.locating(), None);
4558
4559 // What the reader picked comes back as an ordinary request, built by the
4560 // crate that stated the parameter name rather than by the host.
4561 let answered = asking
4562 .answered([quasi_router::Picked::new("/home/max/samples", "samples")])
4563 .expect("a route to answer to");
4564 assert_eq!(
4565 answered,
4566 Request::post("/import/from")
4567 .sending(quasi_router::Params::new().with("folder", "/home/max/samples"))
4568 );
4569 }
4570
4571 /// The save shape reaches the host whole, suggested name included, because the
4572 /// name is the reason the dialog is opened rather than a folder picker.
4573 #[test]
4574 fn a_save_ask_carries_its_suggested_name_out_to_the_host() {
4575 let mut runtime = Runtime::new(screen_of([Node::text("classifier")]));
4576 let follow_up = runtime.apply(
4577 &Request::post("/classifier/open"),
4578 Response::locate(quasi_router::Locating::new(
4579 quasi_router::Sought::Save {
4580 name: "drums-2026-08-25.afcl".into(),
4581 accept: vec![quasi_router::Accepted::suffix(".afcl")],
4582 },
4583 "Export classifier",
4584 Action::post("/classifier/export"),
4585 "path",
4586 )),
4587 );
4588
4589 assert_eq!(follow_up, None);
4590 let asking = runtime.locating().expect("the answer asked for a place");
4591 let quasi_router::Sought::Save { name, accept } = &asking.sought else {
4592 panic!("a save ask");
4593 };
4594 assert_eq!(name, "drums-2026-08-25.afcl");
4595 assert_eq!(accept, &[quasi_router::Accepted::Suffix(".afcl".into())]);
4596 assert_eq!(asking.prompt, "Export classifier");
4597
4598 let answered = asking
4599 .answered([quasi_router::Picked::new(
4600 "/home/max/exports/drums.afcl",
4601 "drums.afcl",
4602 )])
4603 .expect("a route to answer to");
4604 assert_eq!(
4605 answered,
4606 Request::post("/classifier/export")
4607 .sending(quasi_router::Params::new().with("path", "/home/max/exports/drums.afcl"))
4608 );
4609 }
4610
4611 /// Several files picked at once are one call, which is what keeps a batched
4612 /// import a batch.
4613 #[test]
4614 fn several_picked_files_answer_the_ask_once() {
4615 let mut runtime = Runtime::new(screen_of([Node::text("import")]));
4616 let follow_up = runtime.apply(
4617 &Request::post("/import/open"),
4618 Response::locate(quasi_router::Locating::new(
4619 quasi_router::Sought::Files {
4620 accept: vec![quasi_router::Accepted::suffix(".wav")],
4621 },
4622 "Import files",
4623 Action::post("/import/files"),
4624 "path",
4625 )),
4626 );
4627
4628 assert_eq!(follow_up, None);
4629 let asking = runtime.locating().expect("the answer asked for a place");
4630 let answered = asking
4631 .answered([
4632 quasi_router::Picked::new("/tmp/a.wav", "a.wav"),
4633 quasi_router::Picked::new("/tmp/b.wav", "b.wav"),
4634 ])
4635 .expect("a route to answer to");
4636 assert_eq!(
4637 answered,
4638 Request::post("/import/files").sending(
4639 quasi_router::Params::new()
4640 .with("path", "/tmp/a.wav")
4641 .with("path", "/tmp/b.wav")
4642 )
4643 );
4644 }
4645
4646 /// A reader who backs out of the picker has answered nothing, so the host makes
4647 /// no call and the runtime is not told. Nothing to assert but the absence, and
4648 /// the absence is the design: a cancelled picker costs the screen nothing.
4649 #[test]
4650 fn a_screen_with_no_ask_hands_out_no_place() {
4651 let mut runtime = Runtime::new(screen_of([Node::text("import")]));
4652 assert_eq!(runtime.locating(), None);
4653 }
4654
4655 /// The name is sanitised where the host cannot forget to do it. This runtime
4656 /// writes nothing itself, so a host taking the name at face value is exactly
4657 /// the failure -- `../../.ssh/authorized_keys` beside the process.
4658 #[test]
4659 fn a_handed_file_carries_a_name_no_host_can_traverse_with() {
4660 let mut runtime = Runtime::new(screen_of([Node::text("settings")]));
4661 runtime.apply(
4662 &Request::get("/data/export"),
4663 Response::file(
4664 "../../.ssh/authorized_keys",
4665 quasi_router::Accepted::suffix(".txt"),
4666 b"ssh-rsa".to_vec(),
4667 ),
4668 );
4669
4670 let handed = runtime.handed().expect("the answer handed a file over");
4671 assert!(!handed.name.contains('/'));
4672 assert!(!handed.name.contains(".."));
4673 }
4674
4675 /// A host that never drains it drops the download, which is the cost of keeping
4676 /// the writing out of this crate. A second file replaces the first rather than
4677 /// queueing: two files from one answer is not something the vocabulary says.
4678 #[test]
4679 fn a_second_file_replaces_the_one_the_host_never_took() {
4680 let mut runtime = Runtime::new(screen_of([Node::text("settings")]));
4681 for name in ["first.json", "second.json"] {
4682 runtime.apply(
4683 &Request::get("/data/export"),
4684 Response::file(
4685 name,
4686 quasi_router::Accepted::media_type("application/json"),
4687 b"{}".to_vec(),
4688 ),
4689 );
4690 }
4691 assert_eq!(
4692 runtime.handed().map(|handed| handed.name),
4693 Some("second.json".to_owned())
4694 );
4695 }
4696
4697 #[test]
4698 fn a_control_that_deposits_a_value_puts_it_on_the_end_of_the_box_it_named() {
4699 // `f35aafee`. The act names a box on the screen and the press writes the
4700 // value into it. Where in the box is this renderer's, and a terminal has no
4701 // caret inside a field to insert at (`d52884b0`), so it goes on the end --
4702 // which the vocabulary calls correct rather than a fallback.
4703 let mut runtime = Runtime::new(screen_of([
4704 Node::field(Field::new(layout::FieldKind::Text, "body", "Body").value("Intro. ")),
4705 Node::Act(Act::new("kick.png", Action::local()).filling("body", "![](media/kick.png)")),
4706 ]));
4707
4708 // Onto the control: the box is the first stop.
4709 assert!(matches!(runtime.key(Key::Tab), Step::Idle));
4710 // Local, so nothing is called. The deposit is the whole of the press.
4711 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
4712
4713 // After what the box was showing rather than over it. A deposit starting
4714 // from an empty string would discard the draft, which is the defect the
4715 // member was filed to stop.
4716 assert_eq!(
4717 runtime.view().edit("body"),
4718 Some("Intro. ![](media/kick.png)")
4719 );
4720 }
4721
4722 #[test]
4723 fn a_deposit_lands_after_what_was_typed_and_travels_with_a_later_submit() {
4724 let mut runtime = Runtime::new(screen_of([
4725 Node::field(Field::new(layout::FieldKind::Text, "body", "Body")),
4726 Node::Act(Act::new("Insert", Action::local()).filling("body", "[img]")),
4727 ]));
4728
4729 runtime.key(Key::Char('h'));
4730 runtime.key(Key::Char('i'));
4731 runtime.key(Key::Tab);
4732 runtime.key(Key::Enter);
4733
4734 assert_eq!(runtime.view().edit("body"), Some("hi[img]"));
4735 }
4736
4737 #[test]
4738 fn an_ordinary_control_deposits_nothing_into_the_boxes_beside_it() {
4739 let mut runtime = Runtime::new(screen_of([
4740 Node::field(Field::new(layout::FieldKind::Text, "body", "Body").value("Intro.")),
4741 Node::Act(Act::new("Save", Action::post("/save"))),
4742 ]));
4743
4744 runtime.key(Key::Tab);
4745 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save"));
4746 assert_eq!(runtime.view().edit("body"), None);
4747 }
4748
4749 /// Draw a whole screen under a view the user has already touched.
4750 fn shown_under(screen: &Screen, view: &View, width: u16, height: u16) -> Vec<String> {
4751 let area = Rect::new(0, 0, width, height);
4752 let mut buf = Buffer::empty(area);
4753 tui().screen(screen, view, area, &mut buf);
4754 rows(&buf)
4755 }
4756
4757 /// MNW's pay-what-you-want settings: a checkbox and the section it brings out.
4758 fn pricing() -> Screen {
4759 Screen::sidebar_content("Pricing").with(
4760 Slot::new("body", RegionKind::Pane)
4761 .with(Node::Field(Box::new(Field::new(
4762 layout::FieldKind::Checkbox,
4763 "pwyw",
4764 "Pay what you want",
4765 ))))
4766 .with(Node::Region(
4767 Slot::group("pwyw-settings")
4768 .revealed_by(quasi_router::Reveal::ticked("pwyw"))
4769 .with(Node::text("Suggested price")),
4770 )),
4771 )
4772 }
4773
4774 /// A terminal has no stylesheet to hide with, so a region that does not apply
4775 /// right now is left out: the honest reading of a hidden region here is that
4776 /// it is not applicable, which is what the ruling put on the region.
4777 #[test]
4778 fn a_region_whose_control_holds_nothing_is_not_drawn() {
4779 let screen = pricing();
4780 let out = shown_under(&screen, &View::new(), 60, 12).join(" ");
4781 assert!(out.contains("Pay what you want"), "{out}");
4782 assert!(!out.contains("Suggested price"), "{out}");
4783 }
4784
4785 /// And it comes out when the box is ticked, without anything being asked for.
4786 /// The section is in the description already; a round trip to reveal it would
4787 /// re-render a form the reader is midway through.
4788 #[test]
4789 fn ticking_the_control_brings_the_region_out_with_no_request() {
4790 let screen = pricing();
4791 let mut view = View::new();
4792 view.set("pwyw", quasi_router::Node::SELECTED);
4793 let out = shown_under(&screen, &view, 60, 12).join(" ");
4794 assert!(out.contains("Suggested price"), "{out}");
4795 // Nothing outstanding: the region named no call and none was made.
4796 assert!(view.outstanding().is_none());
4797 }
4798
4799 /// An untouched control holds what the description offered it, which is how a
4800 /// section arrives already out on a form the server refilled.
4801 #[test]
4802 fn a_region_reads_the_value_the_description_offered() {
4803 let screen = Screen::sidebar_content("Licensing").with(
4804 Slot::new("body", RegionKind::Pane)
4805 .with(Node::Field(Box::new(
4806 Field::select(
4807 "license_kind",
4808 "Licence",
4809 vec![Choice::new("custom", "Custom")],
4810 )
4811 .value("custom"),
4812 )))
4813 .with(Node::Region(
4814 Slot::group("dash-custom-license")
4815 .revealed_by(quasi_router::Reveal::holding("license_kind", "custom"))
4816 .with(Node::text("Licence text")),
4817 )),
4818 );
4819
4820 let out = shown_under(&screen, &View::new(), 60, 12).join(" ");
4821 assert!(out.contains("Licence text"), "{out}");
4822 }
4823
4824 /// The caret and the picture read one list. A control inside a region that is
4825 /// not on the screen is not a place the caret can stop, or Tab would move the
4826 /// highlight to something nobody can see.
4827 #[test]
4828 fn the_caret_does_not_stop_inside_a_region_that_does_not_apply() {
4829 let screen = pricing();
4830 let view = View::new();
4831 let hidden = crate::reveal::hidden(&screen, &quasi_router::Chrome::new(), &view);
4832 assert_eq!(hidden.regions, vec!["pwyw-settings"]);
4833
4834 // This region holds prose, so the two walks agree about the count and the
4835 // assertion below is what carries the claim.
4836 assert_eq!(
4837 crate::focus::reaches(&screen, &Local::hiding(&hidden)).len(),
4838 crate::focus::reaches(&screen, &Local::none()).len()
4839 );
4840
4841 // With a control in it, the two counts come apart by exactly that control.
4842 let screen = Screen::sidebar_content("Pricing").with(
4843 Slot::new("body", RegionKind::Pane)
4844 .with(Node::Field(Box::new(Field::new(
4845 layout::FieldKind::Checkbox,
4846 "pwyw",
4847 "Pay what you want",
4848 ))))
4849 .with(Node::Region(
4850 Slot::group("pwyw-settings")
4851 .revealed_by(quasi_router::Reveal::ticked("pwyw"))
4852 .with(Node::Field(Box::new(Field::new(
4853 layout::FieldKind::Text,
4854 "suggested",
4855 "Suggested price",
4856 )))),
4857 )),
4858 );
4859 let hidden = crate::reveal::hidden(&screen, &quasi_router::Chrome::new(), &view);
4860 assert_eq!(
4861 crate::focus::reaches(&screen, &Local::hiding(&hidden)).len() + 1,
4862 crate::focus::reaches(&screen, &Local::none()).len()
4863 );
4864 }
4865
4866 /// What was typed into a section the reader has since closed is still theirs,
4867 /// and is still submitted. A browser sends the value of a hidden input, and one
4868 /// description submitted on two hosts has to send the same form.
4869 #[test]
4870 fn a_closed_section_keeps_what_was_typed_into_it() {
4871 let screen = Screen::sidebar_content("Pricing").with(
4872 Slot::new("body", RegionKind::Pane)
4873 .with(Node::Field(Box::new(Field::new(
4874 layout::FieldKind::Checkbox,
4875 "pwyw",
4876 "Pay what you want",
4877 ))))
4878 .with(Node::Region(
4879 Slot::group("pwyw-settings")
4880 .revealed_by(quasi_router::Reveal::ticked("pwyw"))
4881 .with(Node::Field(Box::new(Field::new(
4882 layout::FieldKind::Number,
4883 "suggested",
4884 "Suggested price",
4885 )))),
4886 )),
4887 );
4888
4889 let mut view = View::new();
4890 view.set("pwyw", quasi_router::Node::SELECTED);
4891 view.set("suggested", "12");
4892 // The reader unticks the box: the section goes, the number stays.
4893 view.set("pwyw", "");
4894 view.prune(&screen, &Frame::new(), &quasi_router::Chrome::new());
4895 assert_eq!(view.edit("suggested"), Some("12"));
4896 }
4897
4898 // One conditional question inside a form: `8fdb814c`, goingson's zone picker.
4899
4900 /// goingson's event form: which kind of zone the time is in, and the box that
4901 /// only applies to one of the kinds. The two have to sit in one form, so the
4902 /// condition is the question's own.
4903 fn zone_form() -> Screen {
4904 screen_of([Node::Form {
4905 marks: ::quasi_router::stage::Marks::none(),
4906 action: Action::post("/events"),
4907 submit: "Save".into(),
4908 fields: vec![
4909 Field::select(
4910 "tz_kind",
4911 "Time zone",
4912 vec![
4913 Choice::new("relative", "Relative to me"),
4914 Choice::new("local", "A place"),
4915 ],
4916 )
4917 .value("relative"),
4918 Field::new(layout::FieldKind::Text, "timezone", "Anchored to")
4919 .revealed_by(quasi_router::Reveal::holding("tz_kind", "local")),
4920 ],
4921 }])
4922 }
4923
4924 /// It is not on the screen and the caret does not stop on it, which are one
4925 /// answer read from one list.
4926 #[test]
4927 fn a_question_that_does_not_apply_is_neither_drawn_nor_stopped_on() {
4928 let screen = zone_form();
4929 let view = View::new();
4930 let chrome = quasi_router::Chrome::new();
4931
4932 let hidden = crate::reveal::hidden(&screen, &chrome, &view);
4933 assert_eq!(hidden.fields, vec!["timezone"]);
4934 assert!(hidden.regions.is_empty());
4935
4936 let out = shown_under(&screen, &view, 60, 12).join(" ");
4937 assert!(!out.contains("Anchored to"), "{out}");
4938 assert_eq!(
4939 crate::focus::reaches(&screen, &Local::hiding(&hidden)).len() + 1,
4940 crate::focus::reaches(&screen, &Local::none()).len()
4941 );
4942 }
4943
4944 /// And it comes out when the control holds the value it named, with nothing
4945 /// asked of any route.
4946 #[test]
4947 fn picking_the_kind_brings_the_question_out_with_no_request() {
4948 let screen = zone_form();
4949 let mut view = View::new();
4950 view.set("tz_kind", "local");
4951
4952 let hidden = crate::reveal::hidden(&screen, &quasi_router::Chrome::new(), &view);
4953 assert!(hidden.fields.is_empty());
4954 let out = shown_under(&screen, &view, 60, 12).join(" ");
4955 assert!(out.contains("Anchored to"), "{out}");
4956 assert!(view.outstanding().is_none());
4957 }
4958
4959 /// What was typed into a question that no longer applies is still sent, which
4960 /// is what a browser does with an input inside a hidden element.
4961 #[test]
4962 fn a_question_that_stopped_applying_still_submits_what_it_holds() {
4963 let screen = zone_form();
4964 let mut view = View::new();
4965 view.set("tz_kind", "local");
4966 view.set("timezone", "America/Denver");
4967 // The reader changes their mind: the box goes, the value stays.
4968 view.set("tz_kind", "relative");
4969 view.prune(&screen, &Frame::new(), &quasi_router::Chrome::new());
4970 assert_eq!(view.edit("timezone"), Some("America/Denver"));
4971 }
4972
4973 // A question answered N times: `60d1753c`, ruled 2026-08-25.
4974
4975 /// The reminders question goingson `8fdb814c` restores.
4976 fn reminders() -> Field {
4977 Field::new(layout::FieldKind::Number, "reminder", "Reminder").repeating(
4978 quasi_router::Repeat::answered(["300", "900"])
4979 .most(8)
4980 .adding("Add reminder")
4981 .removing("Remove"),
4982 )
4983 }
4984
4985 fn reminders_form() -> Screen {
4986 screen_of([Node::Form {
4987 marks: ::quasi_router::stage::Marks::none(),
4988 action: Action::post("/events"),
4989 submit: "Save".into(),
4990 fields: vec![reminders()],
4991 }])
4992 }
4993
4994 /// Every slot is a stop, each under its own indexed name, and the two controls
4995 /// are stops of their own.
4996 #[test]
4997 fn a_repeating_question_reaches_a_stop_per_slot_and_its_two_controls() {
4998 let screen = reminders_form();
4999 let spots = crate::focus::spots(&screen, &Local::none());
5000 let names: Vec<String> = spots
5001 .iter()
5002 .filter_map(Spot::field)
5003 .map(|field| field.name.clone())
5004 .collect();
5005 assert_eq!(names, ["reminder[0]", "reminder[1]"]);
5006
5007 let controls: Vec<Option<usize>> = spots
5008 .iter()
5009 .filter_map(|spot| match spot {
5010 Spot::Repeat { at, .. } => Some(*at),
5011 _ => None,
5012 })
5013 .collect();
5014 // A remove under each slot, then the add: the order the drawing paints
5015 // them.
5016 assert_eq!(controls, [Some(0), Some(1), None]);
5017 // Neither calls a route.
5018 for spot in &spots {
5019 if matches!(spot, Spot::Repeat { .. }) {
5020 assert!(spot.enters().is_none());
5021 }
5022 }
5023 }
5024
5025 /// The third hard part in the host with no document: pressing add changes what
5026 /// the reader is holding and asks nothing of any route.
5027 #[test]
5028 fn adding_and_removing_a_slot_asks_no_route() {
5029 let mut runtime = Runtime::new(reminders_form());
5030 // Onto the first remove control, which is the stop after the first box.
5031 runtime.key(Key::Tab);
5032 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
5033 assert_eq!(runtime.view().standing(&reminders()), 1);
5034
5035 // The add control is the stop before the form's submit, whatever the count
5036 // is.
5037 let to_add = |runtime: &mut Runtime| {
5038 let last = runtime.reaches().len() - 2;
5039 while runtime.view().focus() != last {
5040 runtime.key(Key::Tab);
5041 }
5042 };
5043 to_add(&mut runtime);
5044 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
5045 to_add(&mut runtime);
5046 assert!(matches!(runtime.key(Key::Enter), Step::Idle));
5047 assert_eq!(runtime.view().standing(&reminders()), 3);
5048 }
5049
5050 /// One submit carrying every instance, which is what separates this from a list
5051 /// of forms.
5052 #[test]
5053 fn one_submit_carries_every_slot() {
5054 let mut runtime = Runtime::new(reminders_form());
5055 // Tab to the add control, which is the stop before the submit, and press
5056 // it: a third slot the description never described.
5057 let add = runtime.reaches().len() - 2;
5058 while runtime.view().focus() != add {
5059 runtime.key(Key::Tab);
5060 }
5061 runtime.key(Key::Enter);
5062
5063 // The caret is left where it was, which is now the box that arrived.
5064 assert!(runtime.editing(), "the caret is in the new box");
5065 for ch in "7200".chars() {
5066 runtime.key(Key::Char(ch));
5067 }
5068
5069 let submit = runtime.reaches().len() - 1;
5070 while runtime.view().focus() != submit {
5071 runtime.key(Key::Tab);
5072 }
5073 let Step::Call(request) = runtime.key(Key::Enter) else {
5074 panic!("the submit calls its route");
5075 };
5076 assert_eq!(request.path, "/events");
5077 assert_eq!(
5078 request.payload.repeated("reminder"),
5079 ["300", "900", "7200"],
5080 "{:?}",
5081 request.payload
5082 );
5083 }
5084
5085 /// The floor and the ceiling are the description's, and no press gets past
5086 /// them. A question at its ceiling offers no add control at all.
5087 #[test]
5088 fn the_floor_and_the_ceiling_hold() {
5089 let capped = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
5090 .repeating(quasi_router::Repeat::answered(["300", "900"]).most(2));
5091 let screen = screen_of([Node::field(capped.clone())]);
5092 let spots = crate::focus::spots(&screen, &Local::none());
5093 assert!(
5094 !spots
5095 .iter()
5096 .any(|spot| matches!(spot, Spot::Repeat { at: None, .. })),
5097 "a question at its ceiling offers nothing to add"
5098 );
5099
5100 let floored = Field::new(layout::FieldKind::Text, "guest", "Guest")
5101 .repeating(quasi_router::Repeat::answered(["ana"]).least(1));
5102 let screen = screen_of([Node::field(floored)]);
5103 let spots = crate::focus::spots(&screen, &Local::none());
5104 assert!(
5105 !spots
5106 .iter()
5107 .any(|spot| matches!(spot, Spot::Repeat { at: Some(_), .. })),
5108 "a question at its floor offers nothing to remove"
5109 );
5110 }
5111
5112 /// Removing a slot moves the answers after it up, buffers and all: the names
5113 /// are positional, so leaving them alone would submit a hole under the name the
5114 /// reader emptied.
5115 #[test]
5116 fn removing_a_slot_moves_the_answers_after_it_up() {
5117 let field = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
5118 .repeating(quasi_router::Repeat::answered(["300", "900", "3600"]));
5119 let mut view = View::new();
5120 view.set("reminder[1]", "1800");
5121
5122 view.remove_slot(&field, 0);
5123
5124 assert_eq!(view.standing(&field), 2);
5125 // The buffer moves with the answer it belongs to, and the slot that had
5126 // none falls back to what the description offered for its new place.
5127 assert_eq!(view.edit("reminder[0]"), Some("1800"));
5128 assert_eq!(view.edit("reminder[1]"), Some("3600"));
5129 assert_eq!(view.edit("reminder[2]"), None);
5130 }
5131
5132 /// The caret and the picture read one count. The two walks are separate, so a
5133 /// slot the reader added has to reach both.
5134 #[test]
5135 fn the_drawing_and_the_walk_count_the_same_slots() {
5136 let screen = reminders_form();
5137 let mut view = View::new();
5138 view.add_slot(&reminders());
5139 let nothing = crate::Hidden::none();
5140 let local = Local::of(&nothing, &view);
5141
5142 let stops = crate::focus::spots(&screen, &local).len();
5143 let described = crate::focus::spots(&screen, &Local::none()).len();
5144 // One box and one remove control more than the description described.
5145 assert_eq!(stops, described + 2);
5146
5147 let area = Rect::new(0, 0, 60, 40);
5148 let draw = |view: &View| {
5149 let mut buf = Buffer::empty(area);
5150 tui().screen(&screen, view, area, &mut buf);
5151 buf
5152 };
5153 let mut past = view.clone();
5154 past.focus_on(stops, stops + 1);
5155 let unlit = draw(&past);
5156 for at in 0..stops {
5157 let mut lit = view.clone();
5158 lit.focus_on(at, stops);
5159 assert_ne!(
5160 draw(&lit),
5161 unlit,
5162 "focusing {at} of {stops} changed nothing on the screen"
5163 );
5164 }
5165 }
5166
5167 /// A fragment landing elsewhere does not take the slots the reader added, and a
5168 /// question that has gone away does not leave its count behind for the next
5169 /// screen to inherit.
5170 #[test]
5171 fn a_slot_the_reader_added_survives_a_fragment() {
5172 let screen = reminders_form();
5173 let mut view = View::new();
5174 view.add_slot(&reminders());
5175 view.set("reminder[2]", "7200");
5176
5177 view.prune(&screen, &Frame::new(), &quasi_router::Chrome::new());
5178 assert_eq!(view.standing(&reminders()), 3);
5179 assert_eq!(view.edit("reminder[2]"), Some("7200"));
5180
5181 // A screen with no such question at all: the count goes with it.
5182 let gone = screen_of([Node::text("nothing here")]);
5183 view.prune(&gone, &Frame::new(), &quasi_router::Chrome::new());
5184 assert_eq!(view.standing(&reminders()), 2, "the description's count");
5185 }
5186
5187 /// A per-slot message is drawn against its own box, and the question's own
5188 /// error is about the set.
5189 #[test]
5190 fn a_slot_carries_its_own_error() {
5191 let mut field = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
5192 .repeating(quasi_router::Repeat::answered(["300", "-1"]).wrong(1, "Must be positive"));
5193 field.error = Some("At most eight reminders".into());
5194
5195 let screen = screen_of([Node::field(field)]);
5196 let area = Rect::new(0, 0, 60, 30);
5197 let mut buf = Buffer::empty(area);
5198 tui().screen(&screen, &View::new(), area, &mut buf);
5199 let painted = rows(&buf).join("\n");
5200
5201 assert!(painted.contains("Must be positive"), "{painted}");
5202 assert!(painted.contains("At most eight reminders"), "{painted}");
5203 assert!(painted.contains("[ Add ]"), "{painted}");
5204 assert!(painted.contains("Remove"), "{painted}");
5205 }
5206
5207 #[test]
5208 fn an_act_hint_is_the_muted_row_under_the_control() {
5209 // `ca7b5200`. A terminal has no pointer, so the sentence is a row rather
5210 // than a hover -- the same shape `piece::field` gives a field's note, so the
5211 // two read alike wherever they land on a screen.
5212 let drawn = drawn(
5213 &Node::Act(
5214 Act::new("Verify library integrity", Action::post("/verify"))
5215 .hint("The result appears in the status line."),
5216 ),
5217 60,
5218 3,
5219 );
5220
5221 assert!(drawn[0].contains("Verify library integrity"), "{drawn:?}");
5222 assert_eq!(
5223 drawn[1], "The result appears in the status line.",
5224 "{drawn:?}"
5225 );
5226 }
5227
5228 #[test]
5229 fn an_act_with_no_hint_takes_one_row() {
5230 // Additive: a control written before the member existed measures and draws
5231 // exactly as it did.
5232 let drawn = drawn(&Node::Act(Act::new("Save", Action::post("/save"))), 60, 3);
5233
5234 assert!(drawn[0].contains("Save"), "{drawn:?}");
5235 assert_eq!(drawn[1], "", "{drawn:?}");
5236 }
5237
5238 #[test]
5239 fn a_copied_value_is_handed_back_to_the_host() {
5240 // `c3e145e0`. `Step::Open`'s shape and for its reason: this crate owns no
5241 // I/O, so a clipboard is the host's exactly as opening a URL is.
5242 let mut runtime = Runtime::new(screen_of([Node::Act(
5243 Act::new("Copy key", Action::local()).copying("mnw_live_abc123"),
5244 )]));
5245 assert_eq!(
5246 runtime.key(Key::Enter),
5247 Step::Copy("mnw_live_abc123".to_string())
5248 );
5249 }
5250
5251 #[test]
5252 fn a_bound_key_copies_the_same_value_enter_does() {
5253 // The bound key and Enter are one press said two ways.
5254 let mut runtime = Runtime::new(screen_of([Node::Act(
5255 Act::new("Copy key", Action::local())
5256 .copying("mnw_live_abc123")
5257 .key("c"),
5258 )]));
5259 assert_eq!(
5260 runtime.key(Key::Char('c')),
5261 Step::Copy("mnw_live_abc123".to_string())
5262 );
5263 }
5264
5265 #[test]
5266 fn a_terminal_draws_a_shown_pictures_control_by_its_name() {
5267 // `db998898`. `Act::shows` is ignored here on purpose: a picture's alt text
5268 // is all a terminal has of it, and the control's label is already saying
5269 // the name. Drawing both would say it twice.
5270 use quasi_router::screen::Image;
5271 let with = Node::Act(
5272 Act::new("kick.wav", Action::get("/media/1")).showing(Image::new("/m/1.png", "kick.wav")),
5273 );
5274 let without = Node::Act(Act::new("kick.wav", Action::get("/media/1")));
5275 assert_eq!(drawn(&with, 40, 4), drawn(&without, 40, 4));
5276 assert!(drawn(&with, 40, 4).concat().contains("kick.wav"));
5277 }
5278
5279 /// The terminal's half.
5280 #[test]
5281 fn the_same_overlay_does_not_stack_on_itself() {
5282 let mut runtime = Runtime::new(screen_of([Node::text("under")]));
5283 let open = |runtime: &mut Runtime| {
5284 runtime.apply(
5285 &Request::get("/help"),
5286 Response {
5287 outcome: Outcome::Over(screen_of([Node::text("help")])),
5288 notice: None,
5289 address: None,
5290 invalidates: Vec::new(),
5291 },
5292 );
5293 };
5294
5295 for _ in 0..5 {
5296 open(&mut runtime);
5297 }
5298 assert!(runtime.overlaid());
5299
5300 // One Escape, not five. The second finds nothing to close, which is what
5301 // says the other four presses added no layers.
5302 runtime.key(Key::Escape);
5303 assert!(!runtime.overlaid());
5304 }
5305
5306 /// The guard is the top layer only, so a confirm over a palette still stacks
5307 /// and the palette's own identity comes back when the confirm is dismissed.
5308 #[test]
5309 fn a_different_overlay_still_stacks_and_unwinds_in_order() {
5310 let mut runtime = Runtime::new(screen_of([Node::text("under")]));
5311 let raise = |runtime: &mut Runtime, path: &str, label: &str| {
5312 runtime.apply(
5313 &Request::get(path),
5314 Response {
5315 outcome: Outcome::Over(screen_of([Node::text(label)])),
5316 notice: None,
5317 address: None,
5318 invalidates: Vec::new(),
5319 },
5320 );
5321 };
5322
5323 raise(&mut runtime, "/palette", "palette");
5324 raise(&mut runtime, "/confirm", "confirm");
5325 raise(&mut runtime, "/confirm", "confirm");
5326
5327 runtime.key(Key::Escape);
5328 assert!(runtime.overlaid(), "the palette is still up");
5329
5330 raise(&mut runtime, "/palette", "palette");
5331 runtime.key(Key::Escape);
5332 assert!(
5333 !runtime.overlaid(),
5334 "the palette refused to stack on itself"
5335 );
5336 }
5337
5338 /// An anchored menu layers like an overlay and takes a different box: the
5339 /// compact one, in the half the subject is not in. A terminal has no
5340 /// coordinates, so the half is the whole of the claim -- see `Laid::Anchored`.
5341 #[test]
5342 fn an_anchored_menu_is_laid_as_a_menu_rather_than_a_palette() {
5343 let screen = Screen::sidebar_content("Files")
5344 .with(Slot::new("browser", RegionKind::Pane).with(Node::text("rows")));
5345 let mut runtime = Runtime::new(screen);
5346
5347 runtime.apply(
5348 &Request::get("/menu"),
5349 Response {
5350 outcome: Outcome::Anchored {
5351 screen: screen_of([Node::text("menu")]),
5352 anchor: quasi_router::Anchor::Region("browser".into()),
5353 },
5354 notice: None,
5355 address: None,
5356 invalidates: Vec::new(),
5357 },
5358 );
5359
5360 assert!(runtime.overlaid());
5361 assert!(matches!(
5362 runtime.laid,
5363 Some(crate::runtime::Laid::Anchored { .. })
5364 ));
5365 // Dismissal is the overlay's, unchanged: it reveals what was under it and
5366 // touches no history.
5367 assert!(matches!(runtime.key(Key::Escape), Step::Idle));
5368 assert!(!runtime.overlaid());
5369 assert_eq!(runtime.laid, None);
5370 }
5371
5372 /// An anchor naming nothing on the screen it covers falls back to the overlay
5373 /// box. The menu still opens; the loss is the placement.
5374 #[test]
5375 fn an_anchor_that_names_nothing_falls_back_to_the_overlay() {
5376 let mut runtime = Runtime::new(screen_of([Node::text("rows")]));
5377
5378 runtime.apply(
5379 &Request::get("/menu"),
5380 Response {
5381 outcome: Outcome::Anchored {
5382 screen: screen_of([Node::text("menu")]),
5383 anchor: quasi_router::Anchor::Region("nowhere".into()),
5384 },
5385 notice: None,
5386 address: None,
5387 invalidates: Vec::new(),
5388 },
5389 );
5390
5391 assert!(runtime.overlaid());
5392 assert_eq!(runtime.laid, Some(crate::runtime::Laid::Over));
5393 }
5394
5395 /// The `900865dd` guard holds for the anchored member too: a binding asked
5396 /// every frame must not stack a menu per frame.
5397 #[test]
5398 fn an_anchored_menu_does_not_stack_on_the_same_request() {
5399 let mut runtime = Runtime::new(screen_of([Node::text("rows")]));
5400 let request = Request::get("/menu");
5401 let answer = || Response {
5402 outcome: Outcome::Anchored {
5403 screen: screen_of([Node::text("menu")]),
5404 anchor: quasi_router::Anchor::Selection,
5405 },
5406 notice: None,
5407 address: None,
5408 invalidates: Vec::new(),
5409 };
5410
5411 runtime.apply(&request, answer());
5412 runtime.apply(&request, answer());
5413
5414 // One Escape, not two.
5415 assert!(matches!(runtime.key(Key::Escape), Step::Idle));
5416 assert!(!runtime.overlaid());
5417 }
5418
5419 /// A terminal has no hover and no second surface for standing help, so this
5420 /// renderer drops a hint. Asserted rather than only documented: the
5421 /// alternatives -- appending it to the label, or borrowing the status line --
5422 /// both look like improvements until you see what they cost, and a test is
5423 /// what stops one being tried.
5424 #[test]
5425 fn a_hint_is_dropped_because_a_terminal_has_nowhere_to_put_one() {
5426 let plain = drawn(&Node::Token(Tag::badge("Blocked")), 40, 3);
5427 let hinted = drawn(
5428 &Node::Token(Tag::badge("Blocked").hinted("3 steps away")),
5429 40,
5430 3,
5431 );
5432
5433 assert_eq!(plain, hinted);
5434 assert!(!hinted.join("").contains("3 steps away"), "{hinted:?}");
5435 }
5436
5437 /// A regression, and the second place it happened. `focus.rs` pushes a stop
5438 /// per direction a pager can go; the drawing claimed **one** position for the
5439 /// whole line. So a list with both directions left every control below it
5440 /// drawing the caret one place early, exactly as
5441 /// `the_drawing_counts_the_same_table_rows_the_walk_stops_on` records for a
5442 /// table of tickable rows.
5443 #[test]
5444 fn the_drawing_counts_the_same_pager_stops_the_walk_stops_on() {
5445 let mut runtime = Runtime::new(
5446 Screen::sidebar_content("Feed").with(
5447 Slot::new("main", RegionKind::Pane)
5448 .with(
5449 Node::list([Row::new("First")]).and_more(
5450 Rest::page(20, 10)
5451 .of(80)
5452 .back(Action::get("/feed?page=2"))
5453 .forward(Action::get("/feed?page=4")),
5454 ),
5455 )
5456 .with(Node::Act(Act::new("Archive", Action::post("/a"))))
5457 .with(Node::Act(Act::new("Purge", Action::post("/p")))),
5458 ),
5459 );
5460
5461 // Prev, Next, and the two acts. A plain row takes no stop, and the readout
5462 // between the ends is not somewhere to go.
5463 assert_eq!(runtime.reaches().len(), 4);
5464
5465 // Two tabs is past both ends of the pager and onto the first act.
5466 runtime.key(Key::Tab);
5467 runtime.key(Key::Tab);
5468
5469 let area = Rect::new(0, 0, 40, 12);
5470 let mut buf = Buffer::empty(area);
5471 runtime.draw(&tui(), area, &mut buf);
5472 let lit: String = (0..area.height)
5473 .map(|y| marked(&buf, y, Modifier::REVERSED))
5474 .collect();
5475
5476 assert!(
5477 lit.contains("Archive"),
5478 "the caret's own control is lit: {lit:?}"
5479 );
5480 assert!(
5481 !lit.contains("Purge"),
5482 "and the one after it is not: {lit:?}"
5483 );
5484
5485 let Step::Call(request) = runtime.key(Key::Enter) else {
5486 panic!("the focused control calls its route");
5487 };
5488 assert_eq!(request.path, "/a");
5489 }
5490
5491 /// A terminal draws the offered pages on the line it already spends, and every
5492 /// one of them but the current is somewhere to go.
5493 #[test]
5494 fn a_pager_that_offers_pages_puts_each_of_them_on_the_one_line() {
5495 let mut runtime = Runtime::new(
5496 Screen::sidebar_content("Feed").with(
5497 Slot::new("main", RegionKind::Pane).with(
5498 Node::list([Row::new("First")]).and_more(
5499 Rest::page(20, 10)
5500 .of(80)
5501 .back(Action::get("/feed?page=2"))
5502 .forward(Action::get("/feed?page=4"))
5503 .jumping(Jump::new(2, Action::get("/feed?page=2")))
5504 .jumping(Jump::new(3, Action::get("/feed?page=3")).here())
5505 .jumping(Jump::new(4, Action::get("/feed?page=4"))),
5506 ),
5507 ),
5508 ),
5509 );
5510
5511 // Prev, page 2, page 4, Next. Page 3 is the one being read, so it is a
5512 // readout rather than a control -- and the position readout is gone,
5513 // because the strip already says which page of how many.
5514 assert_eq!(runtime.reaches().len(), 4);
5515
5516 let area = Rect::new(0, 0, 40, 8);
5517 let mut buf = Buffer::empty(area);
5518 runtime.draw(&tui(), area, &mut buf);
5519 let drawn = rows(&buf).join(" ");
5520 assert!(drawn.contains("Prev 2 3 4 Next"), "{drawn:?}");
5521 assert!(!drawn.contains("3 / 8"), "{drawn:?}");
5522
5523 // Still one line, whatever the strip carries.
5524 let mut tall = Buffer::empty(Rect::new(0, 0, 40, 8));
5525 Runtime::new(
5526 Screen::sidebar_content("Feed").with(
5527 Slot::new("main", RegionKind::Pane)
5528 .with(Node::list([Row::new("First")]).and_more(Rest::page(20, 10).of(80))),
5529 ),
5530 )
5531 .draw(&tui(), Rect::new(0, 0, 40, 8), &mut tall);
5532 let plain = rows(&tall);
5533 let paged = rows(&buf);
5534 assert_eq!(
5535 plain.iter().filter(|l| !l.trim().is_empty()).count(),
5536 paged.iter().filter(|l| !l.trim().is_empty()).count(),
5537 "the strip costs no extra line: {plain:?} vs {paged:?}"
5538 );
5539
5540 // The third stop is page 4, and pressing it goes there.
5541 runtime.key(Key::Tab);
5542 runtime.key(Key::Tab);
5543 let Step::Call(request) = runtime.key(Key::Enter) else {
5544 panic!("a page is somewhere to go");
5545 };
5546 assert_eq!(request.path, "/feed?page=4");
5547 }
5548