Skip to main content

max / quasi

49.1 KB · 1356 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, Cells, Choice, Column, Field, Figure, Meter, Node, RegionKind, Row, Screen, Slot,
12 Tag,
13 };
14 use ratatui::buffer::Buffer;
15 use ratatui::layout::Rect;
16 use ratatui::style::Modifier;
17
18 use crate::{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 #[test]
107 fn a_row_draws_its_run_in_the_order_the_description_says_it() {
108 // The property the containment migration bought this renderer: a terminal
109 // reads the run rather than knowing the old fixed member sequence.
110 let out = drawn(
111 &Node::list([Row::new("Ship it")
112 .token(Tag::badge("beta"))
113 .meta("2 files")]),
114 40,
115 3,
116 );
117 let line = &out[0];
118 assert!(line.contains("Ship it"), "{out:?}");
119 assert!(
120 line.find("beta").unwrap() < line.find("2 files").unwrap(),
121 "{out:?}"
122 );
123 }
124
125 #[test]
126 fn a_selectable_row_draws_its_tick_and_a_current_row_its_marker() {
127 let ticked = drawn(&Node::list([Row::new("One").selectable(true)]), 20, 2);
128 assert!(ticked[0].starts_with("[x]"), "{ticked:?}");
129
130 let untickable = drawn(&Node::list([Row::new("One")]), 20, 2);
131 assert!(!untickable[0].contains("[x]"), "{untickable:?}");
132 }
133
134 #[test]
135 fn a_meter_is_a_bar_and_a_reading() {
136 let out = drawn(&Node::Meter(Meter::new(3, 6).label("subtasks")), 40, 2);
137 assert!(out[0].starts_with("#####-----"), "{out:?}");
138 assert!(out[0].contains("3/6 subtasks"), "{out:?}");
139 }
140
141 #[test]
142 fn a_figure_puts_the_number_over_what_it_counts() {
143 let out = drawn(&Node::Figure(Figure::new("17", "Current streak")), 30, 3);
144 assert_eq!(out[0], "17");
145 assert_eq!(out[1], "Current streak");
146 }
147
148 #[test]
149 fn a_strip_of_figures_stacks_rather_than_sitting_in_a_row() {
150 // The renderer deciding, which the node leaves it free to do: the strip
151 // says these belong together and not how wide they are.
152 let out = drawn(
153 &Node::stats([Figure::new("17", "Streak"), Figure::new("4", "Today")]),
154 30,
155 5,
156 );
157 assert_eq!(out[0], "17");
158 assert_eq!(out[2], "4");
159 }
160
161 #[test]
162 fn markdown_keeps_its_emphasis_and_loses_its_syntax() {
163 // The node carries source so every renderer can answer it its own way, and
164 // a terminal's own way is the run's marks on the cell: `**ship it**` is the
165 // words in bold, not the words with the asterisks still on them and not the
166 // words with the emphasis thrown away.
167 let buf = buffer(&Node::rich("**ship it** now"), 40, 2);
168 let out = rows(&buf);
169 assert_eq!(out[0], "ship it now");
170 assert_eq!(marked(&buf, 0, Modifier::BOLD), "ship it");
171 }
172
173 #[test]
174 fn each_inline_mark_reaches_the_cell_that_has_it() {
175 let buf = buffer(&Node::rich("*lean* and ~~gone~~"), 40, 2);
176 assert_eq!(rows(&buf)[0], "lean and gone");
177 assert_eq!(marked(&buf, 0, Modifier::ITALIC), "lean");
178 assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "gone");
179 }
180
181 #[test]
182 fn a_code_span_is_set_into_the_page_rather_than_marked() {
183 // Every cell is monospace, so the one thing a webview says with a typeface
184 // is the one mark a terminal cannot repeat. It takes the sunken surface
185 // instead. Asserted as a difference and not as a colour: which colour is
186 // the theme's answer, that there is one is this renderer's.
187 let buf = buffer(&Node::rich("run `cargo build` first"), 40, 2);
188 assert_eq!(rows(&buf)[0], "run cargo build first");
189 let prose = buf[(0, 0)].bg;
190 let code = buf[(4, 0)].bg;
191 assert_ne!(code, prose, "a code span should not sit on the page");
192 assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should");
193 }
194
195 #[test]
196 fn a_heading_inside_a_rich_node_is_drawn_heavier_than_the_prose_under_it() {
197 // The gap this closed: `render_plain` handed over a heading's text at the
198 // weight of everything around it, so a rich node's structure was gone by
199 // the time a cell saw it. Weight is a thing a cell has.
200 let buf = buffer(&Node::rich("# Title\n\nBody."), 40, 4);
201 let out = rows(&buf);
202 assert_eq!(out[0], "Title");
203 assert_eq!(out[2], "Body.");
204 assert_eq!(marked(&buf, 0, Modifier::BOLD), "Title");
205 assert_eq!(marked(&buf, 2, Modifier::BOLD), "");
206 }
207
208 #[test]
209 fn a_deep_heading_reads_as_the_shallowest_a_terminal_can_tell_apart() {
210 // Six markdown levels onto the three `layout::Heading` has. A cell has one
211 // size and only so much colour, so `###` and `######` land together rather
212 // than inventing distinctions nothing can draw.
213 let third = buffer(&Node::rich("### Third"), 40, 2);
214 let sixth = buffer(&Node::rich("###### Sixth"), 40, 2);
215 assert_eq!(third[(0, 0)].fg, sixth[(0, 0)].fg);
216 // And not the same as the prose it sits above, or the level bought nothing.
217 let prose = buffer(&Node::rich("Third"), 40, 2);
218 assert_ne!(third[(0, 0)].fg, prose[(0, 0)].fg);
219 }
220
221 #[test]
222 fn a_list_gets_the_bullet_the_description_refused_to_carry() {
223 // docengine says "this run is an item" and stops there, because what a
224 // bullet looks like is the renderer's answer. This is a terminal's.
225 let out = drawn(&Node::rich("- one\n- two"), 40, 4);
226 assert_eq!(out[0], "- one");
227 assert_eq!(out[1], "- two");
228 }
229
230 #[test]
231 fn a_quote_is_marked_in_the_margin_and_only_on_its_first_line() {
232 // A marker belongs at the head of a line and nowhere else. A run knows its
233 // block but not its position, so the marker is placed off the break the
234 // previous run ended with.
235 let out = drawn(&Node::rich("> quoted\n\nafter"), 40, 4);
236 assert_eq!(out[0], "> quoted");
237 assert_eq!(out[2], "after");
238 }
239
240 #[test]
241 fn emphasis_inside_a_heading_is_added_to_its_weight_rather_than_swapped_for_it() {
242 // The order a stylesheet uses: the block decides the ground and the marks
243 // go on top. A struck word in a heading is struck AND a heading.
244 let buf = buffer(&Node::rich("## Ship ~~later~~"), 40, 2);
245 assert_eq!(rows(&buf)[0], "Ship later");
246 assert_eq!(marked(&buf, 0, Modifier::BOLD), "Ship later");
247 assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "later");
248 }
249
250 #[test]
251 fn a_rich_block_keeps_the_breaks_the_author_wrote() {
252 // The reason a rich node cannot go through `draw_line`: that one wraps a
253 // run that is one line by construction, and two paragraphs run together
254 // read as one sentence that does not parse.
255 let out = drawn(&Node::rich("one\n\ntwo"), 40, 4);
256 assert_eq!(out[0], "one");
257 assert_eq!(out[1], "");
258 assert_eq!(out[2], "two");
259 }
260
261 #[test]
262 fn a_rich_node_inside_a_row_keeps_its_emphasis_too() {
263 // The other path into the same runs. A row's parts are one line of spans,
264 // so this goes through `inline_spans` rather than the block wrap, and the
265 // marks have to survive both.
266 let buf = buffer(
267 &Node::list([Row::new("Ship it").part(layout::RowPart::Meta, Node::rich("**now**"))]),
268 40,
269 3,
270 );
271 // Two spaces in the run, one on the row: `draw_line` breaks on whitespace,
272 // so the gap between two parts is a separator and not a measure.
273 assert_eq!(rows(&buf)[0], "Ship it now");
274 assert_eq!(marked(&buf, 0, Modifier::BOLD), "now");
275 }
276
277 #[test]
278 fn a_rich_block_wraps_without_losing_which_words_were_marked() {
279 // The wrap breaks a run across rows, so the marks have to travel with the
280 // words rather than with the run they arrived in.
281 let buf = buffer(&Node::rich("plain **one two three** plain"), 12, 4);
282 let out = rows(&buf);
283 assert_eq!(out[0], "plain one");
284 assert_eq!(out[1], "two three");
285 assert_eq!(out[2], "plain");
286 assert_eq!(marked(&buf, 0, Modifier::BOLD), "one");
287 assert_eq!(marked(&buf, 1, Modifier::BOLD), "two three");
288 }
289
290 #[test]
291 fn a_hidden_field_draws_nothing_at_all() {
292 let field = Field::new(layout::FieldKind::Hidden, "token", "Token");
293 let out = drawn(&Node::Field(Box::new(field)), 30, 3);
294 assert!(out.iter().all(String::is_empty), "{out:?}");
295 }
296
297 #[test]
298 fn a_secret_field_has_nothing_to_draw_and_that_is_a_finding() {
299 // `Field::value` drops what it is handed when the kind is `Secret`, on
300 // purpose. A webview never noticed, because the browser owns the contents
301 // of an `input` and redraws them itself. A terminal owns nothing, so what
302 // the user typed lives in the runtime's buffer, and this is the first node
303 // whose drawing is not a function of the description alone.
304 let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2");
305 assert_eq!(field.value, None);
306
307 let out = drawn(&Node::Field(Box::new(field)), 30, 3);
308 assert_eq!(out[0], "Password");
309 assert!(!out[1].contains("hunter2"), "{out:?}");
310 }
311
312 #[test]
313 fn a_choice_field_marks_the_chosen_option() {
314 let field = Field::select(
315 "priority",
316 "Priority",
317 vec![Choice::plain("high"), Choice::plain("low")],
318 )
319 .value("low");
320 let out = drawn(&Node::Field(Box::new(field)), 30, 4);
321 assert_eq!(out[1], "( ) high");
322 assert_eq!(out[2], "(*) low");
323 }
324
325 #[test]
326 fn an_act_draws_its_key_because_the_description_carries_one() {
327 // `Act::key` is the one place the vocabulary already anticipated a
328 // terminal, and this is the renderer that finally reads it.
329 let act = Act::new("Delete", Action::post("/tasks/1/delete")).key("d");
330 let out = drawn(&Node::Act(act), 30, 2);
331 assert!(out[0].contains("Delete"), "{out:?}");
332 assert!(out[0].contains("(d)"), "{out:?}");
333 }
334
335 #[test]
336 fn a_table_narrows_by_dropping_the_columns_that_said_they_could_go() {
337 let table = Node::Table {
338 columns: vec![
339 Column::new("Name").priority(layout::Priority::Essential),
340 Column::new("Added").priority(layout::Priority::Optional),
341 ],
342 rows: vec![Cells::new(["kick.wav", "2026-08-12"])],
343 };
344
345 let wide = drawn(&table, 60, 3);
346 assert!(wide[0].contains("Added"), "{wide:?}");
347
348 let narrow = drawn(&table, 14, 3);
349 assert!(!narrow[0].contains("Added"), "{narrow:?}");
350 }
351
352 #[test]
353 fn a_notice_belongs_to_the_screen_and_lands_above_every_region() {
354 let screen = Screen::sidebar_content("Tasks")
355 .saying(Node::Notice {
356 kind: layout::Notice::Banner,
357 tone: layout::Tone::Success,
358 text: "Saved".into(),
359 })
360 .with(Slot::new("main", RegionKind::Pane).with(Node::section("Today")));
361
362 let out = shown(&screen, 40, 6);
363 assert_eq!(out[0], "Saved");
364 assert!(out.iter().any(|row| row.contains("Today")), "{out:?}");
365 }
366
367 #[test]
368 fn a_pending_region_says_so_in_words() {
369 let slot = Slot::new("detail", RegionKind::Pane)
370 .with(Node::text("Ready"))
371 .pending();
372 let out = drawn(&Node::Region(slot), 30, 3);
373 assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}");
374 assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}");
375 }
376
377 // The interaction runtime. Everything below is the half the browser was
378 // supplying: focus order, what is typed, where a key goes, what comes back.
379
380 use crate::focus::{Reach, Spot};
381 use crate::{Key, Runtime, Step};
382 use quasi_router::{Address, Chrome, Message, Method, Outcome, Request, Response, Rest};
383
384 /// A screen with one region holding these nodes.
385 fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen {
386 Screen::sidebar_content("Test").with(
387 nodes
388 .into_iter()
389 .fold(Slot::new("main", RegionKind::Pane), Slot::with),
390 )
391 }
392
393 /// The path a step is calling, for a step that calls one.
394 fn calling(step: &Step) -> Option<&str> {
395 match step {
396 Step::Call(request) => Some(request.path.as_str()),
397 _ => None,
398 }
399 }
400
401 #[test]
402 fn the_focus_walk_and_the_drawing_count_the_same_things() {
403 // The one invariant holding the two walks together. `focus.rs` decides how
404 // many reachable things a screen has and `node.rs` counts them as it draws,
405 // and if they ever disagree the caret lights a different control from the
406 // one Enter would call. Asserted over a screen carrying one of everything
407 // that can be reached.
408 let screen = screen_of([
409 Node::Act(Act::new("Save", Action::post("/save"))),
410 Node::Act(Act::new("Gone", Action::post("/gone")).disabled()),
411 Node::Link {
412 text: "Docs".into(),
413 action: Action::get("/docs"),
414 },
415 Node::field(Field::new(layout::FieldKind::Text, "name", "Name")),
416 Node::field(Field::new(layout::FieldKind::Hidden, "csrf", "")),
417 Node::Form {
418 action: Action::post("/new"),
419 submit: "Create".into(),
420 fields: vec![
421 Field::new(layout::FieldKind::Text, "title", "Title"),
422 Field::new(layout::FieldKind::Secret, "password", "Password"),
423 ],
424 },
425 Node::list([
426 Row::new("Open me").activate(Action::get("/one")),
427 Row::new("Just words"),
428 ])
429 .and_more(Rest::more(Action::get("/more"))),
430 Node::Select {
431 kind: layout::Selector::Tabs,
432 options: vec![
433 (Choice::plain("a"), Some(Action::get("/a"))),
434 (Choice::plain("b"), None),
435 ],
436 chosen: Some("a".into()),
437 action: None,
438 },
439 Node::Table {
440 columns: vec![Column::new("Name")],
441 rows: vec![Cells::new(["one"]).activate(Action::get("/row"))],
442 },
443 ]);
444
445 let expected = crate::focus::spots(&screen).len();
446
447 // The count the drawing keeps is private, so it is read through the only
448 // thing it drives: focusing the nth reachable thing has to change the
449 // picture. The baseline is focus one past the end, where nothing is lit.
450 //
451 // What this catches is the walks drifting apart. If the drawing counted
452 // fewer things than `spots` records, the last indices would light nothing
453 // and come back identical to the baseline; if it counted them in another
454 // order, the caret would still move but a later test would find it on the
455 // wrong control. This is the cheap half, and it is the half that breaks
456 // silently.
457 let area = Rect::new(0, 0, 60, 40);
458 let draw = |view: &View| {
459 let mut buf = Buffer::empty(area);
460 tui().screen(&screen, view, area, &mut buf);
461 buf
462 };
463
464 let mut past = View::new();
465 past.focus_on(expected, expected + 1);
466 let unlit = draw(&past);
467
468 for at in 0..expected {
469 let mut view = View::new();
470 view.focus_on(at, expected);
471 assert_ne!(
472 draw(&view),
473 unlit,
474 "focusing {at} of {expected} changed nothing on the screen"
475 );
476 }
477 }
478
479 #[test]
480 fn a_secret_field_draws_what_was_typed_and_the_description_never_carries_it() {
481 // `39057019`. The description refuses to hold a password, so the dots can
482 // only come from the view, and this is the node that would be undrawable
483 // without the second argument.
484 let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2");
485 assert_eq!(field.value, None, "a secret refuses a described value");
486
487 let node = Node::field(field);
488 let area = Rect::new(0, 0, 30, 4);
489
490 let mut buf = Buffer::empty(area);
491 tui().node(&node, &View::new(), area, &mut buf);
492 assert!(
493 !rows(&buf).iter().any(|row| row.contains('*')),
494 "nothing typed yet"
495 );
496
497 let mut view = View::new();
498 view.set("password", "hunter2");
499 let mut buf = Buffer::empty(area);
500 tui().node(&node, &view, area, &mut buf);
501 assert!(
502 rows(&buf).iter().any(|row| row.contains("*******")),
503 "{:?}",
504 rows(&buf)
505 );
506 }
507
508 #[test]
509 fn tab_walks_the_screen_and_enter_calls_what_it_lands_on() {
510 let mut runtime = Runtime::new(screen_of([
511 Node::Act(Act::new("First", Action::post("/first"))),
512 Node::Act(Act::new("Second", Action::post("/second"))),
513 ]));
514
515 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
516 assert_eq!(runtime.key(Key::Tab), Step::Idle);
517 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/second"));
518 // Wrapping, because a dead stop at the end reads as a broken key.
519 runtime.key(Key::Tab);
520 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
521 }
522
523 #[test]
524 fn a_disabled_control_is_drawn_and_never_landed_on() {
525 let mut runtime = Runtime::new(screen_of([
526 Node::Act(Act::new("Gone", Action::post("/gone")).disabled()),
527 Node::Act(Act::new("Live", Action::post("/live"))),
528 ]));
529 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/live"));
530 }
531
532 #[test]
533 fn the_runtime_starts_on_the_first_reach_and_the_description_gets_no_say() {
534 // The guarantee that replaced `layout::State::Focus`. A description used to
535 // be able to claim the starting control; focus is this renderer's now, and
536 // the rule is the plain one: first thing you can reach.
537 let mut runtime = Runtime::new(screen_of([
538 Node::Act(Act::new("First", Action::post("/first"))),
539 Node::Act(Act::new("Second", Action::post("/second"))),
540 ]));
541 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
542 }
543
544 #[test]
545 fn a_key_the_description_named_reaches_its_control_from_anywhere() {
546 // `Act::key` is the one place the vocabulary already anticipated a
547 // terminal, and this is the renderer that binds it.
548 let mut runtime = Runtime::new(screen_of([
549 Node::Act(Act::new("First", Action::post("/first"))),
550 Node::Act(Act::new("New", Action::get("/new")).key("n")),
551 ]));
552 assert_eq!(calling(&runtime.key(Key::Char('n'))), Some("/new"));
553 // A key nothing claimed does nothing rather than something surprising.
554 assert_eq!(runtime.key(Key::Char('z')), Step::Idle);
555 }
556
557 #[test]
558 fn a_control_that_asks_first_is_not_called_until_it_is_answered() {
559 let mut runtime = Runtime::new(screen_of([Node::Act(
560 Act::new("Delete", Action::post("/delete")).confirm("Delete this?"),
561 )]));
562
563 assert_eq!(
564 runtime.key(Key::Enter),
565 Step::Ask("Delete this?".to_string())
566 );
567 assert!(runtime.asking());
568 assert_eq!(runtime.key(Key::Char('n')), Step::Idle);
569
570 assert!(matches!(runtime.key(Key::Enter), Step::Ask(_)));
571 assert_eq!(calling(&runtime.key(Key::Char('y'))), Some("/delete"));
572 }
573
574 #[test]
575 fn typing_fills_a_box_and_a_form_submits_what_is_in_it() {
576 let mut runtime = Runtime::new(screen_of([Node::Form {
577 action: Action::post("/new"),
578 submit: "Create".into(),
579 fields: vec![
580 Field::new(layout::FieldKind::Text, "title", "Title"),
581 Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent"),
582 ],
583 }]));
584
585 assert!(runtime.editing());
586 for ch in "Ship".chars() {
587 runtime.key(Key::Char(ch));
588 }
589 runtime.key(Key::Backspace);
590
591 // Onto the checkbox, which takes any key as a flip rather than as a
592 // character, then onto the submit.
593 runtime.key(Key::Tab);
594 runtime.key(Key::Char(' '));
595 runtime.key(Key::Tab);
596
597 let Step::Call(request) = runtime.key(Key::Enter) else {
598 panic!("the submit calls its route");
599 };
600 assert_eq!(request.path, "/new");
601 assert_eq!(request.method, Method::Post);
602 assert_eq!(request.payload.get("title"), Some("Shi"));
603 assert_eq!(request.payload.get("urgent"), Some(Node::SELECTED));
604 }
605
606 #[test]
607 fn an_unticked_box_sends_nothing_the_way_a_browser_sends_nothing() {
608 let mut runtime = Runtime::new(screen_of([Node::Form {
609 action: Action::post("/new"),
610 submit: "Create".into(),
611 fields: vec![Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent")],
612 }]));
613 runtime.key(Key::Tab);
614 let Step::Call(request) = runtime.key(Key::Enter) else {
615 panic!("the submit calls its route");
616 };
617 assert!(!request.payload.contains("urgent"), "{:?}", request.payload);
618 }
619
620 #[test]
621 fn a_field_that_writes_as_it_changes_writes_on_the_keystroke() {
622 // `Field::changes` says the change is the write, and a terminal has no
623 // `input` event to debounce, so every keystroke is one call. That is the
624 // description read literally, and the cost of reading it literally is
625 // filed rather than papered over with a delay this renderer invented.
626 let mut runtime = Runtime::new(screen_of([Node::field(
627 Field::new(layout::FieldKind::Text, "query", "Search").changes(Action::post("/search")),
628 )]));
629 let Step::Call(request) = runtime.key(Key::Char('a')) else {
630 panic!("a change writes");
631 };
632 assert_eq!(request.path, "/search");
633 assert_eq!(request.payload.get("query"), Some("a"));
634 }
635
636 #[test]
637 fn a_screen_is_a_place_and_a_write_is_not() {
638 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
639
640 // A read that answered a screen is somewhere to come back to.
641 runtime.apply(
642 &Request::get("/two"),
643 Response {
644 outcome: Outcome::Screen(screen_of([Node::text("second")])),
645 notice: None,
646 address: None,
647 invalidates: Vec::new(),
648 },
649 );
650 runtime.apply(
651 &Request::get("/three"),
652 Response {
653 outcome: Outcome::Screen(screen_of([Node::text("third")])),
654 notice: None,
655 address: None,
656 invalidates: Vec::new(),
657 },
658 );
659
660 assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two"));
661
662 // A write is not a place, so it leaves nothing behind to go back to.
663 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
664 runtime.apply(
665 &Request::post("/save"),
666 Response {
667 outcome: Outcome::Screen(screen_of([Node::text("saved")])),
668 notice: None,
669 address: None,
670 invalidates: Vec::new(),
671 },
672 );
673 assert_eq!(runtime.key(Key::Escape), Step::Idle);
674 }
675
676 #[test]
677 fn a_response_can_say_it_is_not_a_place_when_the_derivation_would_say_it_is() {
678 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
679 runtime.apply(
680 &Request::get("/transient"),
681 Response {
682 outcome: Outcome::Screen(screen_of([Node::text("transient")])),
683 notice: None,
684 address: Some(Address::Unchanged),
685 invalidates: Vec::new(),
686 },
687 );
688 assert_eq!(runtime.key(Key::Escape), Step::Idle);
689 }
690
691 #[test]
692 fn a_fragment_replaces_one_region_and_keeps_the_rest_of_the_screen() {
693 let screen = Screen::sidebar_content("Test")
694 .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("kept")))
695 .with(Slot::new("main", RegionKind::Pane).with(Node::text("old")));
696 let mut runtime = Runtime::new(screen);
697
698 let follow = runtime.apply(
699 &Request::post("/change"),
700 Response {
701 outcome: Outcome::Fragment {
702 region: "main".into(),
703 node: Node::text("new"),
704 },
705 notice: None,
706 address: None,
707 invalidates: Vec::new(),
708 },
709 );
710 assert!(follow.is_none());
711
712 let out = shown(runtime.screen(), 40, 8);
713 assert!(out.iter().any(|row| row.contains("kept")), "{out:?}");
714 assert!(out.iter().any(|row| row.contains("new")), "{out:?}");
715 assert!(!out.iter().any(|row| row.contains("old")), "{out:?}");
716 }
717
718 #[test]
719 fn a_fragment_naming_a_region_that_is_not_there_says_so() {
720 // `Screen::replace` answers false rather than panicking, and the caller is
721 // the one that can act on it. A terminal drawing nothing would look like a
722 // control that does nothing at all.
723 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
724 runtime.apply(
725 &Request::post("/change"),
726 Response {
727 outcome: Outcome::Fragment {
728 region: "nowhere".into(),
729 node: Node::text("new"),
730 },
731 notice: None,
732 address: None,
733 invalidates: Vec::new(),
734 },
735 );
736 let out = shown(runtime.screen(), 60, 8);
737 assert!(out.iter().any(|row| row.contains("nowhere")), "{out:?}");
738 }
739
740 #[test]
741 fn going_somewhere_else_is_a_second_request_the_host_performs() {
742 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
743 let follow = runtime.apply(
744 &Request::post("/delete"),
745 Response {
746 outcome: Outcome::Goto(Action::get("/list")),
747 notice: None,
748 address: None,
749 invalidates: Vec::new(),
750 },
751 );
752 assert_eq!(
753 follow.map(|request| request.path),
754 Some("/list".to_string())
755 );
756 }
757
758 #[test]
759 fn what_a_response_says_lands_on_the_screen_it_belongs_to() {
760 let mut runtime = Runtime::new(screen_of([Node::text("here")]));
761 runtime.apply(
762 &Request::post("/save"),
763 Response {
764 outcome: Outcome::Screen(screen_of([Node::text("after")])),
765 notice: Some(Message {
766 kind: layout::Notice::Banner,
767 tone: layout::Tone::Success,
768 text: "Saved".into(),
769 undo: None,
770 }),
771 address: None,
772 invalidates: Vec::new(),
773 },
774 );
775 let out = shown(runtime.screen(), 40, 6);
776 assert_eq!(out[0], "Saved");
777 }
778
779 #[test]
780 fn a_new_screen_forgets_what_was_typed_into_the_old_one() {
781 // Two screens can name the same field, and carrying a buffer across would
782 // put what was typed into one box into a different box that happens to
783 // share its name.
784 let mut runtime = Runtime::new(screen_of([Node::field(Field::new(
785 layout::FieldKind::Text,
786 "name",
787 "Name",
788 ))]));
789 runtime.key(Key::Char('a'));
790 assert_eq!(runtime.view().edit("name"), Some("a"));
791
792 runtime.apply(
793 &Request::get("/other"),
794 Response {
795 outcome: Outcome::Screen(screen_of([Node::field(Field::new(
796 layout::FieldKind::Text,
797 "name",
798 "Different question, same name",
799 ))])),
800 notice: None,
801 address: None,
802 invalidates: Vec::new(),
803 },
804 );
805 assert_eq!(runtime.view().edit("name"), None);
806 }
807
808 #[test]
809 fn a_scrolled_region_shows_the_rows_under_the_ones_it_started_with() {
810 let slot =
811 Slot::new("main", RegionKind::Pane).extend((0..10).map(|n| Node::text(format!("row {n}"))));
812 let node = Node::Region(slot);
813 let area = Rect::new(0, 0, 20, 4);
814
815 // Row 0 of the buffer is the region's own frame, so the contents start on
816 // row 1 and the window is what is left after the frame takes two.
817 let mut buf = Buffer::empty(area);
818 tui().node(&node, &View::new(), area, &mut buf);
819 assert!(rows(&buf)[1].contains("row 0"), "{:?}", rows(&buf));
820
821 let mut view = View::new();
822 view.scrolled_to("main", 3);
823 let mut buf = Buffer::empty(area);
824 tui().node(&node, &view, area, &mut buf);
825 let out = rows(&buf);
826 assert!(out[1].contains("row 3"), "{out:?}");
827 assert!(!out.iter().any(|row| row.contains("row 0")), "{out:?}");
828 }
829
830 #[test]
831 fn scrolling_stops_at_the_bottom_of_what_there_is() {
832 // The view holds a number and the drawing clamps it, because how far a
833 // region can scroll is how tall it is at the width it was handed, and the
834 // width is not known until it is drawn.
835 let slot =
836 Slot::new("main", RegionKind::Pane).extend((0..6).map(|n| Node::text(format!("row {n}"))));
837 let node = Node::Region(slot);
838 let area = Rect::new(0, 0, 20, 4);
839
840 // Six rows into a window of two, so the furthest down it can go is row 4
841 // at the top: an offset past the end shows the last screenful and not a
842 // blank region.
843 let mut view = View::new();
844 view.scrolled_to("main", 99);
845 let mut buf = Buffer::empty(area);
846 tui().node(&node, &view, area, &mut buf);
847 let out = rows(&buf);
848 assert!(out[1].contains("row 4"), "{out:?}");
849 assert!(out[2].contains("row 5"), "{out:?}");
850 }
851
852 #[test]
853 fn a_page_key_scrolls_the_region_the_caret_is_in() {
854 let screen = Screen::sidebar_content("Test")
855 .with(
856 Slot::new("side", RegionKind::Sidebar)
857 .with(Node::Act(Act::new("Side", Action::get("/side")))),
858 )
859 .with(
860 Slot::new("main", RegionKind::Pane)
861 .with(Node::Act(Act::new("Main", Action::get("/main")))),
862 );
863 let mut runtime = Runtime::new(screen);
864
865 runtime.key(Key::PageDown);
866 assert!(runtime.view().scroll("side") > 0);
867 assert_eq!(runtime.view().scroll("main"), 0);
868
869 runtime.key(Key::Tab);
870 runtime.key(Key::PageDown);
871 assert!(runtime.view().scroll("main") > 0);
872 }
873
874 #[test]
875 fn a_modal_keeps_the_keyboard_until_it_is_gone() {
876 // A dialog you can tab out of is not a dialog. The screen behind it is
877 // still drawn, because covering it costs rows and says nothing.
878 let screen = Screen::sidebar_content("Test")
879 .with(
880 Slot::new("main", RegionKind::Pane)
881 .with(Node::Act(Act::new("Behind", Action::post("/behind")))),
882 )
883 .with(Slot::new("ask", RegionKind::Modal).with(Node::Act(Act::new(
884 "In the dialog",
885 Action::post("/dialog"),
886 ))));
887 let mut runtime = Runtime::new(screen);
888
889 assert_eq!(runtime.reaches().len(), 1);
890 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog"));
891 runtime.key(Key::Tab);
892 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog"));
893 }
894
895 #[test]
896 fn an_external_address_is_handed_back_to_the_host() {
897 let mut runtime = Runtime::new(screen_of([Node::Act(Act::new(
898 "Docs",
899 Action::external("https://example.invalid/docs"),
900 ))]));
901 assert_eq!(
902 runtime.key(Key::Enter),
903 Step::Open("https://example.invalid/docs".to_string())
904 );
905 }
906
907 #[test]
908 fn a_tab_that_is_not_showing_holds_nothing_the_caret_can_reach() {
909 // The tabbed cut is one function, so the drawing and the focus walk cannot
910 // disagree about which region is on the screen.
911 let screen = Screen::list_detail("Test", true)
912 .with(
913 Slot::new("list", RegionKind::Pane)
914 .with(Node::Act(Act::new("Showing", Action::get("/showing")))),
915 )
916 .with(
917 Slot::new("detail", RegionKind::Pane)
918 .with(Node::Act(Act::new("Hidden", Action::get("/hidden")))),
919 );
920 let runtime = Runtime::new(screen);
921 assert_eq!(runtime.reaches().len(), 1);
922 assert!(matches!(
923 runtime.focused(),
924 Some(Spot::Act { ref action, .. }) if action.destination.as_str() == "/showing"
925 ));
926 }
927
928 #[test]
929 fn a_reach_says_which_region_it_is_in() {
930 let screen = Screen::sidebar_content("Test")
931 .with(
932 Slot::new("side", RegionKind::Sidebar)
933 .with(Node::Act(Act::new("Side", Action::get("/side")))),
934 )
935 .with(
936 Slot::new("main", RegionKind::Pane)
937 .with(Node::Act(Act::new("Main", Action::get("/main")))),
938 );
939 let reaches: Vec<String> = crate::focus::reaches(&screen)
940 .into_iter()
941 .map(|Reach { region, .. }| region)
942 .collect();
943 assert_eq!(reaches, vec!["side".to_string(), "main".to_string()]);
944 }
945
946 #[test]
947 fn a_pending_region_holds_nothing_the_caret_can_reach() {
948 let screen = Screen::sidebar_content("Test").with(
949 Slot::new("main", RegionKind::Pane)
950 .with(Node::Act(Act::new("Later", Action::get("/later"))))
951 .pending(),
952 );
953 assert!(crate::focus::spots(&screen).is_empty());
954 }
955
956 #[test]
957 fn a_row_and_the_controls_on_it_are_two_places_to_stand() {
958 let mut runtime = Runtime::new(screen_of([Node::list([Row::new("Open me")
959 .activate(Action::get("/open"))
960 .act(Act::new("Remove", Action::delete("/remove")))])]));
961
962 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/open"));
963 runtime.key(Key::Tab);
964 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/remove"));
965 }
966
967 #[test]
968 fn ticking_a_row_calls_what_the_description_says_ticking_calls() {
969 let mut runtime = Runtime::new(screen_of([Node::list([
970 Row::new("Buy milk").toggling(false, Action::post("/done/1"))
971 ])]));
972 assert_eq!(calling(&runtime.key(Key::Char(' '))), Some("/done/1"));
973 }
974
975 #[test]
976 fn a_field_refuses_the_keystroke_past_the_length_the_description_set() {
977 let mut field = Field::new(layout::FieldKind::Text, "code", "Code");
978 field.max_length = Some(3);
979 let mut runtime = Runtime::new(screen_of([Node::field(field)]));
980 for ch in "abcdef".chars() {
981 runtime.key(Key::Char(ch));
982 }
983 assert_eq!(runtime.view().edit("code"), Some("abc"));
984 }
985
986 #[test]
987 fn an_invalidated_slot_is_on_the_screen_beside_the_one_that_was_replaced() {
988 // The row the write was aimed at, and the count above it that also moved.
989 // On a terminal this is the whole of what an invalidation means: the next
990 // frame redraws everything, so the answer only has to reach the screen.
991 let mut runtime = Runtime::new(
992 Screen::sidebar_content("Tasks")
993 .with(Slot::new("row-7", RegionKind::Pane).with(Node::text("Open")))
994 .with(Slot::new("task-count", RegionKind::Band).with(Node::text("5 left"))),
995 );
996
997 runtime.apply(
998 &Request::post("/tasks/7/done"),
999 Response::fragment("row-7", Node::text("Done")).also("task-count", Node::text("4 left")),
1000 );
1001
1002 let out = shown(runtime.screen(), 40, 12);
1003 assert!(out.iter().any(|row| row.contains("Done")), "{out:?}");
1004 assert!(out.iter().any(|row| row.contains("4 left")), "{out:?}");
1005 assert!(!out.iter().any(|row| row.contains("5 left")), "{out:?}");
1006 }
1007
1008 #[test]
1009 fn an_invalidation_naming_no_region_is_reported_with_the_others() {
1010 // A description bug, and one banner naming every region that was missing
1011 // rather than a banner per region where only the last would survive.
1012 let mut runtime = Runtime::new(screen_of([Node::text("Open")]));
1013
1014 runtime.apply(
1015 &Request::post("/tasks/7/done"),
1016 Response::fragment("main", Node::text("Done"))
1017 .also("task-count", Node::text("4"))
1018 .also("sidebar-badge", Node::text("4")),
1019 );
1020
1021 let out = shown(runtime.screen(), 60, 12).join(" ");
1022 assert!(out.contains("task-count"), "{out}");
1023 assert!(out.contains("sidebar-badge"), "{out}");
1024 assert!(out.contains("are called"), "{out}");
1025 }
1026
1027 #[test]
1028 fn two_ticks_and_a_commit_control_send_both_values() {
1029 // The end of `5f2b8753`. The screen names the set, each row says what its
1030 // tick contributes, and the control says it acts over the set -- so a bulk
1031 // action works from one description with nothing gathering the ticks by
1032 // hand on either host.
1033 let mut runtime = Runtime::new(
1034 Screen::sidebar_content("Mail").selecting("chosen").with(
1035 Slot::new("main", RegionKind::Pane)
1036 .with(Node::list([
1037 Row::new("First").ticking("m-1", false),
1038 Row::new("Second").ticking("m-2", false),
1039 Row::new("Third").ticking("m-3", false),
1040 ]))
1041 .with(Node::Act(
1042 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
1043 )),
1044 ),
1045 );
1046
1047 // Walk to the first row and tick it, then the second.
1048 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
1049 runtime.key(Key::Tab);
1050 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
1051
1052 // Past the third row, onto the control.
1053 runtime.key(Key::Tab);
1054 runtime.key(Key::Tab);
1055 let Step::Call(request) = runtime.key(Key::Enter) else {
1056 panic!("the commit control calls its route");
1057 };
1058
1059 assert_eq!(request.path, "/mail/archive");
1060 assert_eq!(
1061 request
1062 .payload
1063 .get_all(quasi_router::Node::TICKED)
1064 .collect::<Vec<_>>(),
1065 ["m-1", "m-2"]
1066 );
1067 }
1068
1069 #[test]
1070 fn a_tick_is_staged_and_never_a_write() {
1071 // Wiki `explicit-commit-affordance`: a change that happens with no obvious
1072 // indication is confusing, so space stages and the commit control locks it
1073 // in. A row carrying `toggle` is the other case and still writes.
1074 let mut runtime = Runtime::new(
1075 Screen::sidebar_content("Mail").selecting("chosen").with(
1076 Slot::new("main", RegionKind::Pane)
1077 .with(Node::list([Row::new("First").ticking("m-1", false)])),
1078 ),
1079 );
1080
1081 assert!(
1082 matches!(runtime.key(Key::Char(' ')), Step::Idle),
1083 "a tick calls no route"
1084 );
1085 let out = held(&runtime, 40, 6);
1086 assert!(out.contains("[x]"), "{out}");
1087 }
1088
1089 #[test]
1090 fn a_tickable_row_that_names_nothing_still_binds_no_key() {
1091 // The dead affordance `5f2b8753` was filed for, one step earlier: a row
1092 // that can be ticked and says nothing about what the tick contributes has
1093 // nowhere to put it, so the key stays unbound rather than being bound to
1094 // nothing.
1095 let mut runtime = Runtime::new(Screen::sidebar_content("Mail").selecting("chosen").with(
1096 Slot::new("main", RegionKind::Pane).with(Node::list([Row::new("First").selectable(false)])),
1097 ));
1098
1099 assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
1100 let out = held(&runtime, 40, 6);
1101 assert!(!out.contains("[x]"), "{out}");
1102 }
1103
1104 #[test]
1105 fn a_described_tick_starts_the_set_off_and_the_user_can_take_it_back() {
1106 // A description can say a row arrives ticked, and after that the user's
1107 // ticks are the truth -- the same rule `39057019` settled for a field.
1108 let mut runtime = Runtime::new(
1109 Screen::sidebar_content("Mail").selecting("chosen").with(
1110 Slot::new("main", RegionKind::Pane)
1111 .with(Node::list([Row::new("First").ticking("m-1", true)]))
1112 .with(Node::Act(
1113 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
1114 )),
1115 ),
1116 );
1117
1118 let out = held(&runtime, 40, 6);
1119 assert!(out.contains("[x]"), "{out}");
1120
1121 // Untick it, then commit: the set is empty, not the description's.
1122 runtime.key(Key::Char(' '));
1123 runtime.key(Key::Tab);
1124 let Step::Call(request) = runtime.key(Key::Enter) else {
1125 panic!("the commit control calls its route");
1126 };
1127 assert_eq!(
1128 request.payload.get_all(quasi_router::Node::TICKED).count(),
1129 0
1130 );
1131 }
1132
1133 #[test]
1134 fn a_terminal_and_a_webview_agree_about_a_screens_proportions() {
1135 // The done-condition of `e0fd485e`: a screen described once, rendered by
1136 // two hosts, agreeing about its proportions. The assertion that did not
1137 // exist while each renderer held its own number.
1138 let screen = Screen::sidebar_content("Mail")
1139 .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("Folders")))
1140 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages")));
1141
1142 // A quarter of 100 columns is 25, and the webview writes the same quarter
1143 // into its grid. Read off the description rather than off either renderer.
1144 assert_eq!(screen.arrangement.share().as_percent(), 25);
1145 assert_eq!(screen.arrangement.share().of(100), 25);
1146
1147 // And the terminal honours it rather than a number of its own: "Folders"
1148 // fits in 25 columns and "Messages" starts after them.
1149 let out = shown(&screen, 100, 6);
1150 assert!(column_of(&out, "Folders") == Some(0), "{out:?}");
1151 assert!(
1152 column_of(&out, "Messages").is_some_and(|at| at >= 25),
1153 "the content should start after the sidebar's quarter: {out:?}"
1154 );
1155 }
1156
1157 #[test]
1158 fn a_narrower_share_moves_the_boundary_on_the_terminal_too() {
1159 let screen = Screen::new(
1160 "Mail",
1161 layout::Arrangement::sidebar_content().with_share(layout::Share::percent(10)),
1162 )
1163 .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("F")))
1164 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages")));
1165
1166 let out = shown(&screen, 100, 6);
1167 assert!(
1168 column_of(&out, "Messages").is_some_and(|at| (10..25).contains(&at)),
1169 "the boundary should follow the described share: {out:?}"
1170 );
1171 }
1172
1173 #[test]
1174 fn a_reading_measure_narrows_the_screen_and_centres_it() {
1175 // The terminal's answer to `Measure`, and the one with a reason outside
1176 // taste: past roughly 75 characters a line costs the reader the return
1177 // sweep.
1178 let wide = Screen::sidebar_content("Doc")
1179 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words")));
1180 let reading = wide.clone().measured(layout::Measure::Reading);
1181
1182 let full = shown(&wide, 120, 4);
1183 let narrowed = shown(&reading, 120, 4);
1184
1185 let at = |rows: &[String]| column_of(rows, "Words");
1186 assert!(
1187 at(&narrowed) > at(&full),
1188 "a narrowed screen is centred, so its content starts further in: \
1189 {full:?} then {narrowed:?}"
1190 );
1191 }
1192
1193 #[test]
1194 fn a_terminal_narrower_than_the_measure_is_left_alone() {
1195 // There is no measure to enforce when the window is already tighter than
1196 // it, and padding one would waste the only columns there are.
1197 let screen = Screen::sidebar_content("Doc")
1198 .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words")))
1199 .measured(layout::Measure::Reading);
1200
1201 let out = shown(&screen, 40, 4);
1202 // Drawn at all, and not squeezed into a centred column of a window that is
1203 // already narrower than the cap.
1204 assert!(
1205 column_of(&out, "Words").is_some_and(|at| at < 15),
1206 "{out:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn an_app_binding_fires_from_a_screen_that_knows_nothing_about_it() {
1212 // The whole claim chrome makes: the key works here, and here never
1213 // declared it.
1214 let mut runtime = Runtime::new(screen_of([Node::text("anywhere")]))
1215 .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette")));
1216 assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette"));
1217 }
1218
1219 #[test]
1220 fn an_app_binding_beats_a_control_that_wanted_the_same_key() {
1221 // Order, not preference: a screen that could capture the palette's key is
1222 // a screen on which the palette is not available everywhere.
1223 let mut runtime = Runtime::new(screen_of([Node::Act(
1224 Act::new("New", Action::get("/new")).key("k"),
1225 )]))
1226 .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette")));
1227 assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette"));
1228 }
1229
1230 #[test]
1231 fn a_field_keeps_the_printable_key_a_binding_wanted() {
1232 // A binding cannot make a letter untypeable. The field has the keyboard,
1233 // so the character goes in the box.
1234 let mut runtime = Runtime::new(screen_of([Node::Field(Box::new(Field::new(
1235 layout::FieldKind::Text,
1236 "q",
1237 "Query",
1238 )))]))
1239 .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette")));
1240 assert!(matches!(runtime.key(Key::Char('k')), Step::Idle));
1241 // Through the runtime's own view: what was typed lives there, not in the
1242 // description.
1243 let drawn = held(&runtime, 40, 6);
1244 assert!(drawn.contains('k'), "{drawn:?}");
1245 }
1246
1247 #[test]
1248 fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() {
1249 let mut runtime = Runtime::new(screen_of([Node::text("underneath")]));
1250 // Two navigations, because history holds where you *were*: the first
1251 // records where we are and the second pushes it behind us. Now Escape has
1252 // something to consume if an overlay is wrongly treated as a navigation.
1253 for path in ["/two", "/three"] {
1254 runtime.apply(
1255 &Request::get(path),
1256 Response {
1257 outcome: Outcome::Screen(screen_of([Node::text("a place")])),
1258 notice: None,
1259 address: None,
1260 invalidates: Vec::new(),
1261 },
1262 );
1263 }
1264
1265 runtime.apply(
1266 &Request::get("/palette"),
1267 Response {
1268 outcome: Outcome::Over(screen_of([Node::text("palette")])),
1269 notice: None,
1270 address: None,
1271 invalidates: Vec::new(),
1272 },
1273 );
1274 assert!(runtime.overlaid());
1275
1276 // Escape closes the overlay and calls nothing: history was never touched.
1277 assert!(matches!(runtime.key(Key::Escape), Step::Idle));
1278 assert!(!runtime.overlaid());
1279 // And the history the overlay did not consume is still there.
1280 assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two"));
1281 }
1282
1283 #[test]
1284 fn dismissing_an_overlay_leaves_the_screen_under_it_exactly_as_it_was() {
1285 // The regression a shared `View` would produce: the user's typing and the
1286 // control they had walked to would come back changed, or not at all.
1287 let mut runtime = Runtime::new(screen_of([
1288 Node::Field(Box::new(Field::new(
1289 layout::FieldKind::Text,
1290 "title",
1291 "Title",
1292 ))),
1293 Node::Act(Act::new("Save", Action::post("/save"))),
1294 ]));
1295 runtime.key(Key::Char('h'));
1296 runtime.key(Key::Char('i'));
1297 // Walk off the field, so focus is somewhere the overlay could disturb.
1298 runtime.key(Key::Tab);
1299
1300 runtime.apply(
1301 &Request::get("/palette"),
1302 Response {
1303 outcome: Outcome::Over(screen_of([Node::Field(Box::new(Field::new(
1304 layout::FieldKind::Text,
1305 "q",
1306 "Query",
1307 )))])),
1308 notice: None,
1309 address: None,
1310 invalidates: Vec::new(),
1311 },
1312 );
1313 // The overlay's own view: typing here must not reach the screen beneath.
1314 runtime.key(Key::Char('z'));
1315 runtime.key(Key::Escape);
1316
1317 // Focus came back where it was left: Enter calls Save rather than sitting
1318 // in the field.
1319 assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save"));
1320 let drawn = held(&runtime, 40, 8);
1321 assert!(
1322 drawn.contains("hi"),
1323 "the typing survived the overlay: {drawn:?}"
1324 );
1325 assert!(
1326 !drawn.contains('z'),
1327 "the overlay's typing stayed in the overlay: {drawn:?}"
1328 );
1329 }
1330
1331 #[test]
1332 fn a_navigation_takes_the_overlay_with_it() {
1333 // Arriving somewhere new with a palette still floating over it is the
1334 // state nobody asked for.
1335 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
1336 runtime.apply(
1337 &Request::get("/palette"),
1338 Response {
1339 outcome: Outcome::Over(screen_of([Node::text("palette")])),
1340 notice: None,
1341 address: None,
1342 invalidates: Vec::new(),
1343 },
1344 );
1345 runtime.apply(
1346 &Request::get("/two"),
1347 Response {
1348 outcome: Outcome::Screen(screen_of([Node::text("second")])),
1349 notice: None,
1350 address: None,
1351 invalidates: Vec::new(),
1352 },
1353 );
1354 assert!(!runtime.overlaid());
1355 }
1356