//! 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, Cells, Choice, Column, Field, Figure, Meter, Node, RegionKind, Row, Screen, Slot, Tag, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Modifier; use crate::{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) } #[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_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_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 { columns: vec![ Column::new("Name").priority(layout::Priority::Essential), Column::new("Added").priority(layout::Priority::Optional), ], rows: vec![Cells::new(["kick.wav", "2026-08-12"])], }; 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(), }) .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::{Key, Runtime, Step}; use quasi_router::{Address, Chrome, 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 { 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(Action::get("/more"))), Node::Select { kind: layout::Selector::Tabs, options: vec![ (Choice::plain("a"), Some(Action::get("/a"))), (Choice::plain("b"), None), ], chosen: Some("a".into()), action: None, }, Node::Table { columns: vec![Column::new("Name")], rows: vec![Cells::new(["one"]).activate(Action::get("/row"))], }, ]); let expected = crate::focus::spots(&screen).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) ); } #[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 typing_fills_a_box_and_a_form_submits_what_is_in_it() { let mut runtime = Runtime::new(screen_of([Node::Form { 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 { 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_on_the_keystroke() { // `Field::changes` says the change is the write, and a terminal has no // `input` event to debounce, so every keystroke is one call. That is the // description read literally, and the cost of reading it literally is // filed rather than papered over with a delay this renderer invented. let mut runtime = Runtime::new(screen_of([Node::field( Field::new(layout::FieldKind::Text, "query", "Search").changes(Action::post("/search")), )])); let Step::Call(request) = runtime.key(Key::Char('a')) else { panic!("a change writes"); }; assert_eq!(request.path, "/search"); assert_eq!(request.payload.get("query"), Some("a")); } #[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 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_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) .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).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_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, then commit: the set is empty, not the description's. runtime.key(Key::Char(' ')); 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).count(), 0 ); } #[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. assert_eq!(screen.arrangement.share().as_percent(), 25); assert_eq!(screen.arrangement.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::Picture::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) .with(Node::Region( Slot::new("overview", RegionKind::Pane) .label("Overview") .with(Node::text("the summary")), )) .with(Node::Region( Slot::new("files", RegionKind::Pane) .label("Files") .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") .with(Node::Region( Slot::new("body", RegionKind::Pane) .label("Technical details") .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}"); }