//! What a terminal draws from a description. //! //! Assertions are over the buffer's text rather than over styles, for the //! reason the webview's tests assert on classes rather than on colours: the //! colour is the theme's answer and changes with it, and what a renderer owes //! is that the words are there and in the right place. use makeover_layout as layout; use makeover_tui::{Fidelity, Theme}; use quasi_router::{ Act, Action, Candidate, Cell, Choice, Column, Consult, Field, Figure, Meter, Node, RegionKind, Row, Run, Screen, Slot, Tag, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Modifier; use crate::{Local, Tui, View}; /// A renderer in a shipped theme, at full colour. /// /// Through `Theme::from_theme` and a bundled theme file rather than a literal, /// because `makeover_tui::Theme` is `#[non_exhaustive]` and that is the only /// way to get one. A test that could build a partial theme by hand would be a /// test drawing in colours no theme ships. fn tui() -> Tui { let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads"); Tui::new( Theme::from_theme(&colours).expect("a shipped theme resolves"), Fidelity::TrueColor, ) } /// Everything a buffer holds, as one string per row. fn rows(buf: &Buffer) -> Vec { (0..buf.area.height) .map(|y| { (0..buf.area.width) .map(|x| buf[(x, y)].symbol()) .collect::() .trim_end() .to_string() }) .collect() } /// Draw one node into a buffer of this size. /// /// With an empty [`View`], which is what "the description and nothing else" /// looks like now that drawing takes two arguments: nothing typed, nothing /// focused yet, nothing scrolled. Every assertion in this file that predates /// the interaction runtime still reads the same picture through it. fn buffer(node: &Node, width: u16, height: u16) -> Buffer { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); tui().node(node, &View::new(), area, &mut buf); buf } /// Draw one node into a buffer of this size. fn drawn(node: &Node, width: u16, height: u16) -> Vec { rows(&buffer(node, width, height)) } /// The characters on row `y` whose cells carry `modifier`. /// /// The exception to the note at the top of this file, and a narrow one. A /// colour is the theme's answer, but bold is not: no theme decides which words /// in a paragraph are emphasised, so which cells carry it is this renderer's /// claim and the only way to assert it is to read it. fn marked(buf: &Buffer, y: u16, modifier: Modifier) -> String { (0..buf.area.width) .filter(|x| buf[(*x, y)].modifier.contains(modifier)) .map(|x| buf[(x, y)].symbol()) .collect() } /// Draw a whole screen. /// What a runtime draws, which is the screen *and* what the user has done to /// it. `shown` builds a fresh `View`, so it draws the description alone -- and /// a tick lives in the view, which is the whole of `5f2b8753`. fn held(runtime: &Runtime, width: u16, height: u16) -> String { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); rows(&buf).join(" ") } /// The column some text starts at, in whichever row holds it. /// /// A region's contents sit below its top border, so the row a word lands on is /// not the row the region starts on. Asserting against row 0 measures the /// border and not the content. fn column_of(rows: &[String], text: &str) -> Option { rows.iter().find_map(|row| row.find(text)) } fn shown(screen: &Screen, width: u16, height: u16) -> Vec { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); tui().screen(screen, &View::new(), area, &mut buf); rows(&buf) } /// A band whose members were said to share one row. fn run_of(fallback: layout::Fallback, members: &[(&str, layout::Priority)]) -> Screen { let mut row = Run::new(fallback); for (label, priority) in members { row = row.beside( Node::Act(Act::new(*label, Action::post(format!("/{label}")))), *priority, ); } Screen::sidebar_content("Test").with(Slot::new("bar", RegionKind::Band).across(row)) } /// The three toolbar controls, all essential. const THREE: [(&str, layout::Priority); 3] = [ ("Import", layout::Priority::Essential), ("Export", layout::Priority::Essential), ("Settings", layout::Priority::Essential), ]; /// One of every `Node` member, in declaration order. /// /// Kept as a function so more than one test can walk it, and it has to stay /// complete: `Node` is `#[non_exhaustive]`, so a member added upstream lands on /// a catch-all arm and compiles. This list plus the count below is what says a /// walk has learned the member rather than merely accepting it. fn one_of_everything() -> Vec { vec![ Node::page("Tasks"), Node::text("plain"), Node::rich("**bold** and `code`"), Node::Act(Act::new("Save", Action::post("/save"))), Node::Link { text: "Docs".to_owned(), action: Action::get("/docs"), }, Node::Figure(Figure::new("17", "Streak")), Node::since(std::time::SystemTime::UNIX_EPOCH), Node::until(std::time::SystemTime::UNIX_EPOCH), Node::age(std::time::SystemTime::UNIX_EPOCH), Node::Image(quasi_router::Image::new("/cover.png", "The library view")), Node::Token(Tag::badge("beta")), Node::banner(layout::Tone::Info, "Saved"), Node::empty("Nothing here yet"), Node::Field(Box::new(Field::new( layout::FieldKind::Text, "title", "Title", ))), Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/save"), submit: "Save".to_owned(), fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")], }, Node::list([Row::new("One")]), Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Name")], rows: vec![Row::cells([Cell::new("One")])], more: None, }, Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![quasi_router::Placed::new(540, 45, Row::new("Standup"))], focus: Some(540), }, Node::Meter(Meter::new(3, 6)), Node::stats([Figure::new("17", "Streak")]), Node::Region(Slot::new("nested", RegionKind::Pane)), ] } #[test] fn every_described_node_draws_without_panicking() { // This crate walks `Node` twice, so the exhaustiveness it claims is two // claims. Drawing is the first of them: one of everything, painted. for node in one_of_everything() { let _ = drawn(&node, 60, 12); } } #[test] fn every_described_node_is_measured_without_panicking() { // The second walk, and the one that has no visible symptom when it drifts: // a member `height` does not know is measured as something else and the // rows below it land in the wrong place. let tui = tui(); for node in one_of_everything() { let _ = tui.height(&node, 60); } } #[test] fn the_exhaustiveness_list_holds_one_of_every_member() { // A count rather than a comment. `Node` cannot be iterated, so nothing but // this stops the list above going stale while both walks keep compiling. assert_eq!( one_of_everything().len(), 21, "one of every `Node` member, in declaration order" ); } #[test] fn a_member_of_a_run_is_drawn_at_all() { // The defect: `draw` and `height` both walked `body` and nothing walked // `run`, so a description that said `across` and then `beside` contributed // members that were never painted and never counted. Silent, and the same // shape quasi-immediate had until quasi@98a7276. let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6); for label in ["Import", "Export", "Settings"] { assert!( out.iter().any(|row| row.contains(label)), "{label} was never drawn: {out:?}" ); } } #[test] fn a_run_puts_its_members_across_rather_than_down() { let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6); let row = out .iter() .find(|row| row.contains("Import")) .expect("no row holds the first member"); assert!( row.contains("Export") && row.contains("Settings"), "the members went down the screen instead of across it: {out:?}" ); let (first, second) = (row.find("Import").unwrap(), row.find("Export").unwrap()); assert!(first < second, "the members are out of order: {row:?}"); } #[test] fn a_run_wraps_when_the_line_cannot_hold_the_next_member() { // Derived from what the members hold, at the width the region was given. // No breakpoint is authored anywhere in this path. let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 16, 6); let lines: Vec<&String> = out .iter() .filter(|row| THREE.iter().any(|(label, _)| row.contains(label))) .collect(); assert!(lines.len() > 1, "a narrow row did not wrap: {out:?}"); for label in ["Import", "Export", "Settings"] { assert!( out.iter().any(|row| row.contains(label)), "wrapping lost {label}: {out:?}" ); } } #[test] fn a_region_is_as_tall_as_its_row_plus_its_body() { // The half that is invisible until something scrolls: `height` is what the // scroll arithmetic trusts, so a row it did not count is a region that // scrolls short by however many lines the row took. let bare = Slot::new("bar", RegionKind::Band).with(Node::text("body")); let rowed = Slot::new("bar", RegionKind::Band) .with(Node::text("body")) .across(Run::new(layout::Fallback::Wrap).beside( Node::Act(Act::new("Import", Action::post("/import"))), layout::Priority::Essential, )); let tui = tui(); assert_eq!( crate::region::height(&tui, &rowed, 60, &Local::none()), crate::region::height(&tui, &bare, 60, &Local::none()) + 1, "the row was not counted" ); } #[test] fn a_run_that_sheds_drops_by_priority_and_keeps_the_essential() { let members = [ ("Import", layout::Priority::Essential), ("Export", layout::Priority::Secondary), ("Settings", layout::Priority::Optional), ]; let screen = run_of(layout::Fallback::Shed, &members); let narrow = shown(&screen, 30, 6); assert!(narrow.iter().any(|row| row.contains("Import"))); assert!( !narrow.iter().any(|row| row.contains("Export")), "a shed row kept what it said it would drop: {narrow:?}" ); // And nothing is dropped where there is room, or the cutoff would be a // permanent narrowing rather than a measurement. let wide = shown(&screen, 120, 6); assert!(wide.iter().any(|row| row.contains("Settings")), "{wide:?}"); } #[test] fn a_run_that_menus_keeps_every_member_because_a_terminal_has_nowhere_to_put_them() { // This renderer's answer rather than a shortfall, and the header says so: a // menu here is a key, and a marker showing a count nobody can open is a // control drawn, reachable and doing nothing. let members = [ ("Import", layout::Priority::Essential), ("Export", layout::Priority::Secondary), ("Settings", layout::Priority::Optional), ]; let narrow = shown(&run_of(layout::Fallback::Menu, &members), 30, 6); for label in ["Import", "Export", "Settings"] { assert!( narrow.iter().any(|row| row.contains(label)), "Menu shed {label} with nowhere to put it: {narrow:?}" ); } } #[test] fn the_caret_reaches_a_row_s_members_before_the_body() { // The invariant holding the two walks together: the caret lights the // control the drawing put first, or Enter calls something the reader is not // looking at. let screen = Screen::sidebar_content("Test").with( Slot::new("bar", RegionKind::Band) .across(Run::new(layout::Fallback::Wrap).beside( Node::Act(Act::new("Import", Action::post("/import"))), layout::Priority::Essential, )) .with(Node::Act(Act::new("Below", Action::post("/below")))), ); let reached: Vec = crate::focus::reaches(&screen, &Local::none()) .iter() .filter_map(|reach| match &reach.spot { Spot::Act { action, .. } => action.destination.route().map(ToOwned::to_owned), _ => None, }) .collect(); assert_eq!(reached, ["/import", "/below"]); } /// The row a piece of text was drawn on. fn row_of(rows: &[String], text: &str) -> Option { rows.iter().position(|row| row.contains(text)) } #[test] fn a_band_said_after_the_body_is_drawn_under_it() { // Ruled by Max 2026-08-23 (quasicoherent 3725bacf): where a band was said // is which end it belongs to. This renderer hoisted every band before it, // so a status band drew above the content it belonged under while the same // description put it underneath in a webview. let screen = Screen::sidebar_content("Test") .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar"))) .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); let out = shown(&screen, 40, 12); let bar = row_of(&out, "the toolbar").expect("no toolbar drawn"); let content = row_of(&out, "the content").expect("no content drawn"); let foot = row_of(&out, "the status").expect("no status drawn"); assert!(bar < content, "the toolbar left the top: {out:?}"); assert!( content < foot, "the footer is above what it is the footer of: {out:?}" ); } #[test] fn the_room_a_trailing_band_needs_is_kept_out_of_the_body() { // The failure a reservation prevents: a pane that fills its area leaves a // footer nowhere to go, and the band drawn into no rows is a band nobody // sees. The pane here holds more lines than the screen has. let mut pane = Slot::new("main", RegionKind::Pane); for line in 0..30 { pane = pane.with(Node::text(format!("line {line}"))); } let screen = Screen::sidebar_content("Test") .with(pane) .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); let out = shown(&screen, 40, 10); assert!( row_of(&out, "the status").is_some(), "the body took the footer's rows: {out:?}" ); } #[test] fn the_band_said_last_is_the_one_at_the_bottom() { // Two trailing bands, which is audiofiles' shell: a migration strip above // the status band. let screen = Screen::sidebar_content("Test") .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) .with(Slot::new("strip", RegionKind::Band).with(Node::text("the strip"))) .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); let out = shown(&screen, 40, 12); let strip = row_of(&out, "the strip").expect("no strip drawn"); let foot = row_of(&out, "the status").expect("no status drawn"); assert!(strip < foot, "the bands are upside down: {out:?}"); } #[test] fn the_caret_reaches_a_trailing_band_in_the_order_it_is_drawn() { // The focus walk and the drawing have to agree about where a band is, or // the caret lights the footer before the content it sits under. let screen = Screen::sidebar_content("Test") .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar"))) .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); let order: Vec<&str> = crate::region::reachable(&screen) .iter() .map(|slot| slot.id.as_str()) .collect(); assert_eq!(order, ["bar", "main", "foot"]); } #[test] fn a_row_draws_its_run_in_the_order_the_description_says_it() { // The property the containment migration bought this renderer: a terminal // reads the run rather than knowing the old fixed member sequence. let out = drawn( &Node::list([Row::new("Ship it") .token(Tag::badge("beta")) .meta("2 files")]), 40, 3, ); let line = &out[0]; assert!(line.contains("Ship it"), "{out:?}"); assert!( line.find("beta").unwrap() < line.find("2 files").unwrap(), "{out:?}" ); } #[test] fn a_selectable_row_draws_its_tick_and_a_current_row_its_marker() { let ticked = drawn(&Node::list([Row::new("One").selectable(true)]), 20, 2); assert!(ticked[0].starts_with("[x]"), "{ticked:?}"); let untickable = drawn(&Node::list([Row::new("One")]), 20, 2); assert!(!untickable[0].contains("[x]"), "{untickable:?}"); } #[test] fn a_meter_is_a_bar_and_a_reading() { let out = drawn(&Node::Meter(Meter::new(3, 6).label("subtasks")), 40, 2); assert!(out[0].starts_with("#####-----"), "{out:?}"); assert!(out[0].contains("3/6 subtasks"), "{out:?}"); } #[test] fn a_figure_puts_the_number_over_what_it_counts() { let out = drawn(&Node::Figure(Figure::new("17", "Current streak")), 30, 3); assert_eq!(out[0], "17"); assert_eq!(out[1], "Current streak"); } #[test] fn a_strip_of_figures_stacks_rather_than_sitting_in_a_row() { // The renderer deciding, which the node leaves it free to do: the strip // says these belong together and not how wide they are. let out = drawn( &Node::stats([Figure::new("17", "Streak"), Figure::new("4", "Today")]), 30, 5, ); assert_eq!(out[0], "17"); assert_eq!(out[2], "4"); } #[test] fn markdown_keeps_its_emphasis_and_loses_its_syntax() { // The node carries source so every renderer can answer it its own way, and // a terminal's own way is the run's marks on the cell: `**ship it**` is the // words in bold, not the words with the asterisks still on them and not the // words with the emphasis thrown away. let buf = buffer(&Node::rich("**ship it** now"), 40, 2); let out = rows(&buf); assert_eq!(out[0], "ship it now"); assert_eq!(marked(&buf, 0, Modifier::BOLD), "ship it"); } #[test] fn each_inline_mark_reaches_the_cell_that_has_it() { let buf = buffer(&Node::rich("*lean* and ~~gone~~"), 40, 2); assert_eq!(rows(&buf)[0], "lean and gone"); assert_eq!(marked(&buf, 0, Modifier::ITALIC), "lean"); assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "gone"); } #[test] fn a_code_span_is_set_into_the_page_rather_than_marked() { // Every cell is monospace, so the one thing a webview says with a typeface // is the one mark a terminal cannot repeat. It takes the sunken surface // instead. Asserted as a difference and not as a colour: which colour is // the theme's answer, that there is one is this renderer's. let buf = buffer(&Node::rich("run `cargo build` first"), 40, 2); assert_eq!(rows(&buf)[0], "run cargo build first"); let prose = buf[(0, 0)].bg; let code = buf[(4, 0)].bg; assert_ne!(code, prose, "a code span should not sit on the page"); assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should"); } #[test] fn a_heading_inside_a_rich_node_is_drawn_heavier_than_the_prose_under_it() { // The gap this closed: `render_plain` handed over a heading's text at the // weight of everything around it, so a rich node's structure was gone by // the time a cell saw it. Weight is a thing a cell has. let buf = buffer(&Node::rich("# Title\n\nBody."), 40, 4); let out = rows(&buf); assert_eq!(out[0], "Title"); assert_eq!(out[2], "Body."); assert_eq!(marked(&buf, 0, Modifier::BOLD), "Title"); assert_eq!(marked(&buf, 2, Modifier::BOLD), ""); } #[test] fn a_deep_heading_reads_as_the_shallowest_a_terminal_can_tell_apart() { // Six markdown levels onto the three `layout::Heading` has. A cell has one // size and only so much colour, so `###` and `######` land together rather // than inventing distinctions nothing can draw. let third = buffer(&Node::rich("### Third"), 40, 2); let sixth = buffer(&Node::rich("###### Sixth"), 40, 2); assert_eq!(third[(0, 0)].fg, sixth[(0, 0)].fg); // And not the same as the prose it sits above, or the level bought nothing. let prose = buffer(&Node::rich("Third"), 40, 2); assert_ne!(third[(0, 0)].fg, prose[(0, 0)].fg); } #[test] fn a_tight_run_is_one_line_and_says_it_was_cut() { // The cap, which is what `Flow` buys a terminal. Before it this renderer // gave a row as many lines as the words needed, so a long secondary took // four rows of a list nobody asked to be four rows tall. let long = "a headline long enough that it certainly does not fit in twenty columns"; let out = drawn(&Node::list([Row::new(long)]), 20, 6); let painted = out.iter().filter(|row| !row.trim().is_empty()).count(); assert_eq!(painted, 1, "{out:?}"); assert!(out[0].contains('\u{2026}'), "{out:?}"); } #[test] fn a_relaxed_part_gets_the_second_line_it_asked_for() { let long = "a headline long enough that it certainly does not fit in twenty columns"; let out = drawn(&Node::list([Row::new(long).relaxed()]), 20, 6); let painted = out.iter().filter(|row| !row.trim().is_empty()).count(); assert_eq!(painted, 2, "{out:?}"); // Still cut, because two lines is a cap and not a promise of room. assert!(out[1].contains('\u{2026}'), "{out:?}"); } #[test] fn a_run_that_fits_is_not_marked_as_cut() { let out = drawn(&Node::list([Row::new("Ship it")]), 40, 3); assert!(out[0].contains("Ship it"), "{out:?}"); assert!(!out[0].contains('\u{2026}'), "{out:?}"); } #[test] fn a_narrow_row_drops_what_it_can_spare_rather_than_its_tail() { // The defect the ladder fixes. A capped run cuts from the end, so the badge // and the count -- the two things a list is scanned for -- were the first // to go while the title kept every column it wanted. let row = || { Row::new("Ship the release") .token(Tag::badge("beta")) .meta("2 files") }; // Wide enough for all of it: nothing is dropped. let roomy = drawn(&Node::list([row()]), 40, 4); assert!(roomy[0].contains("beta"), "{roomy:?}"); assert!(roomy[0].contains("2 files"), "{roomy:?}"); // Narrow enough that the run wants a second line. Meta is Optional and goes // first; the badge is Secondary and survives it. let narrow = drawn(&Node::list([row()]), 25, 4); assert!(!narrow[0].contains("2 files"), "{narrow:?}"); assert!(narrow[0].contains("beta"), "{narrow:?}"); } #[test] fn dropping_that_would_buy_nothing_is_not_done() { // A title that overflows on its own. Dropping the trailing facts cannot // make it fit, and they were past the cut either way, so the run is left // whole rather than quietly edited for no picture. let out = drawn( &Node::list([Row::new("a headline that is far too long for this pane").meta("2 files")]), 24, 4, ); assert!(out[0].starts_with("a headline"), "{out:?}"); assert!(out[0].contains('\u{2026}'), "{out:?}"); } #[test] fn a_part_can_say_it_is_worth_more_than_its_role() { use quasi_router::layout::Priority; // The role is the default and the description overrules it. Meta is // Optional by role and would go first; said Essential, the badge goes // instead. let out = drawn( &Node::list([Row::new("Ship the release") .token(Tag::badge("beta")) .meta("2 files") .worth(Priority::Essential)]), 25, 4, ); assert!(out[0].contains("2 files"), "{out:?}"); assert!(!out[0].contains("beta"), "{out:?}"); } #[test] fn a_control_survives_a_squeeze_whatever_it_is_worth() { use quasi_router::layout::Priority; // Focus is claimed per part before layout, so dropping a control would // leave a claim pointing at something nobody drew. let out = drawn( &Node::list([Row::new("Ship the release") .act(Act::new("Open", Action::get("/1"))) .worth(Priority::Optional) .meta("2 files")]), 26, 4, ); assert!(out.concat().contains("Open"), "{out:?}"); } #[test] fn a_list_gets_the_bullet_the_description_refused_to_carry() { // docengine says "this run is an item" and stops there, because what a // bullet looks like is the renderer's answer. This is a terminal's. let out = drawn(&Node::rich("- one\n- two"), 40, 4); assert_eq!(out[0], "- one"); assert_eq!(out[1], "- two"); } #[test] fn a_quote_is_marked_in_the_margin_and_only_on_its_first_line() { // A marker belongs at the head of a line and nowhere else. A run knows its // block but not its position, so the marker is placed off the break the // previous run ended with. let out = drawn(&Node::rich("> quoted\n\nafter"), 40, 4); assert_eq!(out[0], "> quoted"); assert_eq!(out[2], "after"); } #[test] fn emphasis_inside_a_heading_is_added_to_its_weight_rather_than_swapped_for_it() { // The order a stylesheet uses: the block decides the ground and the marks // go on top. A struck word in a heading is struck AND a heading. let buf = buffer(&Node::rich("## Ship ~~later~~"), 40, 2); assert_eq!(rows(&buf)[0], "Ship later"); assert_eq!(marked(&buf, 0, Modifier::BOLD), "Ship later"); assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "later"); } #[test] fn a_rich_block_keeps_the_breaks_the_author_wrote() { // The reason a rich node cannot go through `draw_line`: that one wraps a // run that is one line by construction, and two paragraphs run together // read as one sentence that does not parse. let out = drawn(&Node::rich("one\n\ntwo"), 40, 4); assert_eq!(out[0], "one"); assert_eq!(out[1], ""); assert_eq!(out[2], "two"); } #[test] fn a_rich_node_inside_a_row_keeps_its_emphasis_too() { // The other path into the same runs. A row's parts are one line of spans, // so this goes through `inline_spans` rather than the block wrap, and the // marks have to survive both. let buf = buffer( &Node::list([Row::new("Ship it").part(layout::RowPart::Meta, Node::rich("**now**"))]), 40, 3, ); // Two spaces in the run, one on the row: `draw_line` breaks on whitespace, // so the gap between two parts is a separator and not a measure. assert_eq!(rows(&buf)[0], "Ship it now"); assert_eq!(marked(&buf, 0, Modifier::BOLD), "now"); } #[test] fn a_rich_block_wraps_without_losing_which_words_were_marked() { // The wrap breaks a run across rows, so the marks have to travel with the // words rather than with the run they arrived in. let buf = buffer(&Node::rich("plain **one two three** plain"), 12, 4); let out = rows(&buf); assert_eq!(out[0], "plain one"); assert_eq!(out[1], "two three"); assert_eq!(out[2], "plain"); assert_eq!(marked(&buf, 0, Modifier::BOLD), "one"); assert_eq!(marked(&buf, 1, Modifier::BOLD), "two three"); } #[test] fn a_hidden_field_draws_nothing_at_all() { let field = Field::new(layout::FieldKind::Hidden, "token", "Token"); let out = drawn(&Node::Field(Box::new(field)), 30, 3); assert!(out.iter().all(String::is_empty), "{out:?}"); } #[test] fn a_secret_field_has_nothing_to_draw_and_that_is_a_finding() { // `Field::value` drops what it is handed when the kind is `Secret`, on // purpose. A webview never noticed, because the browser owns the contents // of an `input` and redraws them itself. A terminal owns nothing, so what // the user typed lives in the runtime's buffer, and this is the first node // whose drawing is not a function of the description alone. let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2"); assert_eq!(field.value, None); let out = drawn(&Node::Field(Box::new(field)), 30, 3); assert_eq!(out[0], "Password"); assert!(!out[1].contains("hunter2"), "{out:?}"); } #[test] fn a_markdown_field_is_drawn_over_several_rows_like_a_textarea() { // Task f8ad0b32's terminal half. A terminal does nothing with the markdown // and draws the source as text, which loses none of it; what it must not do // is give a document one row. let field = Field::new(layout::FieldKind::Rich, "body", "Body").value("# Heading"); let out = drawn(&Node::Field(Box::new(field)), 30, 4); assert_eq!(out[0], "Body"); assert!(out[1].contains("# Heading"), "{out:?}"); } #[test] fn a_choice_field_marks_the_chosen_option() { let field = Field::select( "priority", "Priority", vec![Choice::plain("high"), Choice::plain("low")], ) .value("low"); let out = drawn(&Node::Field(Box::new(field)), 30, 4); assert_eq!(out[1], "( ) high"); assert_eq!(out[2], "(*) low"); } #[test] fn an_act_draws_its_key_because_the_description_carries_one() { // `Act::key` is the one place the vocabulary already anticipated a // terminal, and this is the renderer that finally reads it. let act = Act::new("Delete", Action::post("/tasks/1/delete")).key("d"); let out = drawn(&Node::Act(act), 30, 2); assert!(out[0].contains("Delete"), "{out:?}"); assert!(out[0].contains("(d)"), "{out:?}"); } #[test] fn a_table_narrows_by_dropping_the_columns_that_said_they_could_go() { let table = Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![ Column::new("Name").priority(layout::Priority::Essential), Column::new("Added").priority(layout::Priority::Optional), ], rows: vec![Row::cells(["kick.wav", "2026-08-12"])], more: None, }; let wide = drawn(&table, 60, 3); assert!(wide[0].contains("Added"), "{wide:?}"); let narrow = drawn(&table, 14, 3); assert!(!narrow[0].contains("Added"), "{narrow:?}"); } #[test] fn a_notice_belongs_to_the_screen_and_lands_above_every_region() { let screen = Screen::sidebar_content("Tasks") .saying(Node::Notice { kind: layout::Notice::Banner, tone: layout::Tone::Success, text: "Saved".into(), act: None, }) .with(Slot::new("main", RegionKind::Pane).with(Node::section("Today"))); let out = shown(&screen, 40, 6); assert_eq!(out[0], "Saved"); assert!(out.iter().any(|row| row.contains("Today")), "{out:?}"); } #[test] fn a_pending_region_says_so_in_words() { let slot = Slot::new("detail", RegionKind::Pane) .with(Node::text("Ready")) .pending(); let out = drawn(&Node::Region(slot), 30, 3); assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}"); assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}"); } // The interaction runtime. Everything below is the half the browser was // supplying: focus order, what is typed, where a key goes, what comes back. use crate::focus::{Reach, Spot}; use crate::{Delayed, Key, Runtime, Step}; use quasi_router::{ Address, Chrome, Frame, Jump, Message, Method, Outcome, Request, Response, Rest, }; /// A screen with one region holding these nodes. fn screen_of(nodes: impl IntoIterator) -> Screen { Screen::sidebar_content("Test").with( nodes .into_iter() .fold(Slot::new("main", RegionKind::Pane), Slot::with), ) } /// The path a step is calling, for a step that calls one. fn calling(step: &Step) -> Option<&str> { match step { Step::Call(request) => Some(request.path.as_str()), _ => None, } } #[test] fn the_focus_walk_and_the_drawing_count_the_same_things() { // The one invariant holding the two walks together. `focus.rs` decides how // many reachable things a screen has and `node.rs` counts them as it draws, // and if they ever disagree the caret lights a different control from the // one Enter would call. Asserted over a screen carrying one of everything // that can be reached. let screen = screen_of([ Node::Act(Act::new("Save", Action::post("/save"))), Node::Act(Act::new("Gone", Action::post("/gone")).disabled()), Node::Link { text: "Docs".into(), action: Action::get("/docs"), }, Node::field(Field::new(layout::FieldKind::Text, "name", "Name")), Node::field(Field::new(layout::FieldKind::Hidden, "csrf", "")), Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/new"), submit: "Create".into(), fields: vec![ Field::new(layout::FieldKind::Text, "title", "Title"), Field::new(layout::FieldKind::Secret, "password", "Password"), ], }, Node::list([ Row::new("Open me").activate(Action::get("/one")), Row::new("Just words"), ]) .and_more(Rest::more(2, Action::get("/more"))), Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Name")], rows: vec![Row::cells(["one"]).activate(Action::get("/row"))], more: None, }, ]); let expected = crate::focus::spots(&screen, &Local::none()).len(); // The count the drawing keeps is private, so it is read through the only // thing it drives: focusing the nth reachable thing has to change the // picture. The baseline is focus one past the end, where nothing is lit. // // What this catches is the walks drifting apart. If the drawing counted // fewer things than `spots` records, the last indices would light nothing // and come back identical to the baseline; if it counted them in another // order, the caret would still move but a later test would find it on the // wrong control. This is the cheap half, and it is the half that breaks // silently. let area = Rect::new(0, 0, 60, 40); let draw = |view: &View| { let mut buf = Buffer::empty(area); tui().screen(&screen, view, area, &mut buf); buf }; let mut past = View::new(); past.focus_on(expected, expected + 1); let unlit = draw(&past); for at in 0..expected { let mut view = View::new(); view.focus_on(at, expected); assert_ne!( draw(&view), unlit, "focusing {at} of {expected} changed nothing on the screen" ); } } #[test] fn a_secret_field_draws_what_was_typed_and_the_description_never_carries_it() { // `39057019`. The description refuses to hold a password, so the dots can // only come from the view, and this is the node that would be undrawable // without the second argument. let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2"); assert_eq!(field.value, None, "a secret refuses a described value"); let node = Node::field(field); let area = Rect::new(0, 0, 30, 4); let mut buf = Buffer::empty(area); tui().node(&node, &View::new(), area, &mut buf); assert!( !rows(&buf).iter().any(|row| row.contains('*')), "nothing typed yet" ); let mut view = View::new(); view.set("password", "hunter2"); let mut buf = Buffer::empty(area); tui().node(&node, &view, area, &mut buf); assert!( rows(&buf).iter().any(|row| row.contains("*******")), "{:?}", rows(&buf) ); } /// `drums` shut over one child, `genre` open over one. fn outline() -> Node { Node::list([ Row::new("drums") .disclosing(false) .activate(Action::get("/tags/drums")), Row::new("drums.kick") .depth(quasi_router::layout::Nesting::at(1)) .activate(Action::get("/k")), Row::new("genre") .disclosing(true) .activate(Action::get("/g")), Row::new("genre.house") .depth(quasi_router::layout::Nesting::at(1)) .activate(Action::get("/h")), ]) } #[test] fn a_shut_branch_draws_neither_its_children_nor_room_for_them() { let drawn = rows(&buffer(&outline(), 40, 6)); let text = drawn.join("\n"); assert!(text.contains("drums"), "{drawn:?}"); assert!(!text.contains("drums.kick"), "{drawn:?}"); assert!(text.contains("genre.house"), "{drawn:?}"); // Shut and open say so, in the column before the words. assert!(text.contains('\u{25b6}'), "{drawn:?}"); assert!(text.contains('\u{25bc}'), "{drawn:?}"); } #[test] fn a_child_is_indented_under_the_branch_that_holds_it() { let drawn = rows(&buffer(&outline(), 40, 6)); let parent = drawn .iter() .find(|row| row.contains("genre") && !row.contains("house")) .expect("the branch"); let child = drawn .iter() .find(|row| row.contains("genre.house")) .expect("the child"); // In cells, not bytes: the chevron is one column and three bytes. let column = |row: &str, text: &str| { row.chars().count() - row[row.find(text).expect("the words")..].chars().count() }; assert!( column(child, "genre.house") > column(parent, "genre"), "{drawn:?}" ); } #[test] fn the_right_key_opens_a_branch_and_the_left_key_shuts_it() { // `ccaa7e4b`. The keys every tree in a terminal already answers, and the // rows they reveal were in the description all along -- folding asks the // app nothing. See `Row::open`. let mut runtime = Runtime::new(screen_of([outline()])); // The caret starts on the shut branch, so Enter is the branch's own route // and not its child's. assert_eq!(calling(&runtime.key(Key::Enter)), Some("/tags/drums")); // One stop past it is the next row drawn, which is `genre` while `drums` // is shut. runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/g")); // Open it, and its child is a stop. runtime.key(Key::BackTab); assert_eq!(runtime.key(Key::Right), Step::Idle); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/k")); // Shut it again, and the child goes with it. runtime.key(Key::BackTab); runtime.key(Key::Left); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/g")); } #[test] fn a_branch_is_reachable_even_when_nothing_else_about_it_is() { // The chevron is the affordance, so a row that only holds one still takes a // stop -- otherwise the branch is drawn and cannot be opened. let mut runtime = Runtime::new(screen_of([Node::list([ Row::new("drums").disclosing(false), Row::new("drums.kick") .depth(quasi_router::layout::Nesting::at(1)) .activate(Action::get("/k")), ])])); runtime.key(Key::Right); assert_eq!(calling(&runtime.key(Key::Enter)), None); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/k")); } #[test] fn a_list_with_no_branch_spends_no_room_on_one() { // Every list described before `Row::open` existed, drawn as it was. let drawn = rows(&buffer( &Node::list([Row::new("one"), Row::new("two")]), 20, 3, )); assert_eq!(drawn[0].trim_start(), "one", "{drawn:?}"); } #[test] fn tab_walks_the_screen_and_enter_calls_what_it_lands_on() { let mut runtime = Runtime::new(screen_of([ Node::Act(Act::new("First", Action::post("/first"))), Node::Act(Act::new("Second", Action::post("/second"))), ])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); assert_eq!(runtime.key(Key::Tab), Step::Idle); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/second")); // Wrapping, because a dead stop at the end reads as a broken key. runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); } #[test] fn a_disabled_control_is_drawn_and_never_landed_on() { let mut runtime = Runtime::new(screen_of([ Node::Act(Act::new("Gone", Action::post("/gone")).disabled()), Node::Act(Act::new("Live", Action::post("/live"))), ])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/live")); } #[test] fn the_runtime_starts_on_the_first_reach_and_the_description_gets_no_say() { // The guarantee that replaced `layout::State::Focus`. A description used to // be able to claim the starting control; focus is this renderer's now, and // the rule is the plain one: first thing you can reach. let mut runtime = Runtime::new(screen_of([ Node::Act(Act::new("First", Action::post("/first"))), Node::Act(Act::new("Second", Action::post("/second"))), ])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); } #[test] fn a_key_the_description_named_reaches_its_control_from_anywhere() { // `Act::key` is the one place the vocabulary already anticipated a // terminal, and this is the renderer that binds it. let mut runtime = Runtime::new(screen_of([ Node::Act(Act::new("First", Action::post("/first"))), Node::Act(Act::new("New", Action::get("/new")).key("n")), ])); assert_eq!(calling(&runtime.key(Key::Char('n'))), Some("/new")); // A key nothing claimed does nothing rather than something surprising. assert_eq!(runtime.key(Key::Char('z')), Step::Idle); } #[test] fn a_control_that_asks_first_is_not_called_until_it_is_answered() { let mut runtime = Runtime::new(screen_of([Node::Act( Act::new("Delete", Action::post("/delete")).confirm("Delete this?"), )])); assert_eq!( runtime.key(Key::Enter), Step::Ask("Delete this?".to_string()) ); assert!(runtime.asking()); assert_eq!(runtime.key(Key::Char('n')), Step::Idle); assert!(matches!(runtime.key(Key::Enter), Step::Ask(_))); assert_eq!(calling(&runtime.key(Key::Char('y'))), Some("/delete")); } #[test] fn the_caret_starts_in_the_question_the_screen_named() { // The whole of what `Screen::opens_at` buys a terminal: the sessionless // form where the only thing to do is type, and the caret starting anywhere // else is a keystroke spent before the reader can begin. let screen = screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/login"), submit: "Log in".into(), fields: vec![ Field::new(layout::FieldKind::Text, "email", "Email"), Field::new(layout::FieldKind::Secret, "password", "Password"), ], }]) .opening_at("password"); let mut runtime = Runtime::new(screen); for ch in "hunter2".chars() { runtime.key(Key::Char(ch)); } // The submit is the third stop, so two tabs from the second box. runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the submit calls its route"); }; assert_eq!(request.payload.get("password"), Some("hunter2")); assert_eq!(request.payload.get("email"), Some("")); } #[test] fn a_screen_that_names_nothing_starts_where_it_always_did() { let mut runtime = Runtime::new(screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/login"), submit: "Log in".into(), fields: vec![ Field::new(layout::FieldKind::Text, "email", "Email"), Field::new(layout::FieldKind::Secret, "password", "Password"), ], }])); for ch in "max".chars() { runtime.key(Key::Char(ch)); } runtime.key(Key::Tab); runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the submit calls its route"); }; assert_eq!(request.payload.get("email"), Some("max")); } #[test] fn a_name_no_question_carries_leaves_the_caret_at_the_first_stop() { // The member's own bargain: the screen is the app's and so is the name. let mut runtime = Runtime::new( screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/login"), submit: "Log in".into(), fields: vec![Field::new(layout::FieldKind::Text, "email", "Email")], }]) .opening_at("nothing-is-called-this"), ); for ch in "max".chars() { runtime.key(Key::Char(ch)); } runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the submit calls its route"); }; assert_eq!(request.payload.get("email"), Some("max")); } #[test] fn typing_fills_a_box_and_a_form_submits_what_is_in_it() { let mut runtime = Runtime::new(screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/new"), submit: "Create".into(), fields: vec![ Field::new(layout::FieldKind::Text, "title", "Title"), Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent"), ], }])); assert!(runtime.editing()); for ch in "Ship".chars() { runtime.key(Key::Char(ch)); } runtime.key(Key::Backspace); // Onto the checkbox, which takes any key as a flip rather than as a // character, then onto the submit. runtime.key(Key::Tab); runtime.key(Key::Char(' ')); runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the submit calls its route"); }; assert_eq!(request.path, "/new"); assert_eq!(request.method, Method::Post); assert_eq!(request.payload.get("title"), Some("Shi")); assert_eq!(request.payload.get("urgent"), Some(Node::SELECTED)); } #[test] fn an_unticked_box_sends_nothing_the_way_a_browser_sends_nothing() { let mut runtime = Runtime::new(screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/new"), submit: "Create".into(), fields: vec![Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent")], }])); runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the submit calls its route"); }; assert!(!request.payload.contains("urgent"), "{:?}", request.payload); } #[test] fn a_field_that_writes_as_it_changes_writes_when_the_value_is_complete() { // `8032fe61`. This asserted the opposite until 2026-08-27: every keystroke // was one call, so a search box was one request per letter. `Field::writes` // means the change is *complete*, which is what `quasi-webview` has always // emitted it as, and on a terminal a typed value is complete when the caret // walks off it. let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "query", "Search").writes(Action::post("/search")), )])); assert!( matches!(runtime.key(Key::Char('a')), Step::Idle), "a keystroke is not a write" ); assert!(matches!(runtime.key(Key::Char('b')), Step::Idle)); let Step::Call(request) = runtime.key(Key::Tab) else { panic!("leaving the box is the write"); }; assert_eq!(request.path, "/search"); assert_eq!(request.payload.get("query"), Some("ab"), "the whole value"); } #[test] fn walking_through_a_box_nobody_altered_writes_nothing() { // The other half of the rule, and what a browser's `change` already // promises: leaving is not a write, changing and then leaving is. let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "query", "Search") .value("kick") .writes(Action::post("/search")), )])); assert!(matches!(runtime.key(Key::Tab), Step::Idle)); } #[test] fn a_field_that_consults_asks_once_the_value_has_stood_still() { // The other half of the comment above. A consult carries the wait, so this // renderer no longer has to choose between calling on every keystroke and // inventing a delay the webview would disagree with. let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "username", "Username") .consults(Action::get("/api/validate/username")), )])); let Step::CallAfter { asks } = runtime.key(Key::Char('m')) else { panic!("a consult waits"); }; let [Delayed { request, after }] = asks.as_slice() else { panic!("one question, one wait"); }; assert_eq!(request.path, "/api/validate/username"); assert_eq!(request.payload.get("username"), Some("m")); assert_eq!(*after, Consult::SETTLES); } #[test] fn a_consult_with_a_floor_stays_quiet_until_the_value_is_long_enough() { let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Find a tag").consulting( Consult::new(Action::get("/discover/tag-suggest")) .after(std::time::Duration::from_millis(150)) .at_least(2), ), )])); // One letter is under the floor, and the floor is a fact about the // question rather than about the host, so every renderer owes the same // silence. assert!(matches!(runtime.key(Key::Char('e')), Step::Idle)); let Step::CallAfter { asks } = runtime.key(Key::Char('l')) else { panic!("two characters clears the floor"); }; let [Delayed { request, after }] = asks.as_slice() else { panic!("one question, one wait"); }; assert_eq!(request.payload.get("q"), Some("el")); assert_eq!(*after, std::time::Duration::from_millis(150)); // And deleting back under it asks nothing again. assert!(matches!(runtime.key(Key::Backspace), Step::Idle)); } #[test] fn one_keystroke_can_pose_two_questions_at_two_waits() { // `N8`. MNW's discover search asks a suggestion route and a results route // about one value. The runtime still owns no scheduler: this is one // keystroke producing one `Step`, and what varies is how many questions it // posed. The host runs the timer it was already running, once per entry. let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search") .consulting( Consult::new(Action::get("/discover/suggestions")) .after(std::time::Duration::from_millis(200)), ) .consulting( Consult::new(Action::get("/discover/results")) .after(std::time::Duration::from_millis(150)), ), )])); let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else { panic!("two consults still wait"); }; assert_eq!(asks.len(), 2); assert_eq!(asks[0].request.path, "/discover/suggestions"); assert_eq!(asks[0].after, std::time::Duration::from_millis(200)); assert_eq!(asks[1].request.path, "/discover/results"); assert_eq!(asks[1].after, std::time::Duration::from_millis(150)); // Both carry the value they are about. assert_eq!(asks[0].request.payload.get("q"), Some("a")); assert_eq!(asks[1].request.payload.get("q"), Some("a")); } #[test] fn a_floor_is_per_question_and_not_per_field() { // The two questions about one box carry their own floors, so a value long // enough for one and not the other asks one of them. let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search") .consulting(Consult::new(Action::get("/discover/results"))) .consulting(Consult::new(Action::get("/discover/suggestions")).at_least(2)), )])); let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else { panic!("the unfloored question is asked"); }; assert_eq!(asks.len(), 1); assert_eq!(asks[0].request.path, "/discover/results"); let Step::CallAfter { asks } = runtime.key(Key::Char('b')) else { panic!("two characters clears the floor"); }; assert_eq!(asks.len(), 2); } #[test] fn a_question_carries_the_controls_it_says_it_carries() { // Discover's results route answers about the current filters, so asking it // without them answers about a screen the user is not looking at. The // untouched select still sends what it is showing, which is the order a // submit reads values in. let mut runtime = Runtime::new(screen_of([ Node::field( Field::select( "mode", "Mode", vec![Choice::new("all", "All"), Choice::new("mine", "Mine")], ) .value("mine"), ), Node::field( Field::new(layout::FieldKind::Text, "q", "Search").consulting( Consult::new(Action::get("/discover/results")).sending(["mode", "absent"]), ), ), ])); // The caret starts on the select; one Tab is into the search box. runtime.key(Key::Tab); let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else { panic!("the caret is in the field"); }; assert_eq!(asks[0].request.payload.get("q"), Some("a")); assert_eq!(asks[0].request.payload.get("mode"), Some("mine")); // A name nothing on the screen carries sends nothing, rather than an empty // value, so a route can tell "not on this screen" from "on it and blank". assert_eq!(asks[0].request.payload.get("absent"), None); } #[test] fn a_panel_recomputes_from_every_dial_the_region_holds() { // `cb62a9dc`. MNW's fee calculator. The browser puts one trigger on the // region and lets the document gather what it contains; here the // containment is walked, and both hosts read the same walk. let mut runtime = Runtime::new( Screen::sidebar_content("Pricing").with( Slot::group("pricing-calculator") .with(Node::field( Field::new(layout::FieldKind::Number, "item_price", "Price").value("10"), )) .with(Node::field(Field::new( layout::FieldKind::Number, "sales", "Sales per month", ))) // Nested, because the dials sit in sections of the calculator // rather than directly in it, and a walk that stopped at the // first region would recompute from half of them. .with(Node::Region(Slot::group("other").with(Node::field( Field::new(layout::FieldKind::Number, "other_pct", "Their cut").value("30"), )))) .consulting( Consult::new(Action::get("/pricing/compare").replacing("results-panel")) .after(std::time::Duration::from_millis(300)), ), ), ); // The caret starts on the price box; one Tab is into the second dial. runtime.key(Key::Tab); let Step::CallAfter { asks } = runtime.key(Key::Char('4')) else { panic!("the region asks when a dial inside it moves"); }; assert_eq!(asks.len(), 1); assert_eq!(asks[0].request.path, "/pricing/compare"); assert_eq!(asks[0].after, std::time::Duration::from_millis(300)); // Every dial, at every depth, and the untouched ones send what they are // showing -- the order a submit reads a form in. assert_eq!(asks[0].request.payload.get("sales"), Some("4")); assert_eq!(asks[0].request.payload.get("item_price"), Some("10")); assert_eq!(asks[0].request.payload.get("other_pct"), Some("30")); } #[test] fn a_dial_outside_the_region_it_recomputes_names_itself() { // What is inside is gathered by containment; what is outside says so, the // same way a field's consult names a sibling. let mut runtime = Runtime::new( Screen::sidebar_content("Pricing") .with( Slot::group("dials").with(Node::field( Field::select( "tier", "Tier", vec![Choice::new("16", "Basic"), Choice::new("24", "Small")], ) .value("16"), )), ) .with( Slot::group("calculator") .with(Node::field(Field::new( layout::FieldKind::Number, "sales", "Sales", ))) .consulting( Consult::new(Action::get("/pricing/compare").replacing("results")) .sending(["tier"]), ), ), ); runtime.key(Key::Tab); let Step::CallAfter { asks } = runtime.key(Key::Char('4')) else { panic!("the region asks"); }; assert_eq!(asks[0].request.payload.get("sales"), Some("4")); assert_eq!(asks[0].request.payload.get("tier"), Some("16")); } #[test] fn a_region_asks_only_about_the_dials_it_holds() { // A box somewhere else on the screen is not one of this panel's dials, so // typing into it recomputes nothing. let mut runtime = Runtime::new( Screen::sidebar_content("Pricing") .with( Slot::group("calculator") .with(Node::field(Field::new( layout::FieldKind::Number, "sales", "Sales", ))) .consulting(Consult::new( Action::get("/pricing/compare").replacing("results"), )), ) .with(Slot::group("notes").with(Node::field(Field::new( layout::FieldKind::Text, "note", "Note", )))), ); runtime.key(Key::Tab); assert!(matches!(runtime.key(Key::Char('x')), Step::Idle)); } #[test] fn a_region_floor_is_read_against_the_value_that_moved() { // Never against the gathered set: five dials holding one character each are // not five characters, which is what `Consult::asks_about` says and what // the browser's `event.target.value` filter says in its own words. let mut runtime = Runtime::new( Screen::sidebar_content("Search").with( Slot::group("results") .with(Node::field(Field::new( layout::FieldKind::Text, "q", "Query", ))) .consulting(Consult::new(Action::get("/search").replacing("hits")).at_least(2)), ), ); assert!(matches!(runtime.key(Key::Char('a')), Step::Idle)); let Step::CallAfter { asks } = runtime.key(Key::Char('b')) else { panic!("two characters clears the floor"); }; assert_eq!(asks[0].request.payload.get("q"), Some("ab")); } #[test] fn a_screen_is_a_place_and_a_write_is_not() { let mut runtime = Runtime::new(screen_of([Node::text("first")])); // A read that answered a screen is somewhere to come back to. runtime.apply( &Request::get("/two"), Response { outcome: Outcome::Screen(screen_of([Node::text("second")])), notice: None, address: None, invalidates: Vec::new(), }, ); runtime.apply( &Request::get("/three"), Response { outcome: Outcome::Screen(screen_of([Node::text("third")])), notice: None, address: None, invalidates: Vec::new(), }, ); assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two")); // A write is not a place, so it leaves nothing behind to go back to. let mut runtime = Runtime::new(screen_of([Node::text("first")])); runtime.apply( &Request::post("/save"), Response { outcome: Outcome::Screen(screen_of([Node::text("saved")])), notice: None, address: None, invalidates: Vec::new(), }, ); assert_eq!(runtime.key(Key::Escape), Step::Idle); } #[test] fn a_response_can_say_it_is_not_a_place_when_the_derivation_would_say_it_is() { let mut runtime = Runtime::new(screen_of([Node::text("first")])); runtime.apply( &Request::get("/transient"), Response { outcome: Outcome::Screen(screen_of([Node::text("transient")])), notice: None, address: Some(Address::Unchanged), invalidates: Vec::new(), }, ); assert_eq!(runtime.key(Key::Escape), Step::Idle); } #[test] fn a_fragment_replaces_one_region_and_keeps_the_rest_of_the_screen() { let screen = Screen::sidebar_content("Test") .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("kept"))) .with(Slot::new("main", RegionKind::Pane).with(Node::text("old"))); let mut runtime = Runtime::new(screen); let follow = runtime.apply( &Request::post("/change"), Response { outcome: Outcome::Fragment { region: "main".into(), node: Node::text("new"), }, notice: None, address: None, invalidates: Vec::new(), }, ); assert!(follow.is_none()); let out = shown(runtime.screen(), 40, 8); assert!(out.iter().any(|row| row.contains("kept")), "{out:?}"); assert!(out.iter().any(|row| row.contains("new")), "{out:?}"); assert!(!out.iter().any(|row| row.contains("old")), "{out:?}"); } #[test] fn a_fragment_naming_a_region_that_is_not_there_says_so() { // `Screen::replace` answers false rather than panicking, and the caller is // the one that can act on it. A terminal drawing nothing would look like a // control that does nothing at all. let mut runtime = Runtime::new(screen_of([Node::text("here")])); runtime.apply( &Request::post("/change"), Response { outcome: Outcome::Fragment { region: "nowhere".into(), node: Node::text("new"), }, notice: None, address: None, invalidates: Vec::new(), }, ); let out = shown(runtime.screen(), 60, 8); assert!(out.iter().any(|row| row.contains("nowhere")), "{out:?}"); } #[test] fn going_somewhere_else_is_a_second_request_the_host_performs() { let mut runtime = Runtime::new(screen_of([Node::text("here")])); let follow = runtime.apply( &Request::post("/delete"), Response { outcome: Outcome::Goto(Action::get("/list")), notice: None, address: None, invalidates: Vec::new(), }, ); assert_eq!( follow.map(|request| request.path), Some("/list".to_string()) ); } #[test] fn what_a_response_says_lands_on_the_screen_it_belongs_to() { let mut runtime = Runtime::new(screen_of([Node::text("here")])); runtime.apply( &Request::post("/save"), Response { outcome: Outcome::Screen(screen_of([Node::text("after")])), notice: Some(Message { kind: layout::Notice::Banner, tone: layout::Tone::Success, text: "Saved".into(), undo: None, }), address: None, invalidates: Vec::new(), }, ); let out = shown(runtime.screen(), 40, 6); assert_eq!(out[0], "Saved"); } #[test] fn the_way_back_a_response_offered_survives_the_conversion() { // `bde35298`. A retained-screen host turns a `Message` into a // `Node::Notice`, and until the node grew an act this dropped the undo: // "deleted, and here is how to put it back" arrived as "deleted". let mut runtime = Runtime::new(screen_of([Node::text("here")])); runtime.apply( &Request::post("/tasks/7/delete"), Response { outcome: Outcome::Screen(screen_of([Node::text("after")])), notice: Some(Message { kind: layout::Notice::Toast, tone: layout::Tone::Success, text: "Deleted".into(), undo: Some(Action::post("/tasks/7/restore")), }), address: None, invalidates: Vec::new(), }, ); let notice = runtime .screen() .notices .first() .expect("the notice arrived with the screen"); let Node::Notice { act: Some(act), .. } = notice else { panic!("the undo did not survive: {notice:?}"); }; // The word is `Message::UNDO` and not this renderer's: three hosts naming // the control separately is three chances to disagree about the copy. assert_eq!(act.label, Message::UNDO); assert_eq!(act.action.route(), Some("/tasks/7/restore")); // On the screen, under the sentence it belongs to, and somewhere the caret // can reach -- a control drawn but unreachable is the same as no control. let out = shown(runtime.screen(), 40, 6); assert!(out[0].contains("Deleted"), "{out:?}"); assert!(out[1].contains("Undo"), "{out:?}"); assert!( crate::focus::spots(runtime.screen(), &Local::none()) .iter() .any(|spot| matches!(spot, Spot::Act { action, .. } if action.route() == Some("/tasks/7/restore"))), "the undo is drawn but the caret cannot get to it" ); } #[test] fn a_control_that_goes_back_asks_for_where_the_reader_came_from() { // `33c27e81`. The address is this runtime's history and not anything the // description could have named, which is the whole reason it is a // destination rather than a path some screen computes. let mut runtime = Runtime::new(screen_of([Node::text("the list")])); runtime.apply( &Request::get("/tasks"), Response::screen(screen_of([Node::text("the list")])), ); runtime.apply( &Request::get("/settings"), Response::screen(screen_of([Node::Act(Act::new("Close", Action::back()))])), ); // The caret starts on the first reachable thing, which is the one control. assert_eq!( runtime.key(Key::Enter), Step::Call(Request::get("/tasks")), "back did not ask for the place before this one" ); } #[test] fn back_from_the_first_screen_goes_nowhere_rather_than_somewhere_wrong() { let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Close", Action::back()))])); assert_eq!(runtime.key(Key::Enter), Step::Idle); } #[test] fn the_slots_of_a_repeating_question_are_numbered_and_reachable() { // `f7abbc08`. The numbering is the renderer's, and the two controls are // reachable: a control that is drawn and that the caret cannot get to is // the same as no control. let mut group = Slot::new("conditions", RegionKind::Group).repeating( quasi_router::Repeating::new( "Condition", Act::new("Add condition", Action::post("/rules/conditions/add")), ) .least(1), ); for at in 0..2 { group = group.with(Node::Region( Slot::new(format!("condition-{at}"), RegionKind::Group) .with(Node::text(format!("condition {at}"))) .removes(Act::new( "Remove", Action::post(format!("/rules/conditions/{at}/remove")), )), )); } let screen = Screen::sidebar_content("Rules") .with(Slot::new("main", RegionKind::Pane).with(Node::Region(group))); let out = shown(&screen, 40, 24).join("\n"); assert!(out.contains("Condition 1"), "{out}"); assert!(out.contains("Condition 2"), "{out}"); assert!(!out.contains("Condition 0"), "{out}"); assert!(out.contains("Add condition"), "{out}"); let routes: Vec = crate::focus::spots(&screen, &Local::none()) .iter() .filter_map(|spot| match spot { Spot::Act { action, .. } => action.route().map(ToOwned::to_owned), _ => None, }) .collect(); assert_eq!( routes, [ "/rules/conditions/0/remove", "/rules/conditions/1/remove", "/rules/conditions/add", ], "the walk and the drawing disagree about the repeating chrome" ); } #[test] fn a_readers_value_survives_a_fragment_because_the_view_holds_it() { // `a135f898` says the webview has to be told this and that a terminal was // already right. Confirmed rather than changed: what is typed lives in the // `View` under the field's name, and a fragment replaces a region rather // than the buffer. let field = || Field::new(layout::FieldKind::Text, "tag", "Tag").keeping_value(); let mut runtime = Runtime::new( Screen::sidebar_content("Discover") .with(Slot::new("side", RegionKind::Sidebar).with(Node::field(field()))), ); for ch in "dru".chars() { runtime.key(Key::Char(ch)); } // Something else on the screen answers, and the region the box sits in is // redrawn from a description that carries no value. runtime.apply( &Request::post("/discover/facet"), Response::from(Outcome::Fragment { region: "side".to_owned(), node: Node::field(field()), }), ); // The buffer is drawn from the view, so the view is where the claim is. // `shown` uses a fresh one and would prove nothing about what was kept. assert_eq!( runtime.view().edit("tag"), Some("dru"), "the reader's value was thrown away by a fragment" ); let out = shown_under(runtime.screen(), runtime.view(), 40, 6).join("\n"); assert!(out.contains("dru"), "kept, and not drawn: {out}"); } #[test] fn a_new_screen_forgets_what_was_typed_into_the_old_one() { // Two screens can name the same field, and carrying a buffer across would // put what was typed into one box into a different box that happens to // share its name. let mut runtime = Runtime::new(screen_of([Node::field(Field::new( layout::FieldKind::Text, "name", "Name", ))])); runtime.key(Key::Char('a')); assert_eq!(runtime.view().edit("name"), Some("a")); runtime.apply( &Request::get("/other"), Response { outcome: Outcome::Screen(screen_of([Node::field(Field::new( layout::FieldKind::Text, "name", "Different question, same name", ))])), notice: None, address: None, invalidates: Vec::new(), }, ); assert_eq!(runtime.view().edit("name"), None); } #[test] fn a_scrolled_region_shows_the_rows_under_the_ones_it_started_with() { let slot = Slot::new("main", RegionKind::Pane).extend((0..10).map(|n| Node::text(format!("row {n}")))); let node = Node::Region(slot); let area = Rect::new(0, 0, 20, 4); // Row 0 of the buffer is the region's own frame, so the contents start on // row 1 and the window is what is left after the frame takes two. let mut buf = Buffer::empty(area); tui().node(&node, &View::new(), area, &mut buf); assert!(rows(&buf)[1].contains("row 0"), "{:?}", rows(&buf)); let mut view = View::new(); view.scrolled_to("main", 3); let mut buf = Buffer::empty(area); tui().node(&node, &view, area, &mut buf); let out = rows(&buf); assert!(out[1].contains("row 3"), "{out:?}"); assert!(!out.iter().any(|row| row.contains("row 0")), "{out:?}"); } #[test] fn scrolling_stops_at_the_bottom_of_what_there_is() { // The view holds a number and the drawing clamps it, because how far a // region can scroll is how tall it is at the width it was handed, and the // width is not known until it is drawn. let slot = Slot::new("main", RegionKind::Pane).extend((0..6).map(|n| Node::text(format!("row {n}")))); let node = Node::Region(slot); let area = Rect::new(0, 0, 20, 4); // Six rows into a window of two, so the furthest down it can go is row 4 // at the top: an offset past the end shows the last screenful and not a // blank region. let mut view = View::new(); view.scrolled_to("main", 99); let mut buf = Buffer::empty(area); tui().node(&node, &view, area, &mut buf); let out = rows(&buf); assert!(out[1].contains("row 4"), "{out:?}"); assert!(out[2].contains("row 5"), "{out:?}"); } #[test] fn a_page_key_scrolls_the_region_the_caret_is_in() { let screen = Screen::sidebar_content("Test") .with( Slot::new("side", RegionKind::Sidebar) .with(Node::Act(Act::new("Side", Action::get("/side")))), ) .with( Slot::new("main", RegionKind::Pane) .with(Node::Act(Act::new("Main", Action::get("/main")))), ); let mut runtime = Runtime::new(screen); runtime.key(Key::PageDown); assert!(runtime.view().scroll("side") > 0); assert_eq!(runtime.view().scroll("main"), 0); runtime.key(Key::Tab); runtime.key(Key::PageDown); assert!(runtime.view().scroll("main") > 0); } #[test] fn a_modal_keeps_the_keyboard_until_it_is_gone() { // A dialog you can tab out of is not a dialog. The screen behind it is // still drawn, because covering it costs rows and says nothing. let screen = Screen::sidebar_content("Test") .with( Slot::new("main", RegionKind::Pane) .with(Node::Act(Act::new("Behind", Action::post("/behind")))), ) .with(Slot::new("ask", RegionKind::Modal).with(Node::Act(Act::new( "In the dialog", Action::post("/dialog"), )))); let mut runtime = Runtime::new(screen); assert_eq!(runtime.reaches().len(), 1); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog")); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog")); } #[test] fn an_external_address_is_handed_back_to_the_host() { let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( "Docs", Action::external("https://example.invalid/docs"), ))])); assert_eq!( runtime.key(Key::Enter), Step::Open("https://example.invalid/docs".to_string()) ); } #[test] fn a_local_action_is_not_an_address_handed_to_the_host() { // `210574ca`. This renderer is `Renderer::Client` and may ignore the mark, // but ignoring is not mistaking: the external branch reads "no route" as // "somewhere outside", and before the mark was handled it would have asked // the host to open the empty string. let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Dismiss", Action::local()))])); assert_eq!(runtime.key(Key::Enter), Step::Idle); } #[test] fn a_tab_that_is_not_showing_holds_nothing_the_caret_can_reach() { // The tabbed cut is one function, so the drawing and the focus walk cannot // disagree about which region is on the screen. let screen = Screen::list_detail("Test", true) .with( Slot::new("list", RegionKind::Pane) .with(Node::Act(Act::new("Showing", Action::get("/showing")))), ) .with( Slot::new("detail", RegionKind::Pane) .with(Node::Act(Act::new("Hidden", Action::get("/hidden")))), ); let runtime = Runtime::new(screen); assert_eq!(runtime.reaches().len(), 1); assert!(matches!( runtime.focused(), Some(Spot::Act { ref action, .. }) if action.destination.as_str() == "/showing" )); } #[test] fn a_reach_says_which_region_it_is_in() { let screen = Screen::sidebar_content("Test") .with( Slot::new("side", RegionKind::Sidebar) .with(Node::Act(Act::new("Side", Action::get("/side")))), ) .with( Slot::new("main", RegionKind::Pane) .with(Node::Act(Act::new("Main", Action::get("/main")))), ); let reaches: Vec = crate::focus::reaches(&screen, &Local::none()) .into_iter() .map(|Reach { region, .. }| region) .collect(); assert_eq!(reaches, vec!["side".to_string(), "main".to_string()]); } #[test] fn a_pending_region_holds_nothing_the_caret_can_reach() { let screen = Screen::sidebar_content("Test").with( Slot::new("main", RegionKind::Pane) .with(Node::Act(Act::new("Later", Action::get("/later")))) .pending(), ); assert!(crate::focus::spots(&screen, &Local::none()).is_empty()); } #[test] fn a_row_and_the_controls_on_it_are_two_places_to_stand() { let mut runtime = Runtime::new(screen_of([Node::list([Row::new("Open me") .activate(Action::get("/open")) .act(Act::new("Remove", Action::delete("/remove")))])])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/open")); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/remove")); } #[test] fn ticking_a_row_calls_what_the_description_says_ticking_calls() { let mut runtime = Runtime::new(screen_of([Node::list([ Row::new("Buy milk").toggling(false, Action::post("/done/1")) ])])); assert_eq!(calling(&runtime.key(Key::Char(' '))), Some("/done/1")); } #[test] fn a_field_refuses_the_keystroke_past_the_length_the_description_set() { let mut field = Field::new(layout::FieldKind::Text, "code", "Code"); field.max_length = Some(3); let mut runtime = Runtime::new(screen_of([Node::field(field)])); for ch in "abcdef".chars() { runtime.key(Key::Char(ch)); } assert_eq!(runtime.view().edit("code"), Some("abc")); } #[test] fn an_invalidated_slot_is_on_the_screen_beside_the_one_that_was_replaced() { // The row the write was aimed at, and the count above it that also moved. // On a terminal this is the whole of what an invalidation means: the next // frame redraws everything, so the answer only has to reach the screen. let mut runtime = Runtime::new( Screen::sidebar_content("Tasks") .with(Slot::new("row-7", RegionKind::Pane).with(Node::text("Open"))) .with(Slot::new("task-count", RegionKind::Band).with(Node::text("5 left"))), ); runtime.apply( &Request::post("/tasks/7/done"), Response::fragment("row-7", Node::text("Done")).also("task-count", Node::text("4 left")), ); let out = shown(runtime.screen(), 40, 12); assert!(out.iter().any(|row| row.contains("Done")), "{out:?}"); assert!(out.iter().any(|row| row.contains("4 left")), "{out:?}"); assert!(!out.iter().any(|row| row.contains("5 left")), "{out:?}"); } #[test] fn an_invalidation_naming_no_region_is_reported_with_the_others() { // A description bug, and one banner naming every region that was missing // rather than a banner per region where only the last would survive. let mut runtime = Runtime::new(screen_of([Node::text("Open")])); runtime.apply( &Request::post("/tasks/7/done"), Response::fragment("main", Node::text("Done")) .also("task-count", Node::text("4")) .also("sidebar-badge", Node::text("4")), ); let out = shown(runtime.screen(), 60, 12).join(" "); assert!(out.contains("task-count"), "{out}"); assert!(out.contains("sidebar-badge"), "{out}"); assert!(out.contains("are called"), "{out}"); } #[test] fn two_ticks_and_a_commit_control_send_both_values() { // The end of `5f2b8753`. The screen names the set, each row says what its // tick contributes, and the control says it acts over the set -- so a bulk // action works from one description with nothing gathering the ticks by // hand on either host. let mut runtime = Runtime::new( Screen::sidebar_content("Mail").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::list([ Row::new("First").ticking("m-1", false), Row::new("Second").ticking("m-2", false), Row::new("Third").ticking("m-3", false), ])) .with(Node::Act( Act::new("Archive", Action::post("/mail/archive")).over("chosen"), )), ), ); // Walk to the first row and tick it, then the second. assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); runtime.key(Key::Tab); assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); // Past the third row, onto the control. runtime.key(Key::Tab); runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the commit control calls its route"); }; assert_eq!(request.path, "/mail/archive"); assert_eq!( request .payload .get_all(quasi_router::Node::TICKED) .collect::>(), ["m-1", "m-2"] ); } #[test] fn a_table_row_ticks_the_way_a_list_row_does() { // The same three keys and the same set. A table row is reachable because it // is tickable here, where before this it was reachable only if something // opened it -- a row drawing a box nobody can reach is the dead affordance // `5f2b8753` was filed for, one node over. let mut runtime = Runtime::new( Screen::sidebar_content("Tasks").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![quasi_router::Column::new("title")], rows: vec![ Row::cells(["First"]).ticking("t-1", false), Row::cells(["Second"]).ticking("t-2", false), ], more: None, }) .with(Node::Act( Act::new("Complete", Action::post("/tasks/complete")).over("chosen"), )), ), ); assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); runtime.key(Key::Tab); runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the commit control calls its route"); }; assert_eq!( request .payload .get_all(quasi_router::Node::TICKED) .collect::>(), ["t-1"] ); } #[test] fn a_table_row_that_only_offers_a_menu_can_still_be_reached() { // `Row::menu`. In a terminal the menu is reached from the row and nowhere // else, so a row the caret cannot land on holds acts nothing can get at -- // the same argument that made a tickable row reachable, one member along. let screen = Screen::sidebar_content("Files").with(Slot::new("main", RegionKind::Pane).with( Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Name")], rows: vec![ Row::cells(["kick.wav"]).offers(Act::new("Preview", Action::post("/files/1/play"))), Row::cells(["snare.wav"]), ], more: None, }, )); let spots = crate::focus::spots(&screen, &Local::none()); // One stop, for the one row that offers something. A row that neither opens, // ticks nor offers is passed over, which is what makes a table of readouts // something the caret does not walk through. assert_eq!(spots.len(), 1, "{spots:?}"); let crate::focus::Spot::Row { menu, activate, .. } = &spots[0] else { panic!("a table row is a row: {spots:?}"); }; assert!(activate.is_none(), "{spots:?}"); assert_eq!(menu.len(), 1, "{spots:?}"); assert_eq!(menu[0].label, "Preview"); } #[test] fn the_drawing_counts_the_same_table_rows_the_walk_stops_on() { // A regression, and it predates `Row::menu`. `focus.rs` pushes a stop for // a row that opens, ticks or offers; `draw_table` claimed only the ones that // open. The claim counter is what decides which control draws as focused, so // a table of tickable rows left every control below it drawing the caret one // place early -- here, the second act lighting up while the runtime's caret // was on the first. let mut runtime = Runtime::new( Screen::sidebar_content("Tasks").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("title")], rows: vec![ Row::cells(["First"]).ticking("t-1", false), Row::cells(["Second"]).ticking("t-2", false), ], more: None, }) .with(Node::Act(Act::new( "Archive", Action::post("/tasks/archive"), ))) .with(Node::Act(Act::new("Purge", Action::post("/tasks/purge")))), ), ); // The caret starts on the first row, so two Tabs is past both and onto the // first act. runtime.key(Key::Tab); runtime.key(Key::Tab); let area = Rect::new(0, 0, 40, 12); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let lit: String = (0..area.height) .map(|y| marked(&buf, y, Modifier::REVERSED)) .collect(); assert!( lit.contains("Archive"), "the caret's own control is lit: {lit:?}" ); assert!( !lit.contains("Purge"), "and the one after it is not: {lit:?}" ); // The caret is where the drawing says it is: Enter calls Archive. let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the focused control calls its route"); }; assert_eq!(request.path, "/tasks/archive"); } #[test] fn a_control_that_asks_for_a_value_draws_the_box_and_sends_what_is_typed() { // `033ff3ca`. MNW's bulk bar reveals a box behind "Set Price"; a terminal // has no disclosure to press, so the box stands above the control and the // press sends what is in it along with the ticks. let mut runtime = Runtime::new( Screen::sidebar_content("Items").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::list([Row::new("First").ticking("i-1", false)])) .with(Node::Act( Act::new("Set Price", Action::post("/items/price")) .over("chosen") .asking(Field::new( layout::FieldKind::Number, "price", "New price ($)", )), )), ), ); let drawn = held(&runtime, 60, 12); assert!(drawn.contains("New price ($)"), "{drawn}"); // Tick the row, then walk onto the box, type into it, and press the verb. runtime.key(Key::Char(' ')); runtime.key(Key::Tab); for ch in "12".chars() { runtime.key(Key::Char(ch)); } runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the control fires with what it asked for"); }; assert_eq!(request.path, "/items/price"); assert_eq!(request.payload.get("price"), Some("12")); assert_eq!( request .payload .get_all(quasi_router::Node::TICKED) .collect::>(), ["i-1"] ); } #[test] fn a_commit_control_says_how_many_it_would_act_on() { // Not sayable in the description: the ticks are the host's until something // submits them, so a screen built from the store cannot carry the number. // `bulk-actions.js` writes "3 selected" into its bar; this renderer holds // the set itself and puts the count on the control it belongs to. let mut runtime = Runtime::new( Screen::sidebar_content("Tasks").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::list([ Row::new("First").ticking("t-1", false), Row::new("Second").ticking("t-2", false), ])) .with(Node::Act( Act::new("Complete", Action::post("/tasks/complete")).over("chosen"), )), ), ); // Nothing ticked: the control says its own words and no number. let empty = held(&runtime, 60, 12); assert!(empty.contains("Complete"), "{empty}"); assert!(!empty.contains("Complete ("), "{empty}"); runtime.key(Key::Char(' ')); let one = held(&runtime, 60, 12); assert!(one.contains("Complete (1)"), "{one}"); runtime.key(Key::Tab); runtime.key(Key::Char(' ')); let two = held(&runtime, 60, 12); assert!(two.contains("Complete (2)"), "{two}"); } #[test] fn a_commit_control_over_an_empty_selection_does_nothing_when_pressed() { let mut runtime = Runtime::new( Screen::sidebar_content("Tasks").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::list([Row::new("First").ticking("t-1", false)])) .with(Node::Act( Act::new("Complete", Action::post("/tasks/complete")) .key("c") .over("chosen"), )), ), ); // Onto the control, past the row, and press it with nothing ticked. Before // this the route was called and the handler answered "0 tasks completed", // which is a screen letting the reader find out by trying. runtime.key(Key::Tab); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); // The key that reaches it is refused for the same reason. assert!(matches!(runtime.key(Key::Char('c')), Step::Idle)); // Tick one and it works again. runtime.key(Key::Tab); runtime.key(Key::Char(' ')); assert_eq!( calling(&runtime.key(Key::Char('c'))), Some("/tasks/complete") ); } #[test] fn a_tick_is_staged_and_never_a_write() { // Wiki `explicit-commit-affordance`: a change that happens with no obvious // indication is confusing, so space stages and the commit control locks it // in. A row carrying `toggle` is the other case and still writes. let mut runtime = Runtime::new( Screen::sidebar_content("Mail").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::list([Row::new("First").ticking("m-1", false)])), ), ); assert!( matches!(runtime.key(Key::Char(' ')), Step::Idle), "a tick calls no route" ); let out = held(&runtime, 40, 6); assert!(out.contains("[x]"), "{out}"); } #[test] fn a_tickable_row_that_names_nothing_still_binds_no_key() { // The dead affordance `5f2b8753` was filed for, one step earlier: a row // that can be ticked and says nothing about what the tick contributes has // nowhere to put it, so the key stays unbound rather than being bound to // nothing. let mut runtime = Runtime::new(Screen::sidebar_content("Mail").selecting("chosen").with( Slot::new("main", RegionKind::Pane).with(Node::list([Row::new("First").selectable(false)])), )); assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); let out = held(&runtime, 40, 6); assert!(!out.contains("[x]"), "{out}"); } #[test] fn a_described_tick_starts_the_set_off_and_the_user_can_take_it_back() { // A description can say a row arrives ticked, and after that the user's // ticks are the truth -- the same rule `39057019` settled for a field. let mut runtime = Runtime::new( Screen::sidebar_content("Mail").selecting("chosen").with( Slot::new("main", RegionKind::Pane) .with(Node::list([Row::new("First").ticking("m-1", true)])) .with(Node::Act( Act::new("Archive", Action::post("/mail/archive")).over("chosen"), )), ), ); let out = held(&runtime, 40, 6); assert!(out.contains("[x]"), "{out}"); // Untick it and the set is empty, not the description's -- which the // commit control now shows by going inert rather than by calling its route // with nothing in it. Both are the view winning over the description; this // is the one that does not make the reader find out by pressing. runtime.key(Key::Char(' ')); let empty = held(&runtime, 40, 6); assert!(!empty.contains("[x]"), "{empty}"); assert!(!empty.contains("Archive ("), "{empty}"); runtime.key(Key::Tab); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); } #[test] fn a_terminal_and_a_webview_agree_about_a_screens_proportions() { // The done-condition of `e0fd485e`: a screen described once, rendered by // two hosts, agreeing about its proportions. The assertion that did not // exist while each renderer held its own number. let screen = Screen::sidebar_content("Mail") .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("Folders"))) .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages"))); // A quarter of 100 columns is 25, and the webview writes the same quarter // into its grid. Read off the description rather than off either renderer. let share = screen .arrangement .share() .expect("a sidebar divides a width"); assert_eq!(share.as_percent(), 25); assert_eq!(share.of(100), 25); // And the terminal honours it rather than a number of its own: "Folders" // fits in 25 columns and "Messages" starts after them. let out = shown(&screen, 100, 6); assert!(column_of(&out, "Folders") == Some(0), "{out:?}"); assert!( column_of(&out, "Messages").is_some_and(|at| at >= 25), "the content should start after the sidebar's quarter: {out:?}" ); } #[test] fn a_narrower_share_moves_the_boundary_on_the_terminal_too() { let screen = Screen::new( "Mail", layout::Arrangement::sidebar_content().with_share(layout::Share::percent(10)), ) .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("F"))) .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages"))); let out = shown(&screen, 100, 6); assert!( column_of(&out, "Messages").is_some_and(|at| (10..25).contains(&at)), "the boundary should follow the described share: {out:?}" ); } #[test] fn a_reading_measure_narrows_the_screen_and_centres_it() { // The terminal's answer to `Measure`, and the one with a reason outside // taste: past roughly 75 characters a line costs the reader the return // sweep. let wide = Screen::sidebar_content("Doc") .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words"))); let reading = wide.clone().measured(layout::Measure::Reading); let full = shown(&wide, 120, 4); let narrowed = shown(&reading, 120, 4); let at = |rows: &[String]| column_of(rows, "Words"); assert!( at(&narrowed) > at(&full), "a narrowed screen is centred, so its content starts further in: \ {full:?} then {narrowed:?}" ); } #[test] fn a_terminal_narrower_than_the_measure_is_left_alone() { // There is no measure to enforce when the window is already tighter than // it, and padding one would waste the only columns there are. let screen = Screen::sidebar_content("Doc") .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words"))) .measured(layout::Measure::Reading); let out = shown(&screen, 40, 4); // Drawn at all, and not squeezed into a centred column of a window that is // already narrower than the cap. assert!( column_of(&out, "Words").is_some_and(|at| at < 15), "{out:?}" ); } #[test] fn an_app_binding_fires_from_a_screen_that_knows_nothing_about_it() { // The whole claim chrome makes: the key works here, and here never // declared it. let mut runtime = Runtime::new(screen_of([Node::text("anywhere")])) .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette"))); assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette")); } #[test] fn an_app_binding_beats_a_control_that_wanted_the_same_key() { // Order, not preference: a screen that could capture the palette's key is // a screen on which the palette is not available everywhere. let mut runtime = Runtime::new(screen_of([Node::Act( Act::new("New", Action::get("/new")).key("k"), )])) .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette"))); assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette")); } #[test] fn a_field_keeps_the_printable_key_a_binding_wanted() { // A binding cannot make a letter untypeable. The field has the keyboard, // so the character goes in the box. let mut runtime = Runtime::new(screen_of([Node::Field(Box::new(Field::new( layout::FieldKind::Text, "q", "Query", )))])) .with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette"))); assert!(matches!(runtime.key(Key::Char('k')), Step::Idle)); // Through the runtime's own view: what was typed lives there, not in the // description. let drawn = held(&runtime, 40, 6); assert!(drawn.contains('k'), "{drawn:?}"); } #[test] fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() { let mut runtime = Runtime::new(screen_of([Node::text("underneath")])); // Two navigations, because history holds where you *were*: the first // records where we are and the second pushes it behind us. Now Escape has // something to consume if an overlay is wrongly treated as a navigation. for path in ["/two", "/three"] { runtime.apply( &Request::get(path), Response { outcome: Outcome::Screen(screen_of([Node::text("a place")])), notice: None, address: None, invalidates: Vec::new(), }, ); } runtime.apply( &Request::get("/palette"), Response { outcome: Outcome::Over(screen_of([Node::text("palette")])), notice: None, address: None, invalidates: Vec::new(), }, ); assert!(runtime.overlaid()); // Escape closes the overlay and calls nothing: history was never touched. assert!(matches!(runtime.key(Key::Escape), Step::Idle)); assert!(!runtime.overlaid()); // And the history the overlay did not consume is still there. assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two")); } #[test] fn dismissing_an_overlay_leaves_the_screen_under_it_exactly_as_it_was() { // The regression a shared `View` would produce: the user's typing and the // control they had walked to would come back changed, or not at all. let mut runtime = Runtime::new(screen_of([ Node::Field(Box::new(Field::new( layout::FieldKind::Text, "title", "Title", ))), Node::Act(Act::new("Save", Action::post("/save"))), ])); runtime.key(Key::Char('h')); runtime.key(Key::Char('i')); // Walk off the field, so focus is somewhere the overlay could disturb. runtime.key(Key::Tab); runtime.apply( &Request::get("/palette"), Response { outcome: Outcome::Over(screen_of([Node::Field(Box::new(Field::new( layout::FieldKind::Text, "q", "Query", )))])), notice: None, address: None, invalidates: Vec::new(), }, ); // The overlay's own view: typing here must not reach the screen beneath. runtime.key(Key::Char('z')); runtime.key(Key::Escape); // Focus came back where it was left: Enter calls Save rather than sitting // in the field. assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); let drawn = held(&runtime, 40, 8); assert!( drawn.contains("hi"), "the typing survived the overlay: {drawn:?}" ); assert!( !drawn.contains('z'), "the overlay's typing stayed in the overlay: {drawn:?}" ); } #[test] fn a_navigation_takes_the_overlay_with_it() { // Arriving somewhere new with a palette still floating over it is the // state nobody asked for. let mut runtime = Runtime::new(screen_of([Node::text("first")])); runtime.apply( &Request::get("/palette"), Response { outcome: Outcome::Over(screen_of([Node::text("palette")])), notice: None, address: None, invalidates: Vec::new(), }, ); runtime.apply( &Request::get("/two"), Response { outcome: Outcome::Screen(screen_of([Node::text("second")])), notice: None, address: None, invalidates: Vec::new(), }, ); assert!(!runtime.overlaid()); } /// A carousel: three captioned frames, the first up, no label anywhere. fn gallery() -> Slot { Slot::widget("shots", "carousel") .extend((0..3).map(|n| { Node::Image(quasi_router::Image::new( format!("/shot-{n}.png"), format!("shot {n}"), )) })) .showing_one(0) } /// Draw a screen with a view that has been moved. fn with_view(screen: &Screen, view: &View, width: u16, height: u16) -> Vec { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); tui().screen(screen, view, area, &mut buf); rows(&buf) } #[test] fn a_carousel_is_one_frame_and_a_row_rather_than_a_stack() { // `c0b63ea9`'s terminal half, and it was never a drawing change: until // `Showing` existed a terminal had no way to learn that a stack of pictures // was meant to be one picture, so it honestly drew the stack. let screen = Screen::list_detail("Product", false).with(gallery()); let out = shown(&screen, 90, 12).join("\n"); assert!(out.contains("shot 0"), "{out}"); assert!(!out.contains("shot 1"), "{out}"); assert!(out.contains("< Prev >"), "{out}"); assert!(out.contains("1 / 3"), "{out}"); assert!(out.contains("< Next >"), "{out}"); } #[test] fn the_row_is_under_the_frame_and_overlays_nothing() { // The reason the chrome ports at all. A terminal cannot honestly overlay // anything, so the arrows a browser drew over the picture had no form here // -- and Max had already called them cluttered in the browser. let screen = Screen::list_detail("Product", false).with(gallery()); let out = shown(&screen, 90, 12); let frame = out.iter().position(|row| row.contains("shot 0")); let row = out.iter().position(|row| row.contains("< Prev >")); assert!(frame < row, "{out:?}"); } #[test] fn a_terminal_draws_the_same_chrome_without_knowing_what_a_carousel_is() { // The whole design in one assertion: the widget's name is changed and the // chrome is identical, because nothing in this renderer reads it. let named = Screen::list_detail("Product", false).with(gallery()); let mut other = gallery(); other.kind = RegionKind::Widget { name: "lookbook".into(), }; let unnamed = Screen::list_detail("Product", false).with(other); assert_eq!(shown(&named, 90, 12), shown(&unnamed, 90, 12)); } #[test] fn the_arrow_keys_move_a_carousel_the_caret_can_never_be_inside() { // A frame is a picture, so nothing in a carousel is reachable and focus can // never land in it. That is why `moving` falls back to the first such region // rather than only ever asking where the caret is. let screen = Screen::list_detail("Product", false).with(gallery()); let mut view = View::new(); let slot = screen.slots[0] .find("shots") .expect("the carousel is there"); view.show_by(slot, 1); let out = with_view(&screen, &view, 90, 12).join("\n"); assert!(out.contains("shot 1"), "{out}"); assert!(out.contains("2 / 3"), "{out}"); } #[test] fn moving_past_the_last_frame_wraps() { // `View::advance`'s reason: a terminal has nothing to show you that you are // at the end, so a next key that stops dead reads as a broken key. let screen = Screen::list_detail("Product", false).with(gallery()); let slot = screen.slots[0] .find("shots") .expect("the carousel is there"); let mut view = View::new(); view.show_by(slot, -1); assert_eq!(view.shown(slot), Some(2)); view.show_by(slot, 1); assert_eq!(view.shown(slot), Some(0)); } #[test] fn labelled_children_draw_a_strip_above_the_pane_it_opens() { // `6af6810e`. A tab group had a kind and no way to say which tab was open or // what it was called, so this drew the first and used the slot id as a // heading. Both halves are answered by the same member. let screen = Screen::list_detail("Project", false).with( Slot::new("detail", RegionKind::TabGroup) .frame( "Overview", Node::Region( Slot::new("overview", RegionKind::Pane).with(Node::text("the summary")), ), ) .frame( "Files", Node::Region(Slot::new("files", RegionKind::Pane).with(Node::text("the files"))), ) .showing_one(1), ); let out = shown(&screen, 60, 16); let joined = out.join("\n"); assert!(joined.contains("Overview"), "{joined}"); assert!(joined.contains("Files"), "{joined}"); // The open tab's pane, and only it. assert!(joined.contains("the files"), "{joined}"); assert!(!joined.contains("the summary"), "{joined}"); // A strip, not a counter row. assert!(!joined.contains("< Prev >"), "{joined}"); let strip = out.iter().position(|row| row.contains("Overview")); let pane = out.iter().position(|row| row.contains("the files")); assert!(strip < pane, "{out:?}"); } #[test] fn a_closed_disclosure_draws_its_name_and_nothing_under_it() { // `871e7f21`, which turns out to be `AtMostOne` and not a member of its own. let disclosure = |shown: Option| { Screen::list_detail("Item", false).with( Slot::widget("more", "disclosure") .frame( "Technical details", Node::Region(Slot::new("body", RegionKind::Pane).with(Node::text("the rest"))), ) .showing_at_most_one(shown), ) }; let closed = shown(&disclosure(None), 60, 12).join("\n"); assert!(closed.contains("Technical details"), "{closed}"); assert!(!closed.contains("the rest"), "{closed}"); let open = shown(&disclosure(Some(0)), 60, 12).join("\n"); assert!(open.contains("the rest"), "{open}"); } #[test] fn a_region_showing_everything_draws_what_it_always_drew() { // The additive claim, checked from the other renderer's side too. Nothing // written before `Showing` existed changes. let screen = Screen::list_detail("Tasks", false).with( Slot::new("main", RegionKind::Pane) .with(Node::text("first")) .with(Node::text("second")), ); let out = shown(&screen, 40, 12).join("\n"); assert!(out.contains("first") && out.contains("second"), "{out}"); assert!(!out.contains("< Prev >"), "{out}"); } /// One of every shape that could plausibly cache a size. /// /// A field at each [`layout::Width`], a table with mixed /// [`layout::Priority`], a list that says how much more there is, and a nested /// region. Written out here rather than shared with the other two renderers' /// copies of it: the fixture is a few lines and sharing it would mean a new /// public surface on a crate for the sake of a test. fn every_shape_that_could_cache() -> Screen { Screen::sidebar_content("Any width").with( Slot::new("main", RegionKind::Pane) .with(Node::Field(Box::new(Field::new( layout::FieldKind::Text, "wide", "Wide", )))) .with(Node::Field(Box::new( Field::new(layout::FieldKind::Text, "tight", "Tight").width(layout::Width::Content), ))) .with(Node::Field(Box::new( Field::new(layout::FieldKind::Text, "held", "Held").width(layout::Width::Fixed), ))) .with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![ Column::new("Name").priority(layout::Priority::Essential), Column::new("Kind").priority(layout::Priority::Secondary), Column::new("Added").priority(layout::Priority::Optional), ], rows: vec![Row::cells(["kick.wav", "sample", "2026-08-12"])], more: Some(Rest::more(1, Action::get("/samples?from=1"))), }) .with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: Vec::new(), rows: vec![Row::new("one"), Row::new("two")], more: Some(Rest::more(2, Action::get("/rows?from=2"))), }) .with(Node::Region( Slot::group("nested").with(Node::text("inside")), )), ) } #[test] fn a_narrow_terminal_draws_the_same_thing_however_it_got_narrow() { // "Any width, one answer", `makeover-layout` 0.27.4. The same description // at the same width is the same picture, whatever widths came before it. // // The renderer and the view are made once and reused across the sequence, // which is the half that matters: a fresh `Tui` per draw could not fail // this test however much geometry it kept. What it catches is a renderer // that remembers -- a cutoff cached on the first pass, a column measure // stored beside the theme -- and that is exactly the shape of the bug that // makes an ordinary page's sidebar depend on the order you dragged the // window. let screen = every_shape_that_could_cache(); let tui = tui(); let view = View::new(); let draw = |width: u16| { let area = Rect::new(0, 0, width, 24); let mut buf = Buffer::empty(area); tui.screen(&screen, &view, area, &mut buf); rows(&buf) }; let cold = draw(40); for width in [120, 200, 40, 12, 400] { let _ = draw(width); } assert_eq!(draw(40), cold); // And the fixture is one that narrowing actually bites, or the assertion // above would be true of an empty screen. assert_ne!(draw(120), cold); } #[test] fn a_region_narrows_by_dropping_the_members_that_said_they_could_go() { // What a table has been able to say since the beginning, said by a band. // The shipped audiofiles toolbar hand-rolled this with `width < 900` and // `width < 700`, and hand-rolling is what makes a layout depend on the // width it came from. let screen = Screen::sidebar_content("Toolbar").with( Slot::new("bar", RegionKind::Band) .with(Node::text("Library")) .with_ranked(Node::text("Filter"), layout::Priority::Secondary) .with_ranked(Node::text("Sort"), layout::Priority::Optional), ); let wide = shown(&screen, 100, 8).join("|"); assert!(wide.contains("Sort"), "{wide}"); assert!(wide.contains("Filter"), "{wide}"); let middling = shown(&screen, 70, 8).join("|"); assert!(!middling.contains("Sort"), "{middling}"); assert!(middling.contains("Filter"), "{middling}"); let narrow = shown(&screen, 30, 8).join("|"); assert!(!narrow.contains("Sort"), "{narrow}"); assert!(!narrow.contains("Filter"), "{narrow}"); // Essential never drops, whatever it costs. A region that cannot say what // it is is not a narrower region. assert!(narrow.contains("Library"), "{narrow}"); } #[test] fn a_member_inserted_above_the_cut_does_not_change_what_drops() { // The terminal half of the property. `makeover-tui`'s table has had this // test for columns; positional narrowing passes the first assertion and // fails this one, which is why it is worth writing twice. let bar = |extra: bool| { let mut slot = Slot::new("bar", RegionKind::Band).with(Node::text("Library")); if extra { slot = slot.with(Node::text("Inserted")); } Screen::sidebar_content("Toolbar") .with(slot.with_ranked(Node::text("Sort"), layout::Priority::Optional)) }; let without = shown(&bar(false), 70, 8).join("|"); let with = shown(&bar(true), 70, 8).join("|"); assert!(!without.contains("Sort"), "{without}"); assert!(!with.contains("Sort"), "{with}"); assert!(with.contains("Inserted"), "{with}"); } #[test] fn an_awaiting_control_is_pressed_once_and_refuses_the_second_press() { // `d8d6f380`. A terminal has no browser to lock a button for it, and the // second press is the one that buys the same thing twice. let mut runtime = Runtime::new(screen_of([ Node::Act(Act::new("Buy", Action::post("/checkout").awaiting())), Node::Act(Act::new("Cancel", Action::post("/cancel"))), ])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/checkout")); assert_eq!( runtime.key(Key::Enter), Step::Idle, "the same control, still waiting" ); // The rest of the screen keeps working: one control is busy, the app is // not. runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/cancel")); // The answer arrives and the control is offered again. runtime.apply( &Request::post("/checkout"), Response::fragment("main", Node::text("Bought")), ); assert!(runtime.awaiting().is_none()); } #[test] fn a_control_with_no_mark_is_never_locked() { // Nothing here decides that a route is slow. Only a described wait locks // anything, so every screen written before this existed behaves as it did. let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( "Save", Action::post("/save"), ))])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); } #[test] fn a_call_the_host_makes_is_refused_here_rather_than_made_wrongly() { // `a81384d4`. The address behind a described upload is a signing endpoint // that answers JSON, reached by a browser that then PUTs the file // somewhere else. This host knows none of that, so it says so instead of // asking for a screen it would be handed a signature for. let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( "Upload", Action::post("/api/upload/presign").by_host(), ))])); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); let said = shown(runtime.screen(), 60, 8).join(" "); assert!(said.contains("cannot do that here"), "[{said}]"); } #[test] fn a_navigating_act_pushes_a_screen_and_takes_the_overlay_with_it() { // `00ee7af5`. The webview's anchor and this are the same sentence: the whole // screen is being replaced, so the call is made and whatever is floating // over the place being left is put away on the way out rather than left // hanging over wherever the answer lands. let mut runtime = Runtime::new(screen_of([Node::text("discover")])); runtime.apply( &Request::get("/discover/suggest"), Response { outcome: Outcome::Over(screen_of([Node::Act(Act::new( "Slow Reader", Action::get("/p/slow-reader").navigating(), ))])), notice: None, address: None, invalidates: Vec::new(), }, ); assert!(runtime.overlaid()); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/p/slow-reader")); // Before the answer, which is the point: nothing has come back yet and the // overlay is already gone. assert!(!runtime.overlaid()); } #[test] fn an_ordinary_act_leaves_the_overlay_where_it_is() { // The mark is what puts the overlay away, and an act without one is the act // it was: a call from inside a palette that answers a fragment still // answers it into the palette. let mut runtime = Runtime::new(screen_of([Node::text("discover")])); runtime.apply( &Request::get("/discover/suggest"), Response { outcome: Outcome::Over(screen_of([Node::Act(Act::new( "Refine", Action::get("/discover/refine"), ))])), notice: None, address: None, invalidates: Vec::new(), }, ); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/discover/refine")); assert!(runtime.overlaid()); } #[test] fn a_call_that_goes_elsewhere_is_performed_where_it_stands() { // goingson `3fb2526a`. A mount of its own in a terminal would be a split or // a tab, which is this renderer's furniture rather than the description's, // so the mark is read and the call is made here. Asserted rather than left // implicit: the alternative failure is the `by_host` one above -- a control // that is drawn, is reachable and does nothing when pressed. let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( "Open in a window", Action::get("/compose/7").elsewhere(), ))])); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/compose/7")); } #[test] fn pressing_a_tab_whose_panel_is_a_route_asks_for_it() { // `dfbc88ce`, the terminal half. The browser puts the address on the strip // button and htmx fires on the press; here the press is a key and the host // performs what comes back, which is the same division of labour. let mut runtime = Runtime::new( Screen::sidebar_content("Library").with( Slot::new("tab-content", RegionKind::TabGroup) // The shown panel came with the screen, so it names no call: // `9b958e7b` forbids the placeholder, and a region that has its // content is not waiting for any. .frame( "Purchases", Node::Region( Slot::new("purchases", RegionKind::Pane) .with(Node::text("what you bought")), ), ) .frame( "Feed", Node::Region( Slot::new("feed", RegionKind::Pane) .fed_by(Action::get("/library/tabs/feed")), ), ) .showing_one(0), ), ); // Nothing is asked for on the way up: the shown panel came with the screen // and the other one is unpressed, not waiting. assert!(runtime.feeds().is_empty(), "{:?}", runtime.feeds()); let step = runtime.key(Key::Right); let Step::Call(request) = step else { panic!("pressing a tab asks for its panel, got {step:?}"); }; assert_eq!(request.path, "/library/tabs/feed"); runtime.apply( &request, Response::fragment("feed", Node::text("what your creators posted")), ); // Asserted on the screen rather than on the drawing: `shown` here paints // through a fresh `View`, so it shows the description's own current child // and not the one this runtime moved to. let panel = runtime .screen() .slots .iter() .find_map(|slot| slot.find("feed")) .expect("the panel is still on the screen"); assert!( panel.asked_for().is_none(), "a panel that arrived is not asked for again" ); // Back to a tab already read, and back again: neither asks. A panel that // has arrived is not asked for a second time, which is what going back to // a tab means. assert!(matches!(runtime.key(Key::Left), Step::Idle)); assert!(matches!(runtime.key(Key::Right), Step::Idle)); } #[test] fn a_region_fed_by_a_call_is_asked_for_and_then_stops_asking() { // What the browser does with a trigger per region, done by the host here // because a terminal has nobody to do it. let mut runtime = Runtime::new(Screen::sidebar_content("Payments").with( Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()), )); let feeds = runtime.feeds(); assert_eq!(feeds.len(), 1); assert_eq!(feeds[0].path, "/dashboard/payouts"); // The stand-in is on the screen while it is out. let waiting = shown(runtime.screen(), 40, 8).join(" "); assert!(waiting.contains("Loading"), "{waiting}"); runtime.apply( &feeds[0].clone(), Response::fragment("payouts", Node::text("$12.00")), ); let filled = shown(runtime.screen(), 40, 8).join(" "); assert!(filled.contains("$12.00"), "{filled}"); assert!(runtime.feeds().is_empty(), "a filled region asks again"); } #[test] fn work_handed_off_leaves_the_region_waiting_and_the_screen_where_it_was() { // `dc2f2b46`. goingson's "Create Backup": the write is offloaded, the route // answers that it started, and the region's own live call is what reports // the finish. Nothing about the rest of the screen moves. let mut runtime = Runtime::new( Screen::sidebar_content("Import & Export").with( Slot::new("backups", RegionKind::Pane) .fed_by(Action::get("/backups")) .live(), ), ); // The region arrives the ordinary way first, so what this test starts from // is a region holding content rather than one that never had any. runtime.apply( &Request::get("/backups"), Response::fragment("backups", Node::text("3 backups")), ); let before = shown(runtime.screen(), 40, 8).join(" "); assert!(before.contains("3 backups"), "{before}"); runtime.apply( &Request::post("/backups/create"), Response::started("backups", "Creating backup…"), ); // A terminal draws its wait in words, off the readiness axis, the same as // for a region that has simply not arrived yet. let waiting = shown(runtime.screen(), 40, 8).join(" "); assert!(waiting.contains("Loading"), "{waiting}"); assert!(!waiting.contains("3 backups"), "{waiting}"); // Still on the same screen: this is not a navigation and not an overlay. assert_eq!(runtime.screen().title, "Import & Export"); // The cadence survived, which is the half that makes the finish reportable // at all. let refreshes = runtime.screen().refreshes(); assert_eq!(refreshes.len(), 1); assert_eq!(refreshes[0].destination.route(), Some("/backups")); // And the finish is an ordinary fragment. runtime.apply( &Request::get("/backups"), Response::fragment("backups", Node::text("4 backups")), ); let done = shown(runtime.screen(), 40, 8).join(" "); assert!(done.contains("4 backups"), "{done}"); } #[test] fn work_handed_off_to_a_region_that_is_not_there_says_so() { // The same treatment a fragment naming a missing region gets, because it is // the same description bug: a route naming a slot the screen does not have. let mut runtime = Runtime::new( Screen::sidebar_content("Import & Export") .with(Slot::new("backups", RegionKind::Pane).with(Node::text("3 backups"))), ); runtime.apply( &Request::post("/backups/create"), Response::started("archives", "Creating backup…"), ); let area = Rect::new(0, 0, 60, 10); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let out = rows(&buf); assert!( out.iter().any(|row| row.contains("3 backups")), "the screen still draws: {out:?}" ); assert!( out.iter().any(|row| row.contains("archives")), "the miss is reported rather than swallowed: {out:?}" ); } #[test] fn a_live_region_is_re_asked_on_the_cadence_and_not_faster() { // The half `feeds` deliberately does not do. A feed is cleared as it lands // and a cadence is not, so the pacing has to live somewhere, and it lives // here rather than in every host that draws a live screen. let mut runtime = Runtime::new( Screen::sidebar_content("Admin").with( Slot::new("queue", RegionKind::Pane) .fed_by(Action::get("/admin/queue")) .live(), ), ); // A live call is never a feed, so a host performing feeds asks for nothing. assert!(runtime.feeds().is_empty()); assert!(runtime.is_live()); let start = std::time::Instant::now(); let first = runtime.refreshes_at(start); assert_eq!(first.len(), 1); assert_eq!(first[0].path, "/admin/queue"); // Asked again a frame later, which is what an event loop does: nothing, or // the rate would be the loop's rather than this crate's. assert!( runtime .refreshes_at(start + std::time::Duration::from_millis(16)) .is_empty() ); // The answer landing leaves the region live, which is what `replace` // learned, so the next period asks again. runtime.apply( &first[0].clone(), Response::fragment("queue", Node::text("4 waiting")), ); assert_eq!(runtime.refreshes_at(start + crate::CADENCE).len(), 1); } #[test] fn a_still_screen_refreshes_nothing() { let mut runtime = Runtime::new( Screen::sidebar_content("Payments") .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts"))), ); assert!(!runtime.is_live()); assert!(runtime.refreshes().is_empty()); assert_eq!(runtime.feeds().len(), 1, "a still region is still a feed"); } #[test] fn a_live_region_with_no_call_re_asks_the_screens_own_address() { // The audiofiles sync panel: state the host already holds, moved by an // OAuth callback landing in another process. There is no fragment to fetch, // so re-reading it is building the description again, which is the address // the screen came from. let mut runtime = Runtime::new( Screen::sidebar_content("Sync").with( Slot::new("sync", RegionKind::Pane) .live() .with(Node::text("Authenticating")), ), ); assert!(runtime.is_live()); // Nothing to ask until the runtime knows where the screen came from, which // is what `apply` records. A first screen handed straight to `new` has no // address, and inventing one would be a route this crate made up. assert!(runtime.refreshes().is_empty()); let home = Request::get("/sync"); runtime.apply( &home, Response::from( Screen::sidebar_content("Sync").with( Slot::new("sync", RegionKind::Pane) .live() .with(Node::text("Needs encryption")), ), ), ); let due = runtime.refreshes_at(std::time::Instant::now() + crate::CADENCE); assert_eq!(due.len(), 1); assert_eq!(due[0].path, "/sync"); } #[test] fn the_control_that_is_waiting_keeps_its_label_and_gains_the_mark() { // Disabled, plus the activity mark: `5db1e0ed`, wiki // `loading-and-progress-standard`. The lock is rule 4 and stays -- a // control that refuses a second press is already saying something. What it // could not say is that a wait is running at all, which on a slow call is // the difference between a control working and a control dead. // // The **label** is what must not change. This test used to assert the whole // drawing kept its width, which the mark deliberately breaks; what that was // protecting is that a pressed control does not re-word itself under the // reader's cursor, and that still holds. let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( "Buy", Action::post("/checkout").awaiting(), ))])); let before = shown(runtime.screen(), 40, 6); assert!( before.iter().any(|row| row.contains("< Buy >")), "{before:?}" ); runtime.key(Key::Enter); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6)); runtime.draw(&tui(), Rect::new(0, 0, 40, 6), &mut buf); let after = rows(&buf); assert!( after.iter().any(|row| row.contains("< Buy > #")), "the label is untouched and the mark sits beside it: {after:?}" ); } #[test] fn a_wait_with_a_size_draws_more_than_a_wait_without_one() { // The done condition of `5db1e0ed`: the amount was described, carried // through the runtime, and dropped at the draw, so `Awaiting::of` and // `Awaiting::unmeasured` produced identical output. let sized = |action: Action| { let mut runtime = Runtime::new(screen_of([Node::Act(Act::new("Upload", action))])); runtime.key(Key::Enter); let mut buf = Buffer::empty(Rect::new(0, 0, 60, 6)); runtime.draw(&tui(), Rect::new(0, 0, 60, 6), &mut buf); rows(&buf).join("\n") }; let unmeasured = sized(Action::post("/upload").awaiting()); let measured = sized(Action::post("/upload").awaiting_amount(41_943_040)); assert_ne!(unmeasured, measured, "two waits, two drawings"); assert!(measured.contains("41943040"), "{measured}"); assert!(!unmeasured.contains("41943040"), "{unmeasured}"); } // ── The frame a mount puts around a screen ── #[test] fn a_mount_that_declares_no_frame_draws_what_it_always_drew() { // The default has to be the old picture, or every host that puts a screen // up changes what it paints when this arrives. let screen = screen_of([Node::text("body")]); let area = Rect::new(0, 0, 40, 8); let mut plain = Buffer::empty(area); tui().screen(&screen, &View::new(), area, &mut plain); let mut framed = Buffer::empty(area); tui().framed(&screen, &Frame::new(), &View::new(), area, &mut framed); assert_eq!(plain, framed); } #[test] fn a_frames_verbs_are_drawn_under_the_screen_and_can_be_reached() { // goingson's compose window. The verbs belong to the mount, so they are // reachable from the screen inside it without the screen describing them. let mut runtime = Runtime::new(screen_of([Node::field(Field::new( layout::FieldKind::Text, "subject", "Subject", ))])) .with_frame( Frame::new() .offering(Act::new("Send", Action::post("/compose/send"))) .offering(Act::new("Discard", Action::post("/compose/discard"))), ); // The screen's own control first, then the frame's, which is the order the // drawing counts in. let reaches = runtime.reaches(); assert_eq!(reaches.len(), 3); assert_eq!(reaches[1].region, crate::focus::FRAME_REGION); assert_eq!(reaches[2].region, crate::focus::FRAME_REGION); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8)); runtime.draw(&tui(), Rect::new(0, 0, 40, 8), &mut buf); let painted = rows(&buf).join(" "); assert!(painted.contains("Send"), "{painted}"); assert!(painted.contains("Discard"), "{painted}"); // And pressing one calls it. Two Tabs from the field is the first verb. runtime.key(Key::Tab); assert_eq!( calling(&runtime.key(Key::Enter)), Some("/compose/send"), "the caret reached the frame's verb" ); } #[test] fn a_banner_rests_in_a_reporting_frame_and_a_toast_still_floats() { // The status line without a channel: `Screen::notices` already carries the // messages, and a reporting mount changes where one of the two kinds lands. let mut screen = screen_of([Node::text("body")]); screen .notices .push(Node::banner(layout::Tone::Danger, "Not sent")); let area = Rect::new(0, 0, 40, 10); let frame = Frame::new().reporting(); let mut buf = Buffer::empty(area); tui().framed(&screen, &frame, &View::new(), area, &mut buf); let painted = rows(&buf); // Drawn, and at the bottom rather than at the top where an unframed // screen's notices go. let at = painted .iter() .position(|row| row.contains("Not sent")) .expect("the banner is drawn"); let body = painted .iter() .position(|row| row.contains("body")) .expect("the screen is drawn"); assert!(at > body, "{painted:?}"); } #[test] fn the_focus_walk_and_the_drawing_count_the_same_things_with_a_frame() { // The same invariant as the unframed walk, extended over the member that // put a second walk in reach of it. One `Pass` draws both, so a restart // between them would light a verb whenever the caret was on the screen's // first control. let screen = screen_of([ Node::Act(Act::new("Save", Action::post("/save"))), Node::field(Field::new(layout::FieldKind::Text, "name", "Name")), ]); let frame = Frame::new() .offering(Act::new("Send", Action::post("/send"))) .offering(Act::new("Gone", Action::post("/gone")).disabled()) .offering(Act::new("Discard", Action::post("/discard"))); let expected = crate::focus::reaches_framed(&screen, &frame, &Local::none()).len(); // Two on the screen and two of the three verbs: a disabled verb is drawn // and not stopped on, which is the rule every other control follows. assert_eq!(expected, 4); let area = Rect::new(0, 0, 60, 20); let draw = |view: &View| { let mut buf = Buffer::empty(area); tui().framed(&screen, &frame, view, area, &mut buf); buf }; let mut past = View::new(); past.focus_on(expected, expected + 1); let unlit = draw(&past); for at in 0..expected { let mut view = View::new(); view.focus_on(at, expected); assert_ne!( draw(&view), unlit, "focusing {at} of {expected} changed nothing" ); } } // ── The panel the app keeps on screen ── #[test] fn an_app_that_declares_no_panel_draws_what_it_always_drew() { // The default has to be the old picture, or every host paints something new // the moment this member arrives. let screen = screen_of([Node::text("body")]); let area = Rect::new(0, 0, 40, 8); let mut plain = Buffer::empty(area); tui().framed(&screen, &Frame::new(), &View::new(), area, &mut plain); let mut chromed = Buffer::empty(area); tui().chromed( &screen, &Frame::new(), &Chrome::new(), &View::new(), area, &mut chromed, ); assert_eq!(plain, chromed); } #[test] fn the_tab_line_is_the_top_row_and_marks_where_the_screen_says_it_is() { // `71aa29b4`. A terminal has no tab bar, so the renderer decides, and the // decision is the very top: the places read before anything under them. use quasi_router::Place; let chrome = Chrome::new() .offering(Place::new("work", "Work", Action::get("/tasks")).within([ Place::new("tasks", "Tasks", Action::get("/tasks")), Place::new("board", "Board", Action::get("/board")), ])) .offering(Place::new("time", "Time", Action::get("/day"))); let runtime = Runtime::new(screen_of([Node::text("body")]).at_place("board")).with_chrome(chrome); let area = Rect::new(0, 0, 60, 12); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let out = rows(&buf); assert!(out[0].contains("Work"), "{out:?}"); assert!(out[0].contains("Time"), "{out:?}"); // Two levels are two rows, and the second holds the current tab's places // and nobody else's: every sub-place of every tab would spend a terminal's // rows on navigation. assert!(out[1].contains("Board"), "{out:?}"); assert!(out[1].contains("Tasks"), "{out:?}"); // Above the screen, which is the whole placement decision. let body = out .iter() .position(|row| row.contains("body")) .expect("the screen is drawn"); assert!(body > 1, "{out:?}"); } #[test] fn a_band_puts_the_app_name_on_the_tab_line_and_its_search_box_under_it() { use quasi_router::{Band, Brand, Place}; let chrome = Chrome::new() .offering(Place::new("discover", "Discover", Action::get("/discover"))) .banded( Band::new() .branded(Brand::new("Makenot.work", Action::get("/")).marking(".")) .searching(Field::new(layout::FieldKind::Text, "q", "Search")) // Ignored here, which is what the vocabulary says a renderer // with no notion of "not enough room" does. .disclosing(quasi_router::Disclose::Narrow), ); let runtime = Runtime::new(screen_of([Node::text("body")]).at_place("discover")).with_chrome(chrome); let area = Rect::new(0, 0, 60, 12); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let out = rows(&buf); // The name at the head of the tab line, whole: a terminal has one typeface // and no way to make a glyph graphic. assert!(out[0].contains("Makenot.work"), "{out:?}"); assert!(out[0].contains("Discover"), "{out:?}"); // The box under it, and the screen under that. assert!(out[1].contains("Search"), "{out:?}"); let body = out .iter() .position(|row| row.contains("body")) .expect("the screen is drawn"); assert!(body > 1, "{out:?}"); } #[test] fn a_band_is_walked_in_the_order_it_is_drawn() { // The invariant this renderer keeps everywhere: the caret walk and the // drawing read one order, so the highlighted thing is the thing the reader // is looking at. Brand, places, then the box. use quasi_router::{Band, Brand, Place}; let chrome = Chrome::new() .offering(Place::new("discover", "Discover", Action::get("/discover"))) .banded( Band::new() .branded(Brand::new("Makenot.work", Action::get("/"))) .searching(Field::new(layout::FieldKind::Text, "q", "Search")), ); let mut runtime = Runtime::new(screen_of([Node::text("body")])).with_chrome(chrome); // The first stop is the brand, which goes home. assert_eq!(calling(&runtime.key(Key::Enter)), Some("/")); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/discover")); // And the third is the box, which takes characters rather than firing. runtime.key(Key::Tab); assert!(runtime.editing(), "the search box is not a stop"); } #[test] fn an_app_with_no_nav_gets_the_rows_it_always_got() { // The default has to be the old behaviour or every terminal app loses a row // the moment this member arrives. let area = Rect::new(0, 0, 60, 12); let painted = |runtime: &Runtime| { let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); rows(&buf) }; let plain = painted(&Runtime::new(screen_of([Node::text("body")]))); let chromed = painted(&Runtime::new(screen_of([Node::text("body")])).with_chrome(Chrome::new())); assert_eq!(plain, chromed); } #[test] fn a_place_is_somewhere_the_caret_can_stop() { // A tab line the user can read and cannot reach would be worse than no tab // line: the walk that reaches every other control has to reach these too. use quasi_router::Place; let mut runtime = Runtime::new(screen_of([Node::text("body")]).at_place("time")) .with_chrome(Chrome::new().offering(Place::new("time", "Time", Action::get("/day")))); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/day")); } #[test] fn two_panels_stack_with_the_apps_condition_nearest_the_edge() { // Declaration order within a role, activity above status. A terminal's // status line is the bottom row, so that is where a `Status` panel goes. let runtime = Runtime::new(screen_of([Node::text("body")])).with_chrome( Chrome::new() .presenting("sync", quasi_router::Role::Status, Node::text("Synced")) .presenting( "timer", quasi_router::Role::Activity, Node::text("00:12:04"), ), ); let area = Rect::new(0, 0, 60, 12); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let out = rows(&buf); let timer = out .iter() .position(|row| row.contains("00:12:04")) .expect("the activity band is drawn"); let sync = out .iter() .position(|row| row.contains("Synced")) .expect("the status band is drawn"); // Declared the other way round, so this is the role deciding and not the // order. assert!(timer < sync, "{out:?}"); } #[test] fn the_panel_is_drawn_under_the_frame_and_survives_a_navigation() { // goingson's running-timer widget. It belongs to the app, so it is on the // screen without any screen describing it, and it is still there after the // screen under it has been replaced. let mut runtime = Runtime::new(screen_of([Node::text("body")])) .with_frame(Frame::new().offering(Act::new("Send", Action::post("/compose/send")))) .with_chrome(Chrome::new().presenting( "timer", quasi_router::Role::Activity, Node::text("00:12:04"), )); let area = Rect::new(0, 0, 60, 12); let painted = |runtime: &Runtime| { let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); rows(&buf) }; let out = painted(&runtime); let panel = out .iter() .position(|row| row.contains("00:12:04")) .expect("the panel is drawn"); let verb = out .iter() .position(|row| row.contains("Send")) .expect("the frame is drawn"); let body = out .iter() .position(|row| row.contains("body")) .expect("the screen is drawn"); // The lifetimes stack: the screen, the mount's frame, then the app's panel. assert!(body < verb && verb < panel, "{out:?}"); runtime.apply( &Request::get("/elsewhere"), Screen::sidebar_content("Elsewhere") .with(Slot::new("main", RegionKind::Pane).with(Node::text("elsewhere"))) .into(), ); let out = painted(&runtime); assert!(out.iter().any(|row| row.contains("elsewhere")), "{out:?}"); assert!(out.iter().any(|row| row.contains("00:12:04")), "{out:?}"); } #[test] fn a_panels_control_is_reachable_after_the_frames_verbs_and_calls_what_it_says() { let mut runtime = Runtime::new(screen_of([Node::field(Field::new( layout::FieldKind::Text, "subject", "Subject", ))])) .with_frame(Frame::new().offering(Act::new("Send", Action::post("/compose/send")))) .with_chrome(Chrome::new().presenting( "timer", quasi_router::Role::Activity, Node::Act(Act::new("Stop", Action::post("/timer/stop"))), )); // The screen's field, the frame's verb, then the panel's control, which is // the order the drawing counts in. let reaches = runtime.reaches(); assert_eq!(reaches.len(), 3); assert_eq!(reaches[1].region, crate::focus::FRAME_REGION); // Under the panel's own id rather than a reserved name: a panel has an // address because an answer aims at it. assert_eq!(reaches[2].region, "timer"); runtime.key(Key::Tab); runtime.key(Key::Tab); assert_eq!( calling(&runtime.key(Key::Enter)), Some("/timer/stop"), "the caret reached the panel's control" ); } #[test] fn an_answer_aimed_at_the_panel_lands_in_it_rather_than_being_reported_missing() { // How a timer ever moves: the panel carries an address, so a route that has // changed what it says reaches it the way it reaches any other region. let mut runtime = Runtime::new(screen_of([Node::text("body")])).with_chrome(Chrome::new().presenting( "timer", quasi_router::Role::Activity, Node::text("00:12:04"), )); runtime.apply( &Request::post("/timer/tick"), Response::fragment("timer", Node::text("00:12:05")), ); let area = Rect::new(0, 0, 40, 8); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let out = rows(&buf); assert!(out.iter().any(|row| row.contains("00:12:05")), "{out:?}"); // A description bug is still reported: the panel is one address, not a // catch-all for everything the screen does not have. assert!( !out.iter().any(|row| row.contains("nothing on this screen")), "{out:?}" ); } /// A fill that paints one word per row, for as many rows as it was made with. /// /// Stands in for what a host actually puts in a bespoke region -- a media /// transport, a canvas -- with the one property the tests are about: it draws /// where it was put and it knows its own height. struct Words { text: &'static str, rows: u16, } impl crate::Fill for Words { fn rows(&self, _tui: &Tui, _width: u16) -> u16 { self.rows } fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) -> u16 { let mut used = 0; while used < self.rows && used < area.height { makeover_tui::text::draw( self.text, ratatui::style::Style::default().fg(tui.theme().content_primary), crate::below(area, used), buf, ); used += 1; } used } } /// A screen holding one bespoke region with a described heading in it. fn with_a_transport() -> Screen { Screen::sidebar_content("Library") .with(Slot::handover("player", "media-transport").with(Node::section("Episode 4"))) } #[test] fn a_bespoke_region_draws_the_host_fill_under_the_blocks_the_description_owns() { // Decision 4's terminal half, and the counterpart to `Webview::with_fill`: // the renderer hands the space over. The ordering is the arrangement // `Containment::Opaque` describes -- a heading the description owns above a // canvas it does not. let screen = with_a_transport(); let area = Rect::new(0, 0, 40, 10); let mut buf = Buffer::empty(area); tui() .with_fill( "player", Words { text: "PLAYING", rows: 2, }, ) .screen(&screen, &View::new(), area, &mut buf); let out = rows(&buf); let heading = out .iter() .position(|row| row.contains("Episode 4")) .expect("the described heading draws"); let fill = out .iter() .position(|row| row.contains("PLAYING")) .expect("the host's fill draws"); assert!(heading < fill, "the fill goes under the described blocks"); assert_eq!( out.iter().filter(|row| row.contains("PLAYING")).count(), 2, "the fill drew the rows it asked for" ); } #[test] fn a_bespoke_region_with_no_fill_draws_what_the_description_says_and_stops() { // What every host that offers no fill gets, and what this renderer did for // every host before `with_fill` existed. let out = shown(&with_a_transport(), 40, 10); assert!(out.iter().any(|row| row.contains("Episode 4"))); assert!(!out.iter().any(|row| row.contains("PLAYING"))); } #[test] fn a_fill_named_against_a_pane_is_ignored() { // A host reaching into a region the description already owns. The webview // keeps the same rule, and it is why the fill is not simply "markup for // this id". let screen = Screen::sidebar_content("Library") .with(Slot::new("player", RegionKind::Pane).with(Node::text("described"))); let area = Rect::new(0, 0, 40, 10); let mut buf = Buffer::empty(area); tui() .with_fill( "player", Words { text: "PLAYING", rows: 1, }, ) .screen(&screen, &View::new(), area, &mut buf); assert!(!rows(&buf).iter().any(|row| row.contains("PLAYING"))); } #[test] fn a_fill_naming_a_slot_the_screen_does_not_have_draws_nowhere() { let screen = with_a_transport(); let area = Rect::new(0, 0, 40, 10); let mut buf = Buffer::empty(area); tui() .with_fill( "elsewhere", Words { text: "PLAYING", rows: 1, }, ) .screen(&screen, &View::new(), area, &mut buf); assert!(!rows(&buf).iter().any(|row| row.contains("PLAYING"))); } #[test] fn a_fill_counts_toward_the_rows_its_region_wants() { // The reason `Fill` answers a height rather than only drawing: the scroll // arithmetic reads `height`, and a region reported shorter than what is on // the screen stops scrolling before the fill's last row. // Ceded rather than a handover, so the baseline is a region that really // does draw nothing when it has no fill. An unfilled handover spends rows // saying the fill is missing, which the test below is about. let slot = Slot::ceded("player", "revenue-chart").with(Node::section("Episode 4")); let bare = crate::region::height(&tui(), &slot, 40, &Local::none()); let filled = crate::region::height( &tui().with_fill( "player", Words { text: "PLAYING", rows: 3, }, ), &slot, 40, &Local::none(), ); assert_eq!(filled, bare + 3); } #[test] fn an_unfilled_handover_says_so_and_an_unfilled_ceded_region_stays_quiet() { // The whole of why `RegionKind` has two opaque members. Both are filled by // the host; only one of them is owed a fill, and a renderer that has none // owes the reader different answers. let handover = Slot::handover("player", "media-transport"); let ceded = Slot::ceded("chart", "revenue-chart"); let handover_rows = crate::region::height(&tui(), &handover, 40, &Local::none()); let ceded_rows = crate::region::height(&tui(), &ceded, 40, &Local::none()); assert!( handover_rows > ceded_rows, "an unfilled handover spends rows saying the fill is missing; \ a ceded region has nothing missing to say" ); // And the notice goes away once the host supplies what it owed. let supplied = crate::region::height( &tui().with_fill( "player", Words { text: "PLAYING", rows: 3, }, ), &handover, 40, &Local::none(), ); assert_eq!(supplied, ceded_rows + 3); } #[test] fn a_stopwatch_draws_the_time_that_has_passed() { // The clock is the renderer's, so the description carries only the instant // and the test moves it rather than moving time. let started = std::time::SystemTime::now() - std::time::Duration::from_secs(3845); let lines = drawn(&Node::since(started), 20, 1); assert_eq!(lines[0], "1:04:05"); } #[test] fn a_readout_sits_in_a_row_beside_the_rest_of_it() { // goingson's measured shape: the elapsed time is one part of a task row // next to its title, not a block of its own. let started = std::time::SystemTime::now() - std::time::Duration::from_secs(65); let row = Row::new("Write the brief").part(layout::RowPart::Meta, Node::since(started)); let lines = drawn(&Node::list([row]), 40, 1); assert!( lines[0].contains("Write the brief") && lines[0].contains("0:01:05"), "{lines:?}" ); } #[test] fn a_stamp_reads_coarsely_and_a_countdown_reads_down() { let ago = std::time::SystemTime::now() - std::time::Duration::from_secs(10_800); assert_eq!(drawn(&Node::age(ago), 20, 1)[0], "3h ago"); let due = std::time::SystemTime::now() + std::time::Duration::from_secs(59); // One second of slack: the clock moves between building the instant and // drawing it, and a test that demanded the exact second would fail on a // busy machine roughly once a minute. assert!( ["0:00:59", "0:00:58"].contains(&drawn(&Node::until(due), 20, 1)[0].as_str()), "{:?}", drawn(&Node::until(due), 20, 1) ); } #[test] fn a_toast_goes_away_on_its_own_and_a_banner_stays() { // `4453bf82`. The description says which of the two a message is and never // says how long a toast keeps: the when is this renderer's, so this is the // terminal being the thing that takes it away. let mut runtime = Runtime::new(screen_of([Node::text("Tasks")])); let request = Request::post("/tasks/1/done"); runtime.apply( &request, Response::from(Outcome::Fragment { region: "main".into(), node: Node::text("Done"), }) .toast(layout::Tone::Success, "Task completed"), ); runtime.apply( &request, Response::from(Outcome::Fragment { region: "main".into(), node: Node::text("Done"), }) .banner(layout::Tone::Danger, "Sync is failing"), ); assert_eq!(runtime.screen().notices.len(), 2); let start = std::time::Instant::now(); // Nothing goes early, and the host is told when to come back. assert!(!runtime.expires_at(start)); assert_eq!(runtime.screen().notices.len(), 2); assert!( runtime .tick_in_at(start) .is_some_and(|wait| wait <= crate::LINGER) ); assert!(runtime.expires_at(start + crate::LINGER + std::time::Duration::from_secs(1))); let left = &runtime.screen().notices; assert_eq!(left.len(), 1, "{left:?}"); assert!( matches!(&left[0], Node::Notice { text, .. } if text == "Sync is failing"), "the banner is the one that stays: {left:?}" ); // Nothing left on a clock, so a host with a still screen may block again. assert!(!runtime.expires_at(start + crate::LINGER * 4)); assert_eq!(runtime.tick_in_at(start), None); } #[test] fn a_toast_arriving_on_a_screen_lingers_from_when_the_screen_did() { // The other way a toast joins a screen: described onto one rather than said // by a response. It starts its linger when the screen arrives, which is // when the user could first have read it. let mut runtime = Runtime::new(screen_of([Node::text("Tasks")])); let arriving = Screen::sidebar_content("Tasks") .saying(Node::Notice { kind: layout::Notice::Toast, tone: layout::Tone::Info, text: "Welcome back".into(), act: None, }) .with(Slot::new("main", RegionKind::Pane).with(Node::text("Today"))); runtime.apply(&Request::get("/tasks"), Response::screen(arriving)); let landed = std::time::Instant::now(); assert!(!runtime.expires_at(landed)); assert_eq!(runtime.screen().notices.len(), 1); assert!(runtime.expires_at(landed + crate::LINGER + std::time::Duration::from_secs(1))); assert!(runtime.screen().notices.is_empty()); assert_eq!(runtime.tick_in_at(landed), None); } #[test] fn a_screen_says_how_long_a_host_may_wait_before_drawing_it_again() { let at = std::time::SystemTime::UNIX_EPOCH; let still = Screen::sidebar_content("Tasks") .with(Slot::new("body", RegionKind::Pane).with(Node::text("Write the brief"))); assert_eq!(crate::Runtime::new(still).tick_in(), None); let stamped = Screen::sidebar_content("Tasks") .with(Slot::new("body", RegionKind::Pane).with(Node::age(at))); assert_eq!(crate::Runtime::new(stamped).tick_in(), Some(crate::COARSE)); // The finest of the kinds on the screen, so one timeout serves both and // neither readout is drawn late. let both = Screen::sidebar_content("Tasks").with( Slot::new("body", RegionKind::Pane) .with(Node::age(at)) .with(Node::since(at)), ); assert_eq!(crate::Runtime::new(both).tick_in(), Some(crate::TICK)); } /// The table a row's controls are reached from: two rows that open, each with /// an Edit and a Remove in its last cell. fn table_with_controls() -> Screen { let row = |id: &str, name: &str| { Row::cells([ Cell::new(name), Cell::acts([ Act::new("Edit", Action::post(format!("/files/{id}/edit"))), Act::new("Remove", Action::post(format!("/files/{id}/remove"))), ]), ]) .activate(Action::get(format!("/files/{id}"))) }; Screen::sidebar_content("Files").with(Slot::new("main", RegionKind::Pane).with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("name"), Column::new("")], rows: vec![row("1", "kick.wav"), row("2", "snare.wav")], more: None, })) } #[test] fn a_control_in_a_cell_is_reached_by_stepping_into_the_row() { // `27f2331e`, the whole of it. Thirty rows across the MNW templates carry a // control in a cell, and a terminal could draw every one of them and reach // none: the table is laid out by `makeover_tui::table`, which answers no // coordinates back, so a cell can never be a stop of its own. Max ruled the // row is one stop and a key gets inside it. let mut runtime = Runtime::new(table_with_controls()); // The caret starts on the first row, and the row still opens. let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the row under the caret opens"); }; assert_eq!(request.path, "/files/1"); // Right steps in, onto the first control in the row and not the second. runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the control the caret stepped onto fires"); }; assert_eq!(request.path, "/files/1/edit"); } #[test] fn the_controls_inside_a_row_cycle_and_escape_steps_back_out() { let mut runtime = Runtime::new(table_with_controls()); runtime.key(Key::Right); runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the second control fires"); }; assert_eq!(request.path, "/files/1/remove"); // Cycling, not stopping. A terminal has nothing to show you that you are on // the last control, so a key that stopped dead would read as a broken key. runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the run wraps round to its first control"); }; assert_eq!(request.path, "/files/1/edit"); // Left is the same run the other way. runtime.key(Key::Left); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("Left cycles back"); }; assert_eq!(request.path, "/files/1/remove"); // And Escape puts the caret back on the row, which opens again. runtime.key(Key::Escape); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the row opens once the caret has stepped out"); }; assert_eq!(request.path, "/files/1"); } #[test] fn leaving_a_row_leaves_the_control_the_caret_was_on() { // Stepping in is a move within one stop, so the moment the caret is on // another stop there is nothing to be inside of. Without this, tabbing off // a row and back onto it would land on the button rather than the row, and // Enter would remove a file the reader meant to open. let mut runtime = Runtime::new(table_with_controls()); runtime.key(Key::Right); runtime.key(Key::Tab); runtime.key(Key::BackTab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the row is what the caret came back to"); }; assert_eq!(request.path, "/files/1"); } #[test] fn each_row_holds_its_own_controls() { // The row is the stop and the controls are inside it, so stepping into the // second row reaches the second row's Remove and not the first's. let mut runtime = Runtime::new(table_with_controls()); runtime.key(Key::Tab); runtime.key(Key::Right); runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the second row's second control fires"); }; assert_eq!(request.path, "/files/2/remove"); } #[test] fn a_row_whose_only_affordance_is_a_control_in_a_cell_is_reachable() { // The third case `focus.rs` counts, after opening and ticking. A row that // neither opens nor ticks used to contribute no stop at all, which put its // Remove button behind a row the caret could not land on. let mut runtime = Runtime::new(Screen::sidebar_content("Files").with( Slot::new("main", RegionKind::Pane).with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("name"), Column::new("")], rows: vec![Row::cells([ Cell::new("kick.wav"), Cell::acts([Act::new("Remove", Action::post("/files/1/remove"))]), ])], more: None, }), )); runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("a row that only carries a control is still a stop"); }; assert_eq!(request.path, "/files/1/remove"); } #[test] fn the_control_the_caret_stepped_onto_is_the_one_drawn_lit() { // The two-step order has no ring around a rect to give, because there is no // rect: the row takes the table's own highlight and the control inside it // takes the focus style. A reader who cannot see which of Edit and Remove // Enter would press has been given a keystroke and no answer. let mut runtime = Runtime::new(table_with_controls()); runtime.key(Key::Right); runtime.key(Key::Right); // Wide enough that the cell is not truncated. What is being asserted is // which control the style landed on, and a narrow window would have the // assertion failing over the table's own eliding. let area = Rect::new(0, 0, 80, 10); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let lit: String = (0..area.height) .map(|y| marked(&buf, y, Modifier::REVERSED)) .collect(); assert!( lit.contains("Remove"), "the caret's control is lit: {lit:?}" ); assert!(!lit.contains("Edit"), "and its neighbour is not: {lit:?}"); } #[test] fn a_list_row_keeps_its_controls_beside_it_rather_than_inside_it() { // The other half of the ruling, and a regression: a terminal draws a list // itself and knows where every part of a line ended up, so a list row's // controls are stops of their own and Tab still reaches them. Only a table // has an inside. let mut runtime = Runtime::new(Screen::sidebar_content("Files").with( Slot::new("main", RegionKind::Pane).with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: Vec::new(), rows: vec![ Row::new("kick.wav") .activate(Action::get("/files/1")) .act(Act::new("Remove", Action::post("/files/1/remove"))), ], more: None, }), )); // One Tab, not a step inside. runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("a list row's control is the next stop"); }; assert_eq!(request.path, "/files/1/remove"); // And Right on the row does nothing, because there is nothing to step into. runtime.key(Key::BackTab); runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the row still opens"); }; assert_eq!(request.path, "/files/1"); } #[test] fn a_disabled_control_in_a_cell_is_drawn_and_stepped_past() { // What `disabled` means on every host, said one node further in. The row is // still a stop because it opens; the control in it is not one of the places // the caret can step onto. let mut runtime = Runtime::new(Screen::sidebar_content("Files").with( Slot::new("main", RegionKind::Pane).with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("name"), Column::new("")], rows: vec![ Row::cells([ Cell::new("kick.wav"), Cell::acts([ Act::new("Restore", Action::post("/files/1/restore")).disabled(), Act::new("Remove", Action::post("/files/1/remove")), ]), ]) .activate(Action::get("/files/1")), ], more: None, }), )); runtime.key(Key::Right); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the first control the caret can stand on fires"); }; assert_eq!(request.path, "/files/1/remove"); } #[test] fn the_drawing_counts_a_row_the_walk_stops_on_for_its_controls_alone() { // `draw_table` and `focus.rs` decide reachability with one function now, and // this is the case that would have made them disagree: a row that neither // opens nor ticks is a stop because of what is in its cells, and a drawing // that did not count it would light every control below the table one place // early. let mut runtime = Runtime::new( Screen::sidebar_content("Files").with( Slot::new("main", RegionKind::Pane) .with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("name"), Column::new("")], rows: vec![Row::cells([ Cell::new("kick.wav"), Cell::acts([Act::new("Remove", Action::post("/files/1/remove"))]), ])], more: None, }) .with(Node::Act(Act::new("Import", Action::post("/files/import")))), ), ); // Past the row and onto the act under the table. runtime.key(Key::Tab); let area = Rect::new(0, 0, 44, 10); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let lit: String = (0..area.height) .map(|y| marked(&buf, y, Modifier::REVERSED)) .collect(); assert!( lit.contains("Import"), "the caret's control is lit: {lit:?}" ); } /// The question a field owns is asked like any other; what differs is where /// the answer goes — onto the view, beside what has been typed, rather than /// into the screen. #[test] fn a_field_that_owns_a_list_asks_for_it_and_holds_the_answer() { let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search").suggesting( Consult::new(Action::get("/discover/suggestions")) .after(std::time::Duration::from_millis(200)) .at_least(2), ), )])); assert!(matches!(runtime.key(Key::Char('r')), Step::Idle)); let Step::CallAfter { asks } = runtime.key(Key::Char('u')) else { panic!("two characters clears the floor"); }; assert_eq!(asks[0].request.path, "/discover/suggestions"); runtime.apply( &asks[0].request, Response::suggestions( "q", vec![ Candidate::new("rust-lang", "Rust"), Candidate::plain("ruby"), ], ), ); let open = runtime.view().suggesting("q").expect("a list is open"); assert_eq!(open.options.len(), 2); // Nothing is highlighted until an arrow says so, which is what leaves Enter // to the form until the reader has walked into the list. assert_eq!(open.at, None); } /// The four keys a list owns while it is open, and what picking costs. #[test] fn a_pick_writes_the_value_and_closes_the_list() { let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search") .suggests(Action::get("/discover/suggestions")), )])); let Step::CallAfter { asks } = runtime.key(Key::Char('r')) else { panic!("the field asks"); }; runtime.apply( &asks[0].request, Response::suggestions( "q", vec![ Candidate::new("rust-lang", "Rust"), Candidate::plain("ruby"), ], ), ); // Down from nothing lands on the first, and wraps rather than stopping. runtime.key(Key::Down); assert_eq!(runtime.view().suggesting("q").expect("open").at, Some(0)); runtime.key(Key::Up); assert_eq!(runtime.view().suggesting("q").expect("open").at, Some(1)); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); // The value, not the label: the pair a candidate carries stays two. assert_eq!(runtime.view().edit("q"), Some("ruby")); assert!(runtime.view().suggesting("q").is_none()); } /// Escape puts the list away before it means anything else, which is the /// innermost-thing-first rule Escape already follows. #[test] fn escape_closes_the_list_before_it_goes_back() { let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search") .suggests(Action::get("/discover/suggestions")), )])); let Step::CallAfter { asks } = runtime.key(Key::Char('r')) else { panic!("the field asks"); }; runtime.apply( &asks[0].request, Response::suggestions("q", vec![Candidate::plain("ruby")]), ); assert!(matches!(runtime.key(Key::Escape), Step::Idle)); assert!(runtime.view().suggesting("q").is_none()); // And the arrows are the focus's again the moment the list is gone. assert!(matches!(runtime.key(Key::Down), Step::Idle)); } /// A route with nothing to suggest and a route that was never asked leave the /// screen in the same state. #[test] fn an_empty_answer_opens_no_list() { let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search") .suggests(Action::get("/discover/suggestions")), )])); let Step::CallAfter { asks } = runtime.key(Key::Char('r')) else { panic!("the field asks"); }; runtime.apply(&asks[0].request, Response::suggestions("q", Vec::new())); assert!(runtime.view().suggesting("q").is_none()); } /// Deleting back under the floor takes the candidates with it: they were about /// a value that no longer earns them. #[test] fn dropping_under_the_floor_closes_the_list() { let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "q", "Search") .suggesting(Consult::new(Action::get("/discover/suggestions")).at_least(2)), )])); runtime.key(Key::Char('r')); let Step::CallAfter { asks } = runtime.key(Key::Char('u')) else { panic!("two characters clears the floor"); }; runtime.apply( &asks[0].request, Response::suggestions("q", vec![Candidate::plain("ruby")]), ); assert!(runtime.view().suggesting("q").is_some()); runtime.key(Key::Backspace); assert!(runtime.view().suggesting("q").is_none()); } /// A file leaves by `handed` rather than by the return value, and the screen /// the control was pressed on is still the screen showing. #[test] fn a_file_answer_is_handed_over_and_changes_nothing_on_screen() { let mut runtime = Runtime::new(screen_of([Node::text("settings")])); let follow_up = runtime.apply( &Request::get("/data/export/json"), Response::file( "goingson-export.json", quasi_router::Accepted::media_type("application/json"), br#"{"tasks":[]}"#.to_vec(), ), ); assert_eq!(follow_up, None); let handed = runtime.handed().expect("the answer handed a file over"); assert_eq!(handed.name, "goingson-export.json"); assert_eq!(handed.bytes, br#"{"tasks":[]}"#); assert_eq!( handed.kind, quasi_router::Accepted::Type("application/json".into()) ); // Nothing on the screen moved: a file is not a region and not a place. assert_eq!(runtime.handed(), None); } /// An ask for a place leaves by `locating`, drains once, and leaves the screen /// the control was pressed on showing. #[test] fn an_ask_for_a_place_leaves_by_locating_and_changes_nothing_on_screen() { let mut runtime = Runtime::new(screen_of([Node::text("import")])); let follow_up = runtime.apply( &Request::post("/import/open"), Response::locate(quasi_router::Locating::folder( "Import folder", Action::post("/import/from"), "folder", )), ); assert_eq!(follow_up, None); let asking = runtime.locating().expect("the answer asked for a place"); assert_eq!(asking.sought, quasi_router::Sought::Folder); assert_eq!(asking.prompt, "Import folder"); // The picker is the host's furniture: nothing was drawn, nothing navigated, // and a second drain gets the ask no second time. assert_eq!(runtime.locating(), None); // What the reader picked comes back as an ordinary request, built by the // crate that stated the parameter name rather than by the host. let answered = asking .answered([quasi_router::Picked::new("/home/max/samples", "samples")]) .expect("a route to answer to"); assert_eq!( answered, Request::post("/import/from") .sending(quasi_router::Params::new().with("folder", "/home/max/samples")) ); } /// The save shape reaches the host whole, suggested name included, because the /// name is the reason the dialog is opened rather than a folder picker. #[test] fn a_save_ask_carries_its_suggested_name_out_to_the_host() { let mut runtime = Runtime::new(screen_of([Node::text("classifier")])); let follow_up = runtime.apply( &Request::post("/classifier/open"), Response::locate(quasi_router::Locating::new( quasi_router::Sought::Save { name: "drums-2026-08-25.afcl".into(), accept: vec![quasi_router::Accepted::suffix(".afcl")], }, "Export classifier", Action::post("/classifier/export"), "path", )), ); assert_eq!(follow_up, None); let asking = runtime.locating().expect("the answer asked for a place"); let quasi_router::Sought::Save { name, accept } = &asking.sought else { panic!("a save ask"); }; assert_eq!(name, "drums-2026-08-25.afcl"); assert_eq!(accept, &[quasi_router::Accepted::Suffix(".afcl".into())]); assert_eq!(asking.prompt, "Export classifier"); let answered = asking .answered([quasi_router::Picked::new( "/home/max/exports/drums.afcl", "drums.afcl", )]) .expect("a route to answer to"); assert_eq!( answered, Request::post("/classifier/export") .sending(quasi_router::Params::new().with("path", "/home/max/exports/drums.afcl")) ); } /// Several files picked at once are one call, which is what keeps a batched /// import a batch. #[test] fn several_picked_files_answer_the_ask_once() { let mut runtime = Runtime::new(screen_of([Node::text("import")])); let follow_up = runtime.apply( &Request::post("/import/open"), Response::locate(quasi_router::Locating::new( quasi_router::Sought::Files { accept: vec![quasi_router::Accepted::suffix(".wav")], }, "Import files", Action::post("/import/files"), "path", )), ); assert_eq!(follow_up, None); let asking = runtime.locating().expect("the answer asked for a place"); let answered = asking .answered([ quasi_router::Picked::new("/tmp/a.wav", "a.wav"), quasi_router::Picked::new("/tmp/b.wav", "b.wav"), ]) .expect("a route to answer to"); assert_eq!( answered, Request::post("/import/files").sending( quasi_router::Params::new() .with("path", "/tmp/a.wav") .with("path", "/tmp/b.wav") ) ); } /// A reader who backs out of the picker has answered nothing, so the host makes /// no call and the runtime is not told. Nothing to assert but the absence, and /// the absence is the design: a cancelled picker costs the screen nothing. #[test] fn a_screen_with_no_ask_hands_out_no_place() { let mut runtime = Runtime::new(screen_of([Node::text("import")])); assert_eq!(runtime.locating(), None); } /// The name is sanitised where the host cannot forget to do it. This runtime /// writes nothing itself, so a host taking the name at face value is exactly /// the failure -- `../../.ssh/authorized_keys` beside the process. #[test] fn a_handed_file_carries_a_name_no_host_can_traverse_with() { let mut runtime = Runtime::new(screen_of([Node::text("settings")])); runtime.apply( &Request::get("/data/export"), Response::file( "../../.ssh/authorized_keys", quasi_router::Accepted::suffix(".txt"), b"ssh-rsa".to_vec(), ), ); let handed = runtime.handed().expect("the answer handed a file over"); assert!(!handed.name.contains('/')); assert!(!handed.name.contains("..")); } /// A host that never drains it drops the download, which is the cost of keeping /// the writing out of this crate. A second file replaces the first rather than /// queueing: two files from one answer is not something the vocabulary says. #[test] fn a_second_file_replaces_the_one_the_host_never_took() { let mut runtime = Runtime::new(screen_of([Node::text("settings")])); for name in ["first.json", "second.json"] { runtime.apply( &Request::get("/data/export"), Response::file( name, quasi_router::Accepted::media_type("application/json"), b"{}".to_vec(), ), ); } assert_eq!( runtime.handed().map(|handed| handed.name), Some("second.json".to_owned()) ); } #[test] fn a_control_that_deposits_a_value_puts_it_on_the_end_of_the_box_it_named() { // `f35aafee`. The act names a box on the screen and the press writes the // value into it. Where in the box is this renderer's, and a terminal has no // caret inside a field to insert at (`d52884b0`), so it goes on the end -- // which the vocabulary calls correct rather than a fallback. let mut runtime = Runtime::new(screen_of([ Node::field(Field::new(layout::FieldKind::Text, "body", "Body").value("Intro. ")), Node::Act(Act::new("kick.png", Action::local()).filling("body", "![](media/kick.png)")), ])); // Onto the control: the box is the first stop. assert!(matches!(runtime.key(Key::Tab), Step::Idle)); // Local, so nothing is called. The deposit is the whole of the press. assert!(matches!(runtime.key(Key::Enter), Step::Idle)); // After what the box was showing rather than over it. A deposit starting // from an empty string would discard the draft, which is the defect the // member was filed to stop. assert_eq!( runtime.view().edit("body"), Some("Intro. ![](media/kick.png)") ); } #[test] fn a_deposit_lands_after_what_was_typed_and_travels_with_a_later_submit() { let mut runtime = Runtime::new(screen_of([ Node::field(Field::new(layout::FieldKind::Text, "body", "Body")), Node::Act(Act::new("Insert", Action::local()).filling("body", "[img]")), ])); runtime.key(Key::Char('h')); runtime.key(Key::Char('i')); runtime.key(Key::Tab); runtime.key(Key::Enter); assert_eq!(runtime.view().edit("body"), Some("hi[img]")); } #[test] fn an_ordinary_control_deposits_nothing_into_the_boxes_beside_it() { let mut runtime = Runtime::new(screen_of([ Node::field(Field::new(layout::FieldKind::Text, "body", "Body").value("Intro.")), Node::Act(Act::new("Save", Action::post("/save"))), ])); runtime.key(Key::Tab); assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); assert_eq!(runtime.view().edit("body"), None); } /// Draw a whole screen under a view the user has already touched. fn shown_under(screen: &Screen, view: &View, width: u16, height: u16) -> Vec { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); tui().screen(screen, view, area, &mut buf); rows(&buf) } /// MNW's pay-what-you-want settings: a checkbox and the section it brings out. fn pricing() -> Screen { Screen::sidebar_content("Pricing").with( Slot::new("body", RegionKind::Pane) .with(Node::Field(Box::new(Field::new( layout::FieldKind::Checkbox, "pwyw", "Pay what you want", )))) .with(Node::Region( Slot::group("pwyw-settings") .revealed_by(quasi_router::Reveal::ticked("pwyw")) .with(Node::text("Suggested price")), )), ) } /// A terminal has no stylesheet to hide with, so a region that does not apply /// right now is left out: the honest reading of a hidden region here is that /// it is not applicable, which is what the ruling put on the region. #[test] fn a_region_whose_control_holds_nothing_is_not_drawn() { let screen = pricing(); let out = shown_under(&screen, &View::new(), 60, 12).join(" "); assert!(out.contains("Pay what you want"), "{out}"); assert!(!out.contains("Suggested price"), "{out}"); } /// And it comes out when the box is ticked, without anything being asked for. /// The section is in the description already; a round trip to reveal it would /// re-render a form the reader is midway through. #[test] fn ticking_the_control_brings_the_region_out_with_no_request() { let screen = pricing(); let mut view = View::new(); view.set("pwyw", quasi_router::Node::SELECTED); let out = shown_under(&screen, &view, 60, 12).join(" "); assert!(out.contains("Suggested price"), "{out}"); // Nothing outstanding: the region named no call and none was made. assert!(view.outstanding().is_none()); } /// An untouched control holds what the description offered it, which is how a /// section arrives already out on a form the server refilled. #[test] fn a_region_reads_the_value_the_description_offered() { let screen = Screen::sidebar_content("Licensing").with( Slot::new("body", RegionKind::Pane) .with(Node::Field(Box::new( Field::select( "license_kind", "Licence", vec![Choice::new("custom", "Custom")], ) .value("custom"), ))) .with(Node::Region( Slot::group("dash-custom-license") .revealed_by(quasi_router::Reveal::holding("license_kind", "custom")) .with(Node::text("Licence text")), )), ); let out = shown_under(&screen, &View::new(), 60, 12).join(" "); assert!(out.contains("Licence text"), "{out}"); } /// The caret and the picture read one list. A control inside a region that is /// not on the screen is not a place the caret can stop, or Tab would move the /// highlight to something nobody can see. #[test] fn the_caret_does_not_stop_inside_a_region_that_does_not_apply() { let screen = pricing(); let view = View::new(); let hidden = crate::reveal::hidden(&screen, &quasi_router::Chrome::new(), &view); assert_eq!(hidden.regions, vec!["pwyw-settings"]); // This region holds prose, so the two walks agree about the count and the // assertion below is what carries the claim. assert_eq!( crate::focus::reaches(&screen, &Local::hiding(&hidden)).len(), crate::focus::reaches(&screen, &Local::none()).len() ); // With a control in it, the two counts come apart by exactly that control. let screen = Screen::sidebar_content("Pricing").with( Slot::new("body", RegionKind::Pane) .with(Node::Field(Box::new(Field::new( layout::FieldKind::Checkbox, "pwyw", "Pay what you want", )))) .with(Node::Region( Slot::group("pwyw-settings") .revealed_by(quasi_router::Reveal::ticked("pwyw")) .with(Node::Field(Box::new(Field::new( layout::FieldKind::Text, "suggested", "Suggested price", )))), )), ); let hidden = crate::reveal::hidden(&screen, &quasi_router::Chrome::new(), &view); assert_eq!( crate::focus::reaches(&screen, &Local::hiding(&hidden)).len() + 1, crate::focus::reaches(&screen, &Local::none()).len() ); } /// What was typed into a section the reader has since closed is still theirs, /// and is still submitted. A browser sends the value of a hidden input, and one /// description submitted on two hosts has to send the same form. #[test] fn a_closed_section_keeps_what_was_typed_into_it() { let screen = Screen::sidebar_content("Pricing").with( Slot::new("body", RegionKind::Pane) .with(Node::Field(Box::new(Field::new( layout::FieldKind::Checkbox, "pwyw", "Pay what you want", )))) .with(Node::Region( Slot::group("pwyw-settings") .revealed_by(quasi_router::Reveal::ticked("pwyw")) .with(Node::Field(Box::new(Field::new( layout::FieldKind::Number, "suggested", "Suggested price", )))), )), ); let mut view = View::new(); view.set("pwyw", quasi_router::Node::SELECTED); view.set("suggested", "12"); // The reader unticks the box: the section goes, the number stays. view.set("pwyw", ""); view.prune(&screen, &Frame::new(), &quasi_router::Chrome::new()); assert_eq!(view.edit("suggested"), Some("12")); } // One conditional question inside a form: `8fdb814c`, goingson's zone picker. /// goingson's event form: which kind of zone the time is in, and the box that /// only applies to one of the kinds. The two have to sit in one form, so the /// condition is the question's own. fn zone_form() -> Screen { screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/events"), submit: "Save".into(), fields: vec![ Field::select( "tz_kind", "Time zone", vec![ Choice::new("relative", "Relative to me"), Choice::new("local", "A place"), ], ) .value("relative"), Field::new(layout::FieldKind::Text, "timezone", "Anchored to") .revealed_by(quasi_router::Reveal::holding("tz_kind", "local")), ], }]) } /// It is not on the screen and the caret does not stop on it, which are one /// answer read from one list. #[test] fn a_question_that_does_not_apply_is_neither_drawn_nor_stopped_on() { let screen = zone_form(); let view = View::new(); let chrome = quasi_router::Chrome::new(); let hidden = crate::reveal::hidden(&screen, &chrome, &view); assert_eq!(hidden.fields, vec!["timezone"]); assert!(hidden.regions.is_empty()); let out = shown_under(&screen, &view, 60, 12).join(" "); assert!(!out.contains("Anchored to"), "{out}"); assert_eq!( crate::focus::reaches(&screen, &Local::hiding(&hidden)).len() + 1, crate::focus::reaches(&screen, &Local::none()).len() ); } /// And it comes out when the control holds the value it named, with nothing /// asked of any route. #[test] fn picking_the_kind_brings_the_question_out_with_no_request() { let screen = zone_form(); let mut view = View::new(); view.set("tz_kind", "local"); let hidden = crate::reveal::hidden(&screen, &quasi_router::Chrome::new(), &view); assert!(hidden.fields.is_empty()); let out = shown_under(&screen, &view, 60, 12).join(" "); assert!(out.contains("Anchored to"), "{out}"); assert!(view.outstanding().is_none()); } /// What was typed into a question that no longer applies is still sent, which /// is what a browser does with an input inside a hidden element. #[test] fn a_question_that_stopped_applying_still_submits_what_it_holds() { let screen = zone_form(); let mut view = View::new(); view.set("tz_kind", "local"); view.set("timezone", "America/Denver"); // The reader changes their mind: the box goes, the value stays. view.set("tz_kind", "relative"); view.prune(&screen, &Frame::new(), &quasi_router::Chrome::new()); assert_eq!(view.edit("timezone"), Some("America/Denver")); } // A question answered N times: `60d1753c`, ruled 2026-08-25. /// The reminders question goingson `8fdb814c` restores. fn reminders() -> Field { Field::new(layout::FieldKind::Number, "reminder", "Reminder").repeating( quasi_router::Repeat::answered(["300", "900"]) .most(8) .adding("Add reminder") .removing("Remove"), ) } fn reminders_form() -> Screen { screen_of([Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/events"), submit: "Save".into(), fields: vec![reminders()], }]) } /// Every slot is a stop, each under its own indexed name, and the two controls /// are stops of their own. #[test] fn a_repeating_question_reaches_a_stop_per_slot_and_its_two_controls() { let screen = reminders_form(); let spots = crate::focus::spots(&screen, &Local::none()); let names: Vec = spots .iter() .filter_map(Spot::field) .map(|field| field.name.clone()) .collect(); assert_eq!(names, ["reminder[0]", "reminder[1]"]); let controls: Vec> = spots .iter() .filter_map(|spot| match spot { Spot::Repeat { at, .. } => Some(*at), _ => None, }) .collect(); // A remove under each slot, then the add: the order the drawing paints // them. assert_eq!(controls, [Some(0), Some(1), None]); // Neither calls a route. for spot in &spots { if matches!(spot, Spot::Repeat { .. }) { assert!(spot.enters().is_none()); } } } /// The third hard part in the host with no document: pressing add changes what /// the reader is holding and asks nothing of any route. #[test] fn adding_and_removing_a_slot_asks_no_route() { let mut runtime = Runtime::new(reminders_form()); // Onto the first remove control, which is the stop after the first box. runtime.key(Key::Tab); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); assert_eq!(runtime.view().standing(&reminders()), 1); // The add control is the stop before the form's submit, whatever the count // is. let to_add = |runtime: &mut Runtime| { let last = runtime.reaches().len() - 2; while runtime.view().focus() != last { runtime.key(Key::Tab); } }; to_add(&mut runtime); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); to_add(&mut runtime); assert!(matches!(runtime.key(Key::Enter), Step::Idle)); assert_eq!(runtime.view().standing(&reminders()), 3); } /// One submit carrying every instance, which is what separates this from a list /// of forms. #[test] fn one_submit_carries_every_slot() { let mut runtime = Runtime::new(reminders_form()); // Tab to the add control, which is the stop before the submit, and press // it: a third slot the description never described. let add = runtime.reaches().len() - 2; while runtime.view().focus() != add { runtime.key(Key::Tab); } runtime.key(Key::Enter); // The caret is left where it was, which is now the box that arrived. assert!(runtime.editing(), "the caret is in the new box"); for ch in "7200".chars() { runtime.key(Key::Char(ch)); } let submit = runtime.reaches().len() - 1; while runtime.view().focus() != submit { runtime.key(Key::Tab); } let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the submit calls its route"); }; assert_eq!(request.path, "/events"); assert_eq!( request.payload.repeated("reminder"), ["300", "900", "7200"], "{:?}", request.payload ); } /// The floor and the ceiling are the description's, and no press gets past /// them. A question at its ceiling offers no add control at all. #[test] fn the_floor_and_the_ceiling_hold() { let capped = Field::new(layout::FieldKind::Number, "reminder", "Reminder") .repeating(quasi_router::Repeat::answered(["300", "900"]).most(2)); let screen = screen_of([Node::field(capped.clone())]); let spots = crate::focus::spots(&screen, &Local::none()); assert!( !spots .iter() .any(|spot| matches!(spot, Spot::Repeat { at: None, .. })), "a question at its ceiling offers nothing to add" ); let floored = Field::new(layout::FieldKind::Text, "guest", "Guest") .repeating(quasi_router::Repeat::answered(["ana"]).least(1)); let screen = screen_of([Node::field(floored)]); let spots = crate::focus::spots(&screen, &Local::none()); assert!( !spots .iter() .any(|spot| matches!(spot, Spot::Repeat { at: Some(_), .. })), "a question at its floor offers nothing to remove" ); } /// Removing a slot moves the answers after it up, buffers and all: the names /// are positional, so leaving them alone would submit a hole under the name the /// reader emptied. #[test] fn removing_a_slot_moves_the_answers_after_it_up() { let field = Field::new(layout::FieldKind::Number, "reminder", "Reminder") .repeating(quasi_router::Repeat::answered(["300", "900", "3600"])); let mut view = View::new(); view.set("reminder[1]", "1800"); view.remove_slot(&field, 0); assert_eq!(view.standing(&field), 2); // The buffer moves with the answer it belongs to, and the slot that had // none falls back to what the description offered for its new place. assert_eq!(view.edit("reminder[0]"), Some("1800")); assert_eq!(view.edit("reminder[1]"), Some("3600")); assert_eq!(view.edit("reminder[2]"), None); } /// The caret and the picture read one count. The two walks are separate, so a /// slot the reader added has to reach both. #[test] fn the_drawing_and_the_walk_count_the_same_slots() { let screen = reminders_form(); let mut view = View::new(); view.add_slot(&reminders()); let nothing = crate::Hidden::none(); let local = Local::of(¬hing, &view); let stops = crate::focus::spots(&screen, &local).len(); let described = crate::focus::spots(&screen, &Local::none()).len(); // One box and one remove control more than the description described. assert_eq!(stops, described + 2); let area = Rect::new(0, 0, 60, 40); let draw = |view: &View| { let mut buf = Buffer::empty(area); tui().screen(&screen, view, area, &mut buf); buf }; let mut past = view.clone(); past.focus_on(stops, stops + 1); let unlit = draw(&past); for at in 0..stops { let mut lit = view.clone(); lit.focus_on(at, stops); assert_ne!( draw(&lit), unlit, "focusing {at} of {stops} changed nothing on the screen" ); } } /// A fragment landing elsewhere does not take the slots the reader added, and a /// question that has gone away does not leave its count behind for the next /// screen to inherit. #[test] fn a_slot_the_reader_added_survives_a_fragment() { let screen = reminders_form(); let mut view = View::new(); view.add_slot(&reminders()); view.set("reminder[2]", "7200"); view.prune(&screen, &Frame::new(), &quasi_router::Chrome::new()); assert_eq!(view.standing(&reminders()), 3); assert_eq!(view.edit("reminder[2]"), Some("7200")); // A screen with no such question at all: the count goes with it. let gone = screen_of([Node::text("nothing here")]); view.prune(&gone, &Frame::new(), &quasi_router::Chrome::new()); assert_eq!(view.standing(&reminders()), 2, "the description's count"); } /// A per-slot message is drawn against its own box, and the question's own /// error is about the set. #[test] fn a_slot_carries_its_own_error() { let mut field = Field::new(layout::FieldKind::Number, "reminder", "Reminder") .repeating(quasi_router::Repeat::answered(["300", "-1"]).wrong(1, "Must be positive")); field.error = Some("At most eight reminders".into()); let screen = screen_of([Node::field(field)]); let area = Rect::new(0, 0, 60, 30); let mut buf = Buffer::empty(area); tui().screen(&screen, &View::new(), area, &mut buf); let painted = rows(&buf).join("\n"); assert!(painted.contains("Must be positive"), "{painted}"); assert!(painted.contains("At most eight reminders"), "{painted}"); assert!(painted.contains("[ Add ]"), "{painted}"); assert!(painted.contains("Remove"), "{painted}"); } #[test] fn an_act_hint_is_the_muted_row_under_the_control() { // `ca7b5200`. A terminal has no pointer, so the sentence is a row rather // than a hover -- the same shape `piece::field` gives a field's note, so the // two read alike wherever they land on a screen. let drawn = drawn( &Node::Act( Act::new("Verify library integrity", Action::post("/verify")) .hint("The result appears in the status line."), ), 60, 3, ); assert!(drawn[0].contains("Verify library integrity"), "{drawn:?}"); assert_eq!( drawn[1], "The result appears in the status line.", "{drawn:?}" ); } #[test] fn an_act_with_no_hint_takes_one_row() { // Additive: a control written before the member existed measures and draws // exactly as it did. let drawn = drawn(&Node::Act(Act::new("Save", Action::post("/save"))), 60, 3); assert!(drawn[0].contains("Save"), "{drawn:?}"); assert_eq!(drawn[1], "", "{drawn:?}"); } #[test] fn a_copied_value_is_handed_back_to_the_host() { // `c3e145e0`. `Step::Open`'s shape and for its reason: this crate owns no // I/O, so a clipboard is the host's exactly as opening a URL is. let mut runtime = Runtime::new(screen_of([Node::Act( Act::new("Copy key", Action::local()).copying("mnw_live_abc123"), )])); assert_eq!( runtime.key(Key::Enter), Step::Copy("mnw_live_abc123".to_string()) ); } #[test] fn a_bound_key_copies_the_same_value_enter_does() { // The bound key and Enter are one press said two ways. let mut runtime = Runtime::new(screen_of([Node::Act( Act::new("Copy key", Action::local()) .copying("mnw_live_abc123") .key("c"), )])); assert_eq!( runtime.key(Key::Char('c')), Step::Copy("mnw_live_abc123".to_string()) ); } #[test] fn a_terminal_draws_a_shown_pictures_control_by_its_name() { // `db998898`. `Act::shows` is ignored here on purpose: a picture's alt text // is all a terminal has of it, and the control's label is already saying // the name. Drawing both would say it twice. use quasi_router::screen::Image; let with = Node::Act( Act::new("kick.wav", Action::get("/media/1")).showing(Image::new("/m/1.png", "kick.wav")), ); let without = Node::Act(Act::new("kick.wav", Action::get("/media/1"))); assert_eq!(drawn(&with, 40, 4), drawn(&without, 40, 4)); assert!(drawn(&with, 40, 4).concat().contains("kick.wav")); } /// The terminal's half. #[test] fn the_same_overlay_does_not_stack_on_itself() { let mut runtime = Runtime::new(screen_of([Node::text("under")])); let open = |runtime: &mut Runtime| { runtime.apply( &Request::get("/help"), Response { outcome: Outcome::Over(screen_of([Node::text("help")])), notice: None, address: None, invalidates: Vec::new(), }, ); }; for _ in 0..5 { open(&mut runtime); } assert!(runtime.overlaid()); // One Escape, not five. The second finds nothing to close, which is what // says the other four presses added no layers. runtime.key(Key::Escape); assert!(!runtime.overlaid()); } /// The guard is the top layer only, so a confirm over a palette still stacks /// and the palette's own identity comes back when the confirm is dismissed. #[test] fn a_different_overlay_still_stacks_and_unwinds_in_order() { let mut runtime = Runtime::new(screen_of([Node::text("under")])); let raise = |runtime: &mut Runtime, path: &str, label: &str| { runtime.apply( &Request::get(path), Response { outcome: Outcome::Over(screen_of([Node::text(label)])), notice: None, address: None, invalidates: Vec::new(), }, ); }; raise(&mut runtime, "/palette", "palette"); raise(&mut runtime, "/confirm", "confirm"); raise(&mut runtime, "/confirm", "confirm"); runtime.key(Key::Escape); assert!(runtime.overlaid(), "the palette is still up"); raise(&mut runtime, "/palette", "palette"); runtime.key(Key::Escape); assert!( !runtime.overlaid(), "the palette refused to stack on itself" ); } /// An anchored menu layers like an overlay and takes a different box: the /// compact one, in the half the subject is not in. A terminal has no /// coordinates, so the half is the whole of the claim -- see `Laid::Anchored`. #[test] fn an_anchored_menu_is_laid_as_a_menu_rather_than_a_palette() { let screen = Screen::sidebar_content("Files") .with(Slot::new("browser", RegionKind::Pane).with(Node::text("rows"))); let mut runtime = Runtime::new(screen); runtime.apply( &Request::get("/menu"), Response { outcome: Outcome::Anchored { screen: screen_of([Node::text("menu")]), anchor: quasi_router::Anchor::Region("browser".into()), }, notice: None, address: None, invalidates: Vec::new(), }, ); assert!(runtime.overlaid()); assert!(matches!( runtime.laid, Some(crate::runtime::Laid::Anchored { .. }) )); // Dismissal is the overlay's, unchanged: it reveals what was under it and // touches no history. assert!(matches!(runtime.key(Key::Escape), Step::Idle)); assert!(!runtime.overlaid()); assert_eq!(runtime.laid, None); } /// An anchor naming nothing on the screen it covers falls back to the overlay /// box. The menu still opens; the loss is the placement. #[test] fn an_anchor_that_names_nothing_falls_back_to_the_overlay() { let mut runtime = Runtime::new(screen_of([Node::text("rows")])); runtime.apply( &Request::get("/menu"), Response { outcome: Outcome::Anchored { screen: screen_of([Node::text("menu")]), anchor: quasi_router::Anchor::Region("nowhere".into()), }, notice: None, address: None, invalidates: Vec::new(), }, ); assert!(runtime.overlaid()); assert_eq!(runtime.laid, Some(crate::runtime::Laid::Over)); } /// The `900865dd` guard holds for the anchored member too: a binding asked /// every frame must not stack a menu per frame. #[test] fn an_anchored_menu_does_not_stack_on_the_same_request() { let mut runtime = Runtime::new(screen_of([Node::text("rows")])); let request = Request::get("/menu"); let answer = || Response { outcome: Outcome::Anchored { screen: screen_of([Node::text("menu")]), anchor: quasi_router::Anchor::Selection, }, notice: None, address: None, invalidates: Vec::new(), }; runtime.apply(&request, answer()); runtime.apply(&request, answer()); // One Escape, not two. assert!(matches!(runtime.key(Key::Escape), Step::Idle)); assert!(!runtime.overlaid()); } /// A terminal has no hover and no second surface for standing help, so this /// renderer drops a hint. Asserted rather than only documented: the /// alternatives -- appending it to the label, or borrowing the status line -- /// both look like improvements until you see what they cost, and a test is /// what stops one being tried. #[test] fn a_hint_is_dropped_because_a_terminal_has_nowhere_to_put_one() { let plain = drawn(&Node::Token(Tag::badge("Blocked")), 40, 3); let hinted = drawn( &Node::Token(Tag::badge("Blocked").hinted("3 steps away")), 40, 3, ); assert_eq!(plain, hinted); assert!(!hinted.join("").contains("3 steps away"), "{hinted:?}"); } /// A regression, and the second place it happened. `focus.rs` pushes a stop /// per direction a pager can go; the drawing claimed **one** position for the /// whole line. So a list with both directions left every control below it /// drawing the caret one place early, exactly as /// `the_drawing_counts_the_same_table_rows_the_walk_stops_on` records for a /// table of tickable rows. #[test] fn the_drawing_counts_the_same_pager_stops_the_walk_stops_on() { let mut runtime = Runtime::new( Screen::sidebar_content("Feed").with( Slot::new("main", RegionKind::Pane) .with( Node::list([Row::new("First")]).and_more( Rest::page(20, 10) .of(80) .back(Action::get("/feed?page=2")) .forward(Action::get("/feed?page=4")), ), ) .with(Node::Act(Act::new("Archive", Action::post("/a")))) .with(Node::Act(Act::new("Purge", Action::post("/p")))), ), ); // Prev, Next, and the two acts. A plain row takes no stop, and the readout // between the ends is not somewhere to go. assert_eq!(runtime.reaches().len(), 4); // Two tabs is past both ends of the pager and onto the first act. runtime.key(Key::Tab); runtime.key(Key::Tab); let area = Rect::new(0, 0, 40, 12); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let lit: String = (0..area.height) .map(|y| marked(&buf, y, Modifier::REVERSED)) .collect(); assert!( lit.contains("Archive"), "the caret's own control is lit: {lit:?}" ); assert!( !lit.contains("Purge"), "and the one after it is not: {lit:?}" ); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("the focused control calls its route"); }; assert_eq!(request.path, "/a"); } /// A terminal draws the offered pages on the line it already spends, and every /// one of them but the current is somewhere to go. #[test] fn a_pager_that_offers_pages_puts_each_of_them_on_the_one_line() { let mut runtime = Runtime::new( Screen::sidebar_content("Feed").with( Slot::new("main", RegionKind::Pane).with( Node::list([Row::new("First")]).and_more( Rest::page(20, 10) .of(80) .back(Action::get("/feed?page=2")) .forward(Action::get("/feed?page=4")) .jumping(Jump::new(2, Action::get("/feed?page=2"))) .jumping(Jump::new(3, Action::get("/feed?page=3")).here()) .jumping(Jump::new(4, Action::get("/feed?page=4"))), ), ), ), ); // Prev, page 2, page 4, Next. Page 3 is the one being read, so it is a // readout rather than a control -- and the position readout is gone, // because the strip already says which page of how many. assert_eq!(runtime.reaches().len(), 4); let area = Rect::new(0, 0, 40, 8); let mut buf = Buffer::empty(area); runtime.draw(&tui(), area, &mut buf); let drawn = rows(&buf).join(" "); assert!(drawn.contains("Prev 2 3 4 Next"), "{drawn:?}"); assert!(!drawn.contains("3 / 8"), "{drawn:?}"); // Still one line, whatever the strip carries. let mut tall = Buffer::empty(Rect::new(0, 0, 40, 8)); Runtime::new( Screen::sidebar_content("Feed").with( Slot::new("main", RegionKind::Pane) .with(Node::list([Row::new("First")]).and_more(Rest::page(20, 10).of(80))), ), ) .draw(&tui(), Rect::new(0, 0, 40, 8), &mut tall); let plain = rows(&tall); let paged = rows(&buf); assert_eq!( plain.iter().filter(|l| !l.trim().is_empty()).count(), paged.iter().filter(|l| !l.trim().is_empty()).count(), "the strip costs no extra line: {plain:?} vs {paged:?}" ); // The third stop is page 4, and pressing it goes there. runtime.key(Key::Tab); runtime.key(Key::Tab); let Step::Call(request) = runtime.key(Key::Enter) else { panic!("a page is somewhere to go"); }; assert_eq!(request.path, "/feed?page=4"); }