//! What the renderer promises, asserted. //! //! Two kinds of test here, and the second is the interesting one. The first //! checks that a description comes out as the markup it should. The second //! checks the architectural claims the design rests on — that no `hx-target` //! is ever emitted, that htmx appears in exactly one function, that a //! description's text cannot become markup — because those are the properties //! that would decay silently, one convenient exception at a time. use makeover_layout as layout; use quasi_http::Serves; use quasi_router::screen::{ Accepted, Act, Candidate, Canvas, Cell, CellKey, Choice, Column, Consult, Document, Field, Figure, Jump, Meter, Prose, Repeat, Repeating, Rest, Row, Tag, ThemeChoice, }; use quasi_router::{Action, Frame, Node, RegionKind, Reveal, Richness, Run, Screen, Slot, Trust}; use crate::{Emit, Shell, Webview}; /// A head with the screen's discovery tags removed. /// /// They are the one part of the head that comes off the `Screen` rather than /// off the `Shell`, so a comparison against the shell's own parts has to drop /// them or it is comparing two different questions. fn strip_discovery(head: &str) -> String { let mut out = String::with_capacity(head.len()); let mut rest = head; while let Some(start) = rest.find("').expect("a tag closes") + start + 1; rest = &rest[end..]; } out.push_str(rest); out } /// A band with every member in it, for the corpus the class checks read. /// /// Every class this crate emits has to be reachable from that corpus or the /// second of the two checks reads the name as dead and fails; a band with a /// brand, a search box and a disclosure is what reaches all six of the band's. fn banded() -> quasi_router::Band { quasi_router::Band::new() .branded(quasi_router::Brand::new("Makenot.work", Action::get("/")).marking(".")) .searching(Field::new(layout::FieldKind::Text, "q", "Search")) .disclosing(quasi_router::Disclose::Narrow) } fn render(screen: &Screen) -> String { Webview::new().screen(screen) } fn fragment(node: &Node) -> String { Webview::new().fragment(node) } /// 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 /// `node_html` 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_emits_markup() { // One of everything, through `node_html`. A member the walk does not know // reaches a catch-all rather than a compile error, so an empty fragment is // what silence looks like here and this is what objects to it. for node in one_of_everything() { let html = fragment(&node); assert!(!html.is_empty(), "nothing came out for {node:?}"); } } #[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 the walk keeps compiling. assert_eq!( one_of_everything().len(), 21, "one of every `Node` member, in declaration order" ); } #[test] fn a_screen_is_a_whole_document() { let html = render(&Screen::list_detail("Tasks", false)); assert!(html.starts_with("")); assert!(html.contains("Tasks")); assert!(html.ends_with("")); } #[test] fn a_fragment_is_not() { let html = fragment(&Node::text("hello")); assert!(!html.contains("hello

"); } #[test] fn the_document_configures_nothing_about_how_a_status_swaps() { // Decision 9's gap, closed by the transport rather than by the document. // htmx 4 swaps every status but 204 and 304, so the classified 403 and 404 // reach the screen with no `htmx-config` meta tag emitted for them -- the // tag 2.x needed, and the reason `responseHandling` was a required piece of // this adapter until 2026-08-18. let html = render(&Screen::list_detail("Tasks", false)); assert!(!html.contains("htmx-config"), "{html}"); assert!(!html.contains("responseHandling"), "{html}"); } #[test] fn the_title_is_escaped_into_the_head() { let html = render(&Screen::list_detail("", false)); assert!(!html.contains(""), )); assert!(!html.contains("")); assert!(html.contains("bold")); assert!(!html.contains(" in it") .with("done", "on"); let html = fragment(&Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/tasks"), submit: "Save".into(), fields: vec![ Field::new(layout::FieldKind::Text, "title", "Title").refilled(¶ms), Field::new(layout::FieldKind::Checkbox, "done", "Done").refilled(¶ms), // Nothing was submitted under this name, so it stays empty rather // than coming back as the empty string. Field::new(layout::FieldKind::Text, "notes", "Notes").refilled(¶ms), ], }); assert!(html.contains("value=\"a name with <angles> in it\"")); assert!(html.contains("checked")); // The escaping guarantee is not weakened by carrying a value. assert!(!html.contains("")); // `notes` had nothing submitted under it, so it comes back empty rather // than carrying a neighbour's value. assert!(html.contains("name=\"notes\" value=\"\"")); } #[test] fn a_secret_is_never_offered_back_however_it_was_set() { // Two halves of one guarantee. The builder refuses to store it, and the // renderer refuses to emit it, because `Field::value` is a public field and // a struct literal reaches past the builder. let params = quasi_router::Params::new().with("password", "hunter2"); let refused = Field::new(layout::FieldKind::Secret, "password", "Password").refilled(¶ms); assert_eq!(refused.value, None); let mut forced = Field::new(layout::FieldKind::Secret, "password", "Password"); forced.value = Some("hunter2".to_owned()); let html = fragment(&Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/login"), submit: "Sign in".into(), fields: vec![forced], }); assert!(!html.contains("hunter2")); } #[test] fn an_empty_region_says_so_instead_of_rendering_an_empty_box() { // `703f4cd2`. A region shows its content or a stand-in, never both, which // is `Readiness` being one axis with four values rather than two. let empty = render( &Screen::list_detail("Projects", false).with( Slot::new("list", RegionKind::Pane) .with(Node::section("Projects")) .with(Node::empty("No projects yet")), ), ); assert!(empty.contains("No projects yet")); assert!(empty.contains(r#"data-state="empty""#)); // The heading survives, which is why this is a node and not a state on the // region: a column with a heading and no rows still has a heading. assert!(empty.contains(">Projects<")); // Not announced as a fault: an empty list is the normal state of a new // install. assert!(!empty.contains(r#"role="alert""#)); } #[test] fn a_failed_region_is_a_different_state_from_an_empty_one_and_offers_a_way_out() { let failed = render( &Screen::list_detail("Events", false).with( Slot::new("list", RegionKind::Pane).with( Node::failed("Failed to load events") .offering(Act::new("Try again", Action::get("/events"))), ), ), ); assert!(failed.contains(r#"data-state="failed""#)); assert!(failed.contains(r#"data-tone="danger""#)); assert!(failed.contains(r#"role="alert""#)); // The way out is a real control, so it reaches a handler. assert!(failed.contains("hx-get=\"/events\"")); } #[test] fn a_destructive_act_asks_first_and_a_shortcut_reaches_it() { // `524a63fe` and `2daea915`. Both are facts about the control that no // renderer can derive, and goingson expressed the first by calling a JS // helper at 33 call sites. let html = fragment(&Node::Act( Act::new("Delete", Action::post("/tasks/1/delete")) .tone(layout::Tone::Danger) .confirm("Delete this task? This cannot be undone.") .key("d"), )); assert!(html.contains(r#"hx-confirm="Delete this task? This cannot be undone.""#)); assert!(html.contains(r#"accesskey="d""#)); assert!(html.contains("hx-post=\"/tasks/1/delete\"")); } #[test] fn a_row_offers_what_it_does_not_show() { // `5e02fbce`. `actions` is what the row shows; `menu` is what it offers, // opened by right-click, long-press or a key depending on the host. let html = fragment(&Node::list([Row::new("Buy milk") .act(Act::new("Done", Action::post("/tasks/1/complete"))) .offers(Act::new("Duplicate", Action::post("/tasks/1/copy"))) .offers( Act::new("Delete", Action::post("/tasks/1/delete")).confirm("Delete this task?"), )])); assert!(html.contains(r#"data-menu="row""#)); // Hidden rather than absent: the host opens it, and a menu that is not in // the document cannot be opened. assert!(html.contains(" hidden>")); assert!(html.contains("Duplicate")); assert!(html.contains(r#"hx-confirm="Delete this task?""#)); // The shown action is still shown. assert!(html.contains("Done")); } #[test] fn a_list_says_how_much_more_there_is_and_how_to_ask() { // `346567f9`. A described list of the first 50 of 400 was indistinguishable // from a described list of 50. let counted = fragment( &Node::list([Row::new("One")]) .and_more(Rest::more(50, Action::get("/tasks?page=2")).of(400)), ); assert!(counted.contains("50 of 400")); assert!(counted.contains("hx-get=\"/tasks?page=2\"")); // A count is often unknown: asking for 51 to find out whether there are // more than 50 answers the question without answering how many. let uncounted = fragment(&Node::list([Row::new("One")]).and_more(Rest::more(50, Action::get("/more")))); assert!(uncounted.contains("Show more")); assert!(!uncounted.contains(" of ")); let plain = fragment(&Node::list([Row::new("One")])); assert!(!plain.contains("rest")); } #[test] fn a_table_can_say_there_is_more_and_the_pager_is_not_a_row() { // goingson's task list, the first described table anywhere, had to hang its // paging off a separate `Node::Act` under the table because `Node::Table` // carried no `Rest`. What that lost was the renderer knowing the control // belonged to the table above it, and this is that being closed. let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Name")], rows: vec![Row::cells([Cell::new("One")])], more: Some( Rest::page(100, 50) .of(400) .back(Action::get("/tasks?page=2")) .forward(Action::get("/tasks?page=4")), ), }); assert!(html.contains("3 / 8")); assert!(html.contains("hx-get=\"/tasks?page=4\"")); // Outside the table element. A `role="table"` whose children are not rows is // a table saying something untrue about its own shape, so the pager sits // after it the way a list's sits outside the `
    `. let table_end = html.rfind("").expect("the table closes"); let pager = html.find("rest-position").expect("the pager is drawn"); assert!( pager < table_end, "the pager should follow the table's rows, not sit inside its last cell" ); let plain = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Name")], rows: vec![Row::cells([Cell::new("One")])], more: None, }); assert!(!plain.contains("rest")); } #[test] fn a_paged_list_prints_its_page_and_keeps_both_ends() { // Numbered pages read the same here as a carousel's position does, because // both are a `layout::Window` and the spelling is deliberately shared. let middle = fragment( &Node::list([Row::new("One")]).and_more( Rest::page(100, 50) .of(400) .back(Action::get("/tasks?page=2")) .forward(Action::get("/tasks?page=4")), ), ); assert!(middle.contains("3 / 8")); assert!(middle.contains("hx-get=\"/tasks?page=2\"")); assert!(middle.contains("hx-get=\"/tasks?page=4\"")); assert!(!middle.contains("disabled")); // The first page has nowhere back and draws nothing there. Ruled by Max // 2026-09-08, against the "first paint is final paint" reading that stood // before: a Prev that cannot go back is the control `control_tag` exists to // prevent, and a control that is only ever drawn when it acts is also the // one shape a residual can hold. See `rest_html`. let first = fragment( &Node::list([Row::new("One")]).and_more( Rest::page(0, 50) .of(400) .forward(Action::get("/tasks?page=2")), ), ); assert!(first.contains("1 / 8")); assert!(!first.contains("rest-previous"), "{first}"); assert!(first.contains("rest-next"), "{first}"); assert!(!first.contains("disabled"), "{first}"); // And the last page the other way round, so neither direction is the only // one this is checked on. let last = fragment( &Node::list([Row::new("One")]).and_more( Rest::page(350, 50) .of(400) .back(Action::get("/tasks?page=7")), ), ); assert!(last.contains("8 / 8")); assert!(last.contains("rest-previous"), "{last}"); assert!(!last.contains("rest-next"), "{last}"); } #[test] fn a_pager_that_offers_pages_draws_a_strip_and_marks_the_one_being_read() { // `0ce21f4b`. The description names each page's address, because a renderer // building page 5's out of prev and next would have to know the address // grammar -- the private vocabulary a conversion exists to retire. let html = fragment( &Node::list([Row::new("One")]).and_more( Rest::page(100, 50) .of(400) .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"))), ), ); assert!(html.contains("rest-pages"), "{html}"); assert!(!html.contains("hx-get=\"/feed?page=3\""), "{html}"); assert!(html.contains("hx-get=\"/feed?page=2\""), "{html}"); assert!(html.contains("hx-get=\"/feed?page=4\""), "{html}"); // The page being read is text, not a control: one that reloads the page it // is on is the lying control `control_tag` exists to prevent. assert!(html.contains("aria-current=\"page\""), "{html}"); assert!(html.contains("rest-page-here"), "{html}"); // And the readout is gone, because the strip says both numbers already. assert!(!html.contains("3 / 8"), "{html}"); assert!(!html.contains("rest-position"), "{html}"); // The ends are still there. A strip is a way to jump, not a replacement for // stepping. assert!(html.contains("rest-previous"), "{html}"); assert!(html.contains("rest-next"), "{html}"); } #[test] fn a_pager_that_offers_no_pages_prints_the_position_it_always_did() { // Empty jumps is every site that existed before this member, and it has to // emit the same bytes. let html = fragment( &Node::list([Row::new("One")]).and_more( Rest::page(100, 50) .of(400) .back(Action::get("/feed?page=2")) .forward(Action::get("/feed?page=4")), ), ); assert!(html.contains("rest-position"), "{html}"); assert!(!html.contains("rest-pages"), "{html}"); } #[test] fn a_sortable_column_says_which_way_and_offers_the_press() { // `ce620871`. The one finding that completed a member rather than adding // one: `Column` shipped with a width and a priority and could say nothing // about order. let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![ Column::new("Title") .reorder(Action::get("/tasks?sort=title")) .sorted(layout::Sort::Ascending), Column::new("Due").reorder(Action::get("/tasks?sort=due")), Column::new("Notes"), ], rows: vec![Row::cells(["Ship it", "Tomorrow", "None"])], more: None, }); assert!(html.contains(r#"aria-sort="ascending""#)); assert_eq!(html.matches("data-sortable").count(), 2); // The press is its own control inside the header cell, never the cell // itself: a `columnheader` is not a control and must not announce itself as // one. Reordering is a read, so the control is a link and is addressable. assert!(html.contains(r#""); let html = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("A", false)); let icon = html.find("/f.png").expect("the icon is linked"); let htmx = html.find("htmx.min.js").expect("htmx is linked"); assert!(htmx < icon); assert!(icon < html.find("").expect("the head closes")); } #[test] fn injected_opening_body_markup_precedes_everything_the_screen_draws() { // A skip link is the case, and the top is the only position it works from. let shell = Shell::default().with_body_first("Skip"); let html = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("A", false)); let skip = html.find("Skip").expect("the link is emitted"); assert!(html.find(""); let html = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("A", false)); let tail = html.find("/tail.js").expect("the script is emitted"); assert!(html.find("").expect("the content closes") < tail); assert!(tail < html.find("").expect("the body closes")); } #[test] fn a_document_with_nothing_to_append_is_the_one_it_was_before() { // Additive, checked rather than assumed: a shell that says nothing here // emits what it emitted before the member existed. let html = Webview::new().screen(&Screen::list_detail("A", false)); assert!(html.ends_with("")); } #[test] fn the_layer_statement_precedes_every_stylesheet() { // The whole point: a layer's position is fixed where its name is first // seen, so a statement after the links is not a statement. let shell = Shell::default() .layered(["base", "components", "responsive"]) .styled("/geometry.css") .styled("/style.css"); let html = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("A", false)); let stmt = html .find("@layer makeover, base, components, responsive;") .expect("the order is stated"); let first_sheet = html.find("/geometry.css").expect("the sheet is linked"); assert!(stmt < first_sheet); } #[test] fn the_layer_statement_is_emitted_even_with_no_app_layers() { // An app that names no layers of its own still needs makeover pinned to the // bottom of the cascade, and that is the case where forgetting is easiest. let html = Webview::new().screen(&Screen::list_detail("A", false)); assert!(html.contains("@layer makeover;")); } #[test] fn a_layer_name_cannot_escape_the_style_element() { // HTML escaping does not apply inside "]); let html = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("A", false)); assert!(!html.contains("' }"), ); let html = Webview::new().screen(&screen); // The property is that the element is not left early, so everything the // sheet carries is still raw text inside it. `' }"), "{html}"); // Broken in CSS's own vocabulary, so a reader of the string gets the // characters back rather than the sheet losing its rule. assert!(inside.contains("\\3c/style"), "{html}"); } #[test] fn the_close_sequence_is_caught_whatever_its_case() { // The HTML parser is case-insensitive about it, so this has to be. let screen = Screen::list_detail("A", false) .documented(Document::default().styled("p::after { content: '' }")); let html = Webview::new().screen(&screen); assert!(!html.contains(""), "{html}"); assert!(html.contains("\\3c/STYLE"), "{html}"); } #[test] fn a_root_attribute_named_twice_is_written_once() { // Two attributes of one name on one tag is markup a parser halves, so the // renderer picks rather than emitting both. The first statement wins. let screen = Screen::list_detail("A", false).documented( Document::default() .rooted("data-theme", "slate") .rooted("data-theme", "paper"), ); let html = Webview::new().screen(&screen); assert_eq!(html.matches("data-theme=").count(), 1, "{html}"); assert!(html.contains("data-theme=\"slate\""), "{html}"); } #[test] fn a_host_assembling_its_own_document_gets_the_same_head_as_a_screen() { // The property the split exists for. A server converting one screen at a // time renders both ways at once, and the two heads agreeing is the whole // reason its templates take the shell at all. let shell = Shell::default() .layered(["base", "components"]) .styled("/style.css") .with_head_first(""); let parts = shell.parts(); let screen = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("Console", false)); // Everything the SHELL owns is the same markup, in the same order. Two // things are not the shell's and are stripped before comparing: the title, // because the host writes it, and the screen's discovery tags, because a // host on this path has no `Screen` to read them from and writes its own // head metadata -- which is exactly what the server does today in its // `block head`. let head = screen .split("") .next() .expect("the screen has a head") .replace("Console", ""); let head = strip_discovery(&head); assert_eq!(parts.head, head); assert!(screen.contains(&format!("", parts.body_attrs))); } #[test] fn the_body_attributes_compose_with_the_hosts_own() { // Space-prefixed and never a bare `class`, so a template that writes its // own class attribute after them does not produce two. let parts = Shell::default().sending("X-CSRF-Token", "abc").parts(); assert!(parts.body_attrs.starts_with(' '), "{}", parts.body_attrs); assert!(!parts.head.contains("")); assert!(!parts.head.contains("")); // A shell told nothing owns nothing on the tag at all. assert_eq!(Shell::default().parts().body_attrs, ""); } #[test] fn a_declared_header_travels_with_every_request_the_document_makes() { // The whole point is inheritance: it goes on the body once, so a control // emitted anywhere in the document carries it without knowing it exists. let parts = Shell::default().sending("X-CSRF-Token", "abc123").parts(); // `:inherited`, because htmx 4 inherits nothing unless the attribute says // so and a bare `hx-headers` here would reach no control at all. assert_eq!( parts.body_attrs, " hx-headers:inherited=\"{"X-CSRF-Token":"abc123"}\"" ); // A shell told nothing emits nothing, byte for byte what it emitted before // this existed. assert_eq!(Shell::default().parts().body_attrs, ""); } #[test] fn a_declared_header_cannot_break_out_of_its_attribute() { // A token is opaque bytes from a host, and a host that concatenates one // from somewhere unwise should not get a way out of the tag. Same escaper // `hx-vals` uses, asserted here too because this is the one place a shell // puts host-supplied text into markup. let parts = Shell::default() .sending("X-Token", "a\"><script>alert(1)</script>") .parts(); assert!( !parts.body_attrs.contains("<script>"), "{}", parts.body_attrs ); assert!( parts.body_attrs.contains("<script>"), "{}", parts.body_attrs ); } #[test] fn headers_accumulate_in_the_order_they_were_declared() { let parts = Shell::default().sending("A", "1").sending("B", "2").parts(); assert!( parts.body_attrs.contains( "hx-headers:inherited=\"{"A":"1","B":"2"}\"" ), "{}", parts.body_attrs ); } #[test] fn a_document_that_calls_no_route_ships_no_transport() { // The five MNW embeds. An embed is an iframe on a third party's page and // carries not one `hx-` attribute, so htmx there is a script the reader // downloads and runs to do nothing. let full = Shell::default().parts(); assert!(full.head.contains("htmx.min.js")); let bare = Shell::default().without_htmx().parts(); assert!(!bare.head.contains("htmx.min.js"), "{}", bare.head); // The selection script hangs off htmx, so it goes with it rather than // being left to bind to an event nothing dispatches. assert!(!bare.head.contains("quasi-selection.js"), "{}", bare.head); assert_eq!(bare.body_attrs, ""); // Everything else a document needs is untouched: this drops a transport, // not the document chrome. assert!(bare.head.contains("@layer makeover")); } #[test] fn a_nested_region_renders_inside_its_parent() { let screen = Screen::list_detail("Tasks", false).with(Slot::new("outer", RegionKind::Pane).with( Node::Region(Slot::new("inner", RegionKind::Pane).with(Node::text("in"))), )); let html = render(&screen); let outer = html.find("id=\"outer\"").expect("outer renders"); let inner = html.find("id=\"inner\"").expect("inner renders"); assert!(outer < inner); // The inner region closes inside the outer one, with each region's own // anchor container as its last child. `ae8e8836` put those there; before it // the two closing tags were adjacent. assert!( html.contains( "in</p><div class=\"anchored\" id=\"inner-anchored\" data-menu=\"anchored\" \ hidden></div></div><div class=\"anchored\" id=\"outer-anchored\" \ data-menu=\"anchored\" hidden></div></div>" ), "{html}" ); } #[test] fn text_from_a_description_can_never_become_markup() { // The property that has to hold across every variant, because a // description's strings come from application state. Checked over the whole // tree rather than per node, so a variant added without escaping fails // here rather than in production. let hostile = "<script>alert(1)</script>"; let screen = Screen::list_detail(hostile, false) .saying(Node::banner(layout::Tone::Danger, hostile)) .with( Slot::new("s", RegionKind::Pane) .with(Node::page(hostile)) .with(Node::text(hostile)) .with(Node::act(hostile, Action::get("/x"))) .with(Node::list([Row::new(hostile) .secondary(hostile) .meta(hostile) .act(Act::new(hostile, Action::post("/y")))])) .with(Node::Token(Tag::removable(hostile, Action::get("/z")))), ); let html = render(&screen); assert!(!html.contains("<script>")); // Twelve sinks: the title, the notice, the heading, the prose, the act's // label, the row's four parts, the chip's label, and the two the title is // repeated into for a link preview -- `og:title` and `twitter:title`, which // are attribute values and escape through the same path. Counted rather // than merely checked for absence, so a variant that silently stops // rendering its text fails here too. assert_eq!(html.matches("<script>").count(), 12); } #[test] fn a_rows_plain_prose_is_escaped_exactly_as_it_always_was() { // The default case, and the one that must not change: `.secondary("...")` // still means text, and text is never markup however it is punctuated. let row = Row::new("Atlas").secondary("**not bold** <b>not bold either</b>"); let node = Node::list(vec![row]); let html = Webview::new().fragment(&node); assert!(html.contains("**not bold**"), "got: {html}"); assert!(!html.contains("<strong>"), "got: {html}"); assert!(!html.contains("<b>"), "got: {html}"); assert!(html.contains("<b>"), "got: {html}"); } #[test] fn a_rows_rich_prose_is_rendered_inline() { // The row-prose decision. The description says the string is markdown and // this renderer draws it as markdown, one line's worth. let row = Row::new("Atlas").secondary(Prose::rich("**Ships Q3.** `soon`")); let node = Node::list(vec![row]); let html = Webview::new().fragment(&node); assert!(html.contains("<strong>Ships Q3.</strong>"), "got: {html}"); assert!(html.contains("<code>soon</code>"), "got: {html}"); } #[test] fn a_rows_rich_prose_keeps_no_blocks() { // A row is one line tall. A heading, a list and a quote each contribute // their words and none of them claims a block. let row = Row::new("Borealis").secondary(Prose::rich("# Goal\n\n> ship it\n\n- one\n- two")); let node = Node::list(vec![row]); let html = Webview::new().fragment(&node); // The outer `<ul class="list">` is the list itself; what must not appear is // a second one inside the row's own span. let secondary = html .split_once(r#"<span class="row-secondary">"#) .expect("the row draws its secondary") .1 .split_once("</span>") .expect("the part closes") .0; for block in ["<h1", "<blockquote", "<ul", "<li", "<p"] { assert!(!secondary.contains(block), "no {block} in a row: {html}"); } for word in ["Goal", "ship it", "one", "two"] { assert!(html.contains(word), "{word} survives: {html}"); } } #[test] fn a_rows_rich_prose_carries_no_second_click_target() { // The row is already the target through `activate`. An anchor inside it // would be a second target inside the first. let row = Row::new("Atlas") .secondary(Prose::rich( "see [the brief](https://example.com/a/long/path)", )) .activate(Action::get("/projects/1")); let node = Node::list(vec![row]); let html = Webview::new().fragment(&node); assert!(html.contains("see the brief"), "the text survives: {html}"); assert!(!html.contains("example.com"), "no href: {html}"); // The row itself is an anchor, drawn from `activate`. That one is the // target; the assertion is that the prose did not add a second. assert_eq!( html.matches("<a ").count(), 1, "the row is the only target: {html}" ); } #[test] fn a_rows_rich_prose_cannot_smuggle_markup() { // `Prose::Rich` carries source, not markup, so the renderer decides what is // drawable. Raw HTML in the source is not. let row = Row::new("Atlas").secondary(Prose::rich( "hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))", )); let node = Node::list(vec![row]); let html = Webview::new().fragment(&node); assert!(!html.contains("<script"), "got: {html}"); assert!(!html.contains("onerror"), "got: {html}"); assert!(!html.contains("javascript:"), "got: {html}"); } #[test] fn no_control_ever_says_whether_its_answer_is_a_place() { // History is derived from the answer in quasi-http, not decided by the // control at render time. The MNW server has 24 hand-written hx-push-url // uses across 13 files, which is what asking the control looks like after // a while: each one is a prediction of what a route will do, made by the // party that does not know. let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Title").width(layout::Width::Fill)], rows: vec![ Row::cells([Cell::new("Release notes").activate(Action::get("/blog/7"))]) .activate(Action::get("/blog/7/edit")), ], more: None, }); assert!(!html.contains("push-url"), "{html}"); assert!(!html.contains("replace-url"), "{html}"); } #[test] fn a_screen_that_says_nothing_is_still_a_findable_page() { // The default has to be right, because most screens will never mention the // subject. Indexable, a title a preview can show, and a type. let html = render(&Screen::sidebar_content("Projects")); assert!( html.contains("<meta property=\"og:title\" content=\"Projects\">"), "{html}" ); assert!( html.contains("<meta property=\"og:type\" content=\"website\">"), "{html}" ); assert!(!html.contains("robots"), "{html}"); // A None emits nothing rather than an empty tag. A preview showing a blank // line reads as a broken page, and an empty `description` is a page telling // a search engine it is about nothing. assert!(!html.contains("og:description"), "{html}"); assert!(!html.contains("name=\"description\""), "{html}"); assert!(!html.contains("og:image"), "{html}"); assert!(!html.contains("canonical"), "{html}"); } /// One summary, three tags: the pair a share sheet reads and the plain one a /// search engine reads. MNW's `base.html` emitted all three and the described /// document emitted two, so `/pricing` was appending the third through /// `Shell::head`. #[test] fn a_summary_reaches_the_plain_description_meta_and_not_only_the_social_pair() { let html = render(&Screen::sidebar_content("Pricing").summarised("What you keep.")); assert!( html.contains("<meta name=\"description\" content=\"What you keep.\">"), "{html}" ); // Beside the pair rather than instead of it. All three say the same thing // and are read by different things. assert!( html.contains("<meta property=\"og:description\" content=\"What you keep.\">"), "{html}" ); assert!( html.contains("<meta name=\"twitter:description\" content=\"What you keep.\">"), "{html}" ); } /// A screen that has told crawlers to go away has nothing to gain from /// describing itself to them -- but a noindex page can still be linked, so the /// share sheet keeps its preview. The asymmetry is deliberate. #[test] fn a_screen_that_refuses_indexing_keeps_its_preview_and_drops_its_description() { let html = render( &Screen::sidebar_content("Downloads") .summarised("Your purchases.") .indexed(false), ); assert!(!html.contains("name=\"description\""), "{html}"); assert!( html.contains("<meta property=\"og:description\" content=\"Your purchases.\">"), "{html}" ); assert!( html.contains("<meta name=\"robots\" content=\"noindex\">"), "{html}" ); } #[test] fn a_purchased_content_screen_can_say_it_is_not_for_crawlers() { // Six of the server's screens. This is the assertion the whole decision // exists to buy: a conversion that drops the tag fails here rather than // exposing the URLs and being noticed in a search result. let html = render(&Screen::sidebar_content("Downloads").indexed(false)); assert!( html.contains("<meta name=\"robots\" content=\"noindex\">"), "{html}" ); } #[test] fn a_screen_that_offers_a_feed_says_so_where_a_reader_looks() { let html = render( &Screen::sidebar_content("Blue Hour").syndicating(quasi_router::Feed::new( quasi_router::FeedKind::Rss, "Blue Hour updates", "/p/blue-hour/feed.xml", )), ); // The media type comes off the kind rather than out of a template, which is // the whole of what typing it bought: `application/rss` written by hand at // one of three sites is a feed a reader skips. assert!( html.contains( "<link rel=\"alternate\" type=\"application/rss+xml\" \ title=\"Blue Hour updates\" href=\"/p/blue-hour/feed.xml\">" ), "{html}" ); // And a screen that offers none says nothing, rather than an empty link. assert!( !render(&Screen::sidebar_content("Blue Hour")).contains("rel=\"alternate\""), "a screen with no feed claimed one" ); } #[test] fn a_feed_title_is_escaped_like_every_other_string_in_the_head() { // App-authored text going into an attribute value. The rule in // `discovery_head` is that none of them is trusted for being ours. let html = render( &Screen::sidebar_content("Blog").syndicating(quasi_router::Feed::new( quasi_router::FeedKind::Atom, "A \" quote", "/feed", )), ); assert!(html.contains("type=\"application/atom+xml\""), "{html}"); assert!(!html.contains("title=\"A \" quote\""), "unescaped: {html}"); } #[test] fn the_caret_starts_where_a_whole_document_said_it_does() { let screen = Screen::single("Log in").opening_at("email").with( Slot::new("form", RegionKind::Pane).with(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"), ], }), ); let html = render(&screen); assert!(html.contains("autofocus"), "{html}"); // Exactly one, and it is the box the screen named. The other question on // the form is not touched. assert_eq!(html.matches("autofocus").count(), 1, "{html}"); let at = html.find("autofocus").expect("emitted"); assert!( html[..at].rfind("\"email\"").is_some(), "the caret landed somewhere other than the named box: {html}" ); } #[test] fn a_name_no_question_on_the_screen_carries_moves_no_caret() { // `Screen::place`'s bargain: the screen is the app's and so is the name, so // a renderer is the wrong place to discover that an app disagrees with // itself. let html = render( &Screen::single("Log in") .opening_at("nothing-is-called-this") .with( Slot::new("form", RegionKind::Pane).with(Node::Field(Box::new(Field::new( layout::FieldKind::Text, "email", "Email", )))), ), ); assert!(!html.contains("autofocus"), "{html}"); } #[test] fn a_fragment_never_moves_the_caret() { // The accessibility half of the decision. A browser answers most // interactions with a fragment, and a swap that steals focus takes it out // of whatever the reader was typing into. let field = Node::Field(Box::new(Field::new( layout::FieldKind::Text, "email", "Email", ))); assert!( !fragment(&field).contains("autofocus"), "a fragment claimed it" ); // Nor an overlay, which is a swap into a document the reader is already in. let over = Screen::single("Search") .opening_at("q") .with( Slot::new("body", RegionKind::Pane).with(Node::Field(Box::new(Field::new( layout::FieldKind::Text, "q", "Search", )))), ); assert!( !Webview::new().overlay(&over).contains("autofocus"), "an overlay claimed it" ); } #[test] fn a_band_is_one_element_and_the_nav_is_inside_it() { // The whole reason the member exists. MNW's narrow-viewport menu is a // checkbox styling its siblings, and siblings in two parents match nothing; // split across the two emission points the menu stops opening on a phone. let document = Webview::new() .with_shell( Shell::under("/static").with_chrome( quasi_router::Chrome::new() .offering(quasi_router::Place::new( "discover", "Discover", Action::get("/discover"), )) .banded(banded()), ), ) .screen(&Screen::single("Home")); let band = document.find("chrome-band").expect("emitted"); let nav = document.find("chrome-nav").expect("emitted"); let close = document.find("</header>").expect("closed"); let main = document.find("<main").expect("emitted"); assert!( band < nav && nav < close, "the nav left the band: {document}" ); assert!( close < main, "the band landed after the content: {document}" ); // The checkbox before everything it discloses: `~` reaches forward and only // forward, so a toggle written after the search box could never style it. let toggle = document.find("chrome-disclose-state").expect("emitted"); let search = document.find("chrome-search").expect("emitted"); assert!(toggle < search && search < nav, "{document}"); // And the wordmark comes apart at its mark, read whole. assert!( document.contains(">Makenot<span class=\"chrome-brand-mark\">.</span>work</a>"), "{document}" ); } #[test] fn a_navigating_read_that_is_not_a_click_keeps_its_transport() { // The correction in `action_attrs`. The `href` shortcut returns early, so a // navigating read on anything but a click used to come out as an `href` on // a wrapper element and no htmx at all -- a control that did nothing. A // band's search box is that shape: a field whose write navigates, emitted // on a `Fires::ChangeInside` wrapper. let document = Webview::new() .with_shell( Shell::under("/static").with_chrome( quasi_router::Chrome::new().banded( quasi_router::Band::new().searching( Field::new(layout::FieldKind::Text, "q", "Search") .writes(Action::get("/discover").navigating()), ), ), ), ) .screen(&Screen::single("Home")); assert!(document.contains("hx-get=\"/discover\""), "{document}"); assert!( !document.contains("<div class=\"field-writes\" href="), "an href on a wrapper: {document}" ); } #[test] fn an_app_with_no_band_gets_the_nav_it_had_before_bands_existed() { let document = Webview::new() .with_shell( Shell::under("/static").with_chrome(quasi_router::Chrome::new().offering( quasi_router::Place::new("discover", "Discover", Action::get("/discover")), )), ) .screen(&Screen::single("Home")); assert!(document.contains("chrome-nav"), "{document}"); assert!(!document.contains("chrome-band"), "{document}"); assert!(!document.contains("<header"), "{document}"); } #[test] fn a_screen_names_what_it_is_about_and_how_it_previews() { let html = render( &Screen::sidebar_content("Blue Hour") .summarised("Nine tracks recorded in one night.") .illustrated("https://makenot.work/media/cover.png") .about(quasi_router::SocialKind::Song) .canonical_at("https://makenot.work/i/7"), ); assert!( html.contains("content=\"Nine tracks recorded in one night.\""), "{html}" ); assert!( html.contains("content=\"https://makenot.work/media/cover.png\""), "{html}" ); assert!( html.contains("<meta property=\"og:type\" content=\"music.song\">"), "{html}" ); assert!( html.contains("<link rel=\"canonical\" href=\"https://makenot.work/i/7\">"), "{html}" ); // An image means a large card. The Twitter tags are `name`, never // `property`: they were not part of RDFa, and a card written the other way // is a card the crawler skips. assert!( html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">"), "{html}" ); assert!(!html.contains("property=\"twitter:"), "{html}"); } #[test] fn a_summary_is_escaped_because_a_person_wrote_it() { // An item description and a bio are user-authored, and they land in an // attribute value. The one place in the head where that is true. let html = render(&Screen::sidebar_content("Item").summarised("She said \"hi\" & <b>waved</b>")); assert!(!html.contains("<b>waved"), "{html}"); assert!(html.contains(""hi""), "{html}"); assert!(html.contains("&"), "{html}"); } /// A screen exercising every [`Node`] variant and every [`RegionKind`]. /// /// Written out rather than derived, for the reason `makeover-webview`'s own /// `part_class` is written out: both enums are `#[non_exhaustive]`-shaped in /// practice and there is nothing to iterate. A variant added upstream and not /// added here emits classes this file never sees, which is the one way the /// check below can be quietly weakened. Grep this function when adding a node. fn every_kind_of_screen() -> Vec<String> { let mut htmls = Vec::new(); for kind in [ RegionKind::Band, RegionKind::Sidebar, RegionKind::Pane, RegionKind::TabGroup, RegionKind::Modal, ] { htmls.push(render( &Screen::list_detail("Everything", false) .saying(Node::banner(layout::Tone::Warning, "heads up")) .saying(Node::toast(layout::Tone::Success, "saved")) .with(Slot::new("region", kind).with(Node::text("in a region"))), )); } htmls.push(render(&Screen::sidebar_content("Everything").with( Slot::handover("bespoke", "map").with(Node::text("beside a fill")), ))); htmls.push(render(&Screen::list_detail("Tabbed", true))); // Every measure, so the accounting below covers all three rather than only // the default a screen gets for saying nothing. for measure in [ layout::Measure::Wide, layout::Measure::Contained, layout::Measure::Reading, ] { htmls.push(render( &Screen::list_detail("Measured", false).measured(measure), )); } let acts = || Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger); for node in [ Node::page("A page"), Node::section("A section"), Node::text("plain"), Node::rich("**bold** and a [link](https://example.com)"), Node::act("Save", Action::post("/save")), Node::Token(Tag::badge("Paid").tone(layout::Tone::Success)), Node::Token(Tag::chip("Open", Action::get("/tasks?open=1")).latched()), Node::banner(layout::Tone::Danger, "it broke"), Node::toast(layout::Tone::Info, "it saved"), Node::empty("nothing here").offering(acts()), Node::failed("it broke").offering(acts()), Node::field(Field::new(layout::FieldKind::Text, "name", "Name").required()), Node::field(Field::new(layout::FieldKind::Secret, "pw", "Password").error("too short")), Node::field( Field::new(layout::FieldKind::Checkbox, "live", "Live").writes(Action::post("/live")), ), Node::field( Field::new(layout::FieldKind::Text, "slug", "Slug") .consults(Action::get("/api/validate/slug").replacing("slug-status")), ), Node::field(Field::select( "size", "Size", vec![Choice::plain("small"), Choice::new("l", "large")], )), Node::field(Field::radio( "mode", "Mode", vec![Choice::plain("one"), Choice::plain("two")], )), Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/new"), submit: "Create".into(), fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")], }, Node::list([Row::new("fw13") .secondary("a second line") .meta("2 days ago") .token(Tag::badge("Active")) .meter(Meter::new(3, 10)) .activate(Action::get("/keys/7")) .act(acts())]) .and_more(Rest::more(10, Action::get("/keys?page=2")).of(50)), Node::list([Row::new("astra").toggling(true, Action::post("/keys/8/pin"))]), // A pager offering numbered pages, for the `rest-page*` family. Node::list([Row::new("fw12")]).and_more( Rest::page(100, 50) .of(400) .back(Action::get("/keys?page=2")) .forward(Action::get("/keys?page=4")) .jumping(Jump::new(2, Action::get("/keys?page=2"))) .jumping(Jump::new(3, Action::get("/keys?page=3")).here()) .jumping(Jump::new(4, Action::get("/keys?page=4"))), ), Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![ Column::new("Name") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Status") .width(layout::Width::Content) .priority(layout::Priority::Optional), ], rows: vec![ Row::cells([ Cell::new("deploy"), Cell::tag(Tag::badge("Paid").tone(layout::Tone::Success)), ]) .activate(Action::get("/runs/1")), Row::cells([Cell::new("build"), Cell::acts([acts()])]), ], more: None, }, Node::Meter(Meter::new(7, 10).label("7 of 10")), Node::stats([Figure::new("$12.00", "Revenue").change("+3%")]), ] { htmls.push(fragment(&node)); } // The one strip this renderer emits, derived rather than described: a // region showing one labelled child at a time. `Node::Select` used to // contribute three of these, one per `Selector`, and `segment` and `toggle` // left with it -- `option_class` still names them and only `makeover` emits // them now. htmls.push(fragment(&Node::Region( Slot::new("tabs", RegionKind::TabGroup) .showing_one(0) .frame( "Open", Node::Region(Slot::new("open", RegionKind::Group).with(Node::text("what is open"))), ) .frame( "Done", Node::Region( Slot::new("done", RegionKind::Group).fed_by(Action::get("/tasks/done")), ), ), ))); htmls } /// Every class name the markup carries, from `class="a b c"` attributes. /// /// Column identity classes are dropped. `col-Name` is `column_classes`'s own /// output and names the column rather than the vocabulary, so it is data and /// there is nothing for a stylesheet to define. fn emitted_classes(htmls: &[String]) -> std::collections::BTreeSet<String> { let mut names = std::collections::BTreeSet::new(); for html in htmls { let mut rest = html.as_str(); while let Some(at) = rest.find("class=\"") { rest = &rest[at + 7..]; let end = rest.find('"').expect("the attribute closes"); for name in rest[..end].split_whitespace() { if !name.starts_with("col-") { names.insert(name.to_string()); } } rest = &rest[end..]; } } names } /// Classes this renderer emits that no rule in the generated stylesheet names, /// because there is nothing for makeover to say about them. /// /// Arrangements and regions are placement, and placement is spacing: /// `makeover-geometry`'s question, answered per app in `styles.css`. The rest /// are containers holding things makeover styles one by one, or elements that /// are already an element before they are a class. /// /// This list is the boundary written down, not a todo. A *component* joining it /// is the bug, and the test is what refuses one. const BY_DESIGN: &[&str] = &[ // Arrangements, from `Webview::arrangement_class`. "list-detail", "list-detail-tabbed", "sidebar-content", // Measures, from `Screen::measure`. Placement for the same reason an // arrangement is: the screen says which of the three it is, and what a // measure means in pixels is the app's stylesheet answering once instead of // 69 templates answering separately. `0eccff0d` moved the choice, not the // number. "measure-wide", "measure-contained", "measure-reading", // Regions, from `region_class`. "band", "bespoke", // A `RegionKind::Group` came out of no fixture until a tab strip needed // labelled children, so this sat unlisted rather than accounted-for. It // belongs beside its siblings: a group is an arrangement, and what a // grouping costs in space is the app's stylesheet answering once. "group", "modal", "pane", "region", "sidebar", "tabgroup", // Containers. Each holds things that carry their own styled classes. // // `anchored` is the popover container an `Outcome::Anchored` lands in, and // it has no rule upstream for the reason the rest of this list has none: // where a menu sits relative to the thing it opened at is the app's // stylesheet answering once. It is emitted empty and `hidden`, so until an // app writes that rule it is invisible rather than wrong. "anchored", "figures", "form", "notices", "rest", "row", "selector", // Typography. makeover sets no type scale, so a heading and a paragraph // are an `h2` and a `p` before they are anything this crate named. "heading", "rich", "text", ]; /// Classes that name *which* of something, on an element already styled by the /// class beside them. /// /// `class="button act-submit"` takes its whole appearance from `button`. The /// second name exists so an app can reach the submit button of a form without /// reaching every button, and a rule for it upstream would be makeover deciding /// that a submit button looks different, which is the app's call. /// /// The row parts are makeover's own `part_class` output. `row_rules` writes a /// colour for `Primary`, `Secondary` and `Meta` and deliberately none for these /// three: actions carry controls, and tokens and proportions each carry their /// own tone, so a colour on the container would fight what is inside it. const MODIFIERS: &[&str] = &[ "act-submit", // The pager's three parts. `rest-more` was the single load-more button // these replaced; a pager that can also go back needs to name both ends and // the position between them, and each end is present whether or not it can // be pressed so the control keeps its width on the first page and the last. "rest-next", // The numbered strip and its two spellings of a page. makeover names no // pager at all -- `rest` is this renderer's own -- so every part of one is // a modifier here. `rest-page-here` is a state on `rest-page` rather than a // name of its own: the page the reader is on is drawn as text and not as a // control, so the base name is what both share. "rest-page", "rest-page-here", "rest-pages", "rest-position", "rest-previous", "row-actions", "row-activate", "row-proportion", "row-select", "row-selected", "row-tokens", "cell-actions", "cell-tokens", "field-consults", "field-writes", ]; /// Classes that reach the markup with no rule anywhere, which is a gap rather /// than a decision. /// /// Every one is a component: something makeover-layout names, that a described /// screen produces, that arrives unstyled. This is the same failure the /// SSH-keys tab found, one layer up — the name is not invented here, it is /// correct and nothing defines it. /// /// Three separate causes, none of them fixable in this crate alone: /// /// - `form-*`, `has-error`, `visible` and `placeholder-action` are emitted by /// `makeover_webview::form` and `::placeholder` and styled by no rule that /// crate's own `stylesheet` writes. A field's anatomy is makeover's from end /// to end, so both halves are over there. /// - `cell-fill` and `cell-keeps` come off `column_classes`, and the rules that /// make them mean anything come off `list::narrowing_css`, which needs the /// columns and is therefore per-table. Nothing calls it here, so a described /// table has no column tracks and no narrowing: every column is content-width /// and none of them ever drops. goingson generates its `tables.css` from it in /// its own `build.rs`, which is the shape a described table cannot use, /// because its columns are known at render time and not at build time. /// - `banner` and `toast` are `makeover_layout::Notice`, which the description /// layer names and `component_rules` has no section for. /// /// Shrinking this list is the work. Growing it needs a reason written here. const GAPS: &[&str] = &[ "form-checkbox-label", "form-error", "form-group", "form-label", "form-radio-group", "form-radio-label", "has-error", "visible", "placeholder-action", "cell-fill", "cell-keeps", "banner", "toast", ]; #[test] fn every_class_this_renderer_emits_is_one_makeover_defines() { // The invariant the SSH-keys tab found three counterexamples to, checked // by enumeration rather than by remembering the three. `act`, `tone-danger` // and `chip-latched` were each a name this renderer made up, and each one // rendered a described control as unstyled text beside a hand-written one // that had a rule. A fourth is a matter of time without this. let css = makeover_webview::stylesheet(&Emit::default()); let emitted = emitted_classes(&every_kind_of_screen()); // Whole-name matching. `.row` is in the stylesheet and `.row-primary` // starts with it, so a substring search would call every misspelling styled. let styled = |name: &str| { css.match_indices(&format!(".{name}")).any(|(at, found)| { css[at + found.len()..] .chars() .next() .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_') }) }; let unstyled: Vec<&str> = emitted .iter() .map(String::as_str) .filter(|name| !styled(name)) .collect(); let mut accounted: Vec<&str> = [BY_DESIGN, MODIFIERS, GAPS].concat(); accounted.sort_unstable(); assert_eq!( accounted.len(), accounted .iter() .collect::<std::collections::BTreeSet<_>>() .len(), "a name is in two of the three lists, which means two answers to one \ question" ); assert_eq!( unstyled, accounted, "a class this renderer emits has no rule and no entry above. If \ makeover spells it differently, use makeover's spelling -- that is the \ whole of the SSH-keys bug. Otherwise put it in BY_DESIGN, MODIFIERS or \ GAPS with the reason, and note that GAPS is work rather than a \ decision." ); } #[test] fn a_class_prefix_reaches_the_markup_the_way_it_reaches_the_stylesheet() { // The prefix is one setting shared by two emitters, and the failure is // silent in the same way: a prefixed app whose renderer forgot the prefix // on one element gets a stylesheet that matches everything except that // element. let emit = Emit { class_prefix: "mk-", ..Emit::default() }; let html = Webview::new() .with_emit(emit) .fragment(&Node::list([Row::new("fw13").token(Tag::badge("Active"))])); assert!(html.contains("class=\"mk-row\""), "{html}"); assert!(html.contains("mk-row-primary"), "{html}"); assert!(html.contains("mk-badge"), "{html}"); assert!(!html.contains("\"row\""), "{html}"); } #[test] fn an_invalidated_slot_is_addressed_by_name_and_swapped_inside_its_region() { // `innerHTML:` rather than a bare `true`, because a bare one replaces the // element carrying the id and that element is the region `slot_html` // emitted, classes and all. The region would keep its contents and lose // its layout. let html = Webview::new().invalidated("task-count", &Node::text("4 left")); assert!( html.starts_with("<div hx-swap-oob=\"innerHTML:#task-count\">"), "{html}" ); assert!(html.contains("4 left"), "{html}"); assert!(html.ends_with("</div>"), "{html}"); } #[test] fn a_slot_id_cannot_break_out_of_the_out_of_band_selector() { // A slot id reaches this from the description, and the description is the // app's. It is escaped for the same reason `slot_html` escapes it. let html = Webview::new().invalidated("a\"><script>x</script>", &Node::text("hi")); assert!(!html.contains("<script>"), "{html}"); } #[test] fn a_tick_carries_the_value_it_contributes_to_the_selection() { // `5f2b8753`. A checkbox with no value submits `on`, which says a box was // checked and not which one, so every app gathered them in JS instead. let html = fragment(&Node::list([ Row::new("First").ticking("m-1", false), Row::new("Second").ticking("m-2", true), ])); assert!(html.contains("name=\"ticked\" value=\"m-1\""), "{html}"); assert!(html.contains("name=\"ticked\" value=\"m-2\""), "{html}"); assert_eq!(html.matches("checked").count(), 1, "{html}"); } #[test] fn a_commit_control_carries_the_hook_the_selection_script_reads() { // Two things no description can say: how many rows are ticked, and that a // control over none of them should not be pressable. The ticks are the // browser's until something submits them, so the browser says both -- and // this is the whole of what it needs emitted to do it. let act = fragment(&Node::Act( Act::new("Complete", Action::post("/tasks/list/complete")).over("chosen"), )); assert!(act.contains("data-over=\"chosen\""), "{act}"); // And an ordinary control does not, so the script never touches it. let plain = fragment(&Node::Act(Act::new("Save", Action::post("/save")))); assert!(!plain.contains("data-over"), "{plain}"); } #[test] fn a_control_that_asks_for_a_value_reveals_the_box_and_sends_it_with_the_ticks() { // `033ff3ca`. MNW's bulk bar presses "Set Price", reveals a box, and applies // it to whatever is ticked. `details` is the browser's own disclosure, so // none of that is script here. let html = fragment(&Node::Act( Act::new("Set Price", Action::post("/items/price")) .over("chosen") .asking( Field::new(layout::FieldKind::Number, "price", "New price ($)") .hint("Enter 0 to make items free."), ), )); assert!(html.starts_with("<details"), "{html}"); assert!(html.contains("<summary"), "{html}"); assert!(html.contains("Set Price"), "{html}"); assert!(html.contains("name=\"price\""), "{html}"); assert!(html.contains("Enter 0 to make items free."), "{html}"); // What the press sends: the box it revealed, and the ticks it acts over. assert!(html.contains("closest details"), "{html}"); assert!(html.contains("row-select"), "{html}"); // The count and the refusal land on the control that fires, not on the // summary that opens it. assert_eq!(html.matches("data-over=\"chosen\"").count(), 1, "{html}"); } #[test] fn a_box_a_control_asked_for_does_not_also_write_on_its_own() { // It is answered by the press, so a write of its own would send the value // twice: once as it was typed, once when the verb fired. let html = fragment(&Node::Act( Act::new("Add Tag", Action::post("/items/tag")).asking( Field::new(layout::FieldKind::Text, "tag", "Tag slug") .writes(Action::post("/items/tag/live")), ), )); assert!(!html.contains("/items/tag/live"), "{html}"); assert!(!html.contains("data-over"), "{html}"); } #[test] fn the_selection_script_is_linked_after_htmx_and_can_be_left_out() { let with = Webview::new().screen(&Screen::list_detail("A", false)); let htmx = with.find("htmx.min.js").expect("htmx is linked"); let selection = with .find("quasi-selection.js") .expect("the selection script is linked"); // It listens for htmx's own settle event, so binding before htmx exists // would bind to an event nothing dispatches. assert!(htmx < selection, "{with}"); // Dropping it is a supported shape and it fails safe: a commit control // stays enabled and says no count, which is what // every screen did before the script existed. let without = Webview::new() .with_shell(Shell::default().without_selection()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-selection.js"), "{without}"); } #[test] fn the_selection_script_reads_only_the_hooks_this_crate_emits() { // It ships from here rather than being copied per app so that the script // and the markup that feeds it move together. This is the assertion that // they still agree: both hooks it looks for are emitted above. assert!(crate::SELECTION_JS.contains(".row-select"), "the tick hook"); assert!( crate::SELECTION_JS.contains("[data-over]"), "the commit hook" ); // And that it names nothing of any app's. The script is shipped by a // renderer and read by every app, so an app's noun in it -- even in a // comment, even as an example -- is the coupling this whole layer exists to // refuse. for word in ["task", "email", "contact", "goingson"] { assert!( !crate::SELECTION_JS.to_lowercase().contains(word), "the script names {word}" ); } } #[test] fn a_table_row_joins_the_same_selection_a_list_row_does() { // The member a table row deliberately did not have until goingson's task list // asked for it. The class is the one a list row's tick carries, and that is // the point: a commit control gathers by it, so a screen holding a list and // a table holds one selection rather than two that look alike. let html = fragment(&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", true), ], more: None, }); assert!(html.contains("name=\"ticked\" value=\"t-1\""), "{html}"); assert!(html.contains("name=\"ticked\" value=\"t-2\""), "{html}"); assert_eq!(html.matches("class=\"row-select\"").count(), 2, "{html}"); assert_eq!(html.matches(" checked").count(), 1, "{html}"); } #[test] fn a_table_row_offers_what_it_does_not_show() { // `Row::menu`, the member audiofiles' file list asked for. One fact on one // type since the 2026-09-05 collapse, so it emits the same // `data-menu="row"` a host already binds to. let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("title"), Column::new("size")], rows: vec![ Row::cells(["kick.wav", "2.1 MB"]) .offers(Act::new("Preview", Action::post("/files/1/preview"))) .offers( Act::new("Delete", Action::post("/files/1/delete")).confirm("Delete kick.wav?"), ), ], more: None, }); assert!(html.contains(r#"data-menu="row""#), "{html}"); assert!(html.contains("class=\"table-row-menu\""), "{html}"); // Hidden rather than absent, for the list row's reason: the host opens it, // and a menu that is not in the document cannot be opened. assert!(html.contains(" hidden>"), "{html}"); assert!(html.contains("Preview"), "{html}"); assert!(html.contains(r#"hx-confirm="Delete kick.wav?""#), "{html}"); // The menu takes no column, so the head is unchanged and the body row still // holds exactly the two cells the columns name. This is the assertion that // would catch a menu emitted as a cell: the tick above is one because it is // drawn in line, and this is not. assert_eq!(html.matches("role=\"columnheader\"").count(), 2, "{html}"); assert!(!html.contains("table-select"), "{html}"); } #[test] fn a_table_row_that_offers_nothing_emits_no_menu_container() { // The empty case, asserted rather than assumed: an empty `Vec<Act>` is what // every row of every table already carries, so a container emitted // unconditionally would be one hidden div per row of every table in the tree. let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("title")], rows: vec![Row::cells(["kick.wav"])], more: None, }); assert!(!html.contains("data-menu"), "{html}"); assert!(!html.contains("table-row-menu"), "{html}"); } #[test] fn a_tick_takes_a_gutter_cell_in_the_head_as_well_as_the_body() { // `display: table` aligns by position, so a body row with one more cell // than the head puts every heading one place left of the values under it. let ticked = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("title"), Column::new("size")], rows: vec![Row::cells(["kick.wav", "2.1 MB"]).ticking("t-1", false)], more: None, }); assert_eq!( ticked.matches("role=\"columnheader\"").count(), 3, "{ticked}" ); assert_eq!(ticked.matches("class=\"cell").count(), 3, "{ticked}"); // And a table nobody can tick grows no gutter at all. let plain = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("title"), Column::new("size")], rows: vec![Row::cells(["kick.wav", "2.1 MB"])], more: None, }); assert_eq!(plain.matches("role=\"columnheader\"").count(), 2, "{plain}"); assert!(!plain.contains("row-select"), "{plain}"); } #[test] fn a_field_that_writes_without_a_selection_gathers_only_itself() { let html = fragment(&Node::field( Field::select("folder", "Folder", vec![Choice::plain("Inbox")]) .writes(Action::get("/emails/list")), )); assert_eq!(html.matches("hx-include").count(), 1, "{html}"); assert!(!html.contains(".row-select"), "{html}"); } #[test] fn a_commit_control_gathers_every_tick_on_the_screen() { // Declarative, because a checkbox already submits its own name and value: // all that was missing was something saying which boxes belong together. // That is the whole of what the per-app gathering JS did. let html = fragment(&Node::Act( Act::new("Archive", Action::post("/mail/archive")).over("chosen"), )); assert!(html.contains("hx-include=\".row-select\""), "{html}"); assert!(html.contains("hx-post=\"/mail/archive\""), "{html}"); } #[test] fn a_control_over_nothing_gathers_nothing() { let html = fragment(&Node::Act(Act::new( "Delete", Action::post("/mail/1/delete"), ))); assert!(!html.contains("hx-include"), "{html}"); } #[test] fn the_gathering_selector_follows_a_hosts_class_prefix() { // A host setting `Emit::class_prefix` moves the class the ticks carry, and // the selector has to move with it or the commit control gathers nothing. let emit = Emit { class_prefix: "q-", ..Emit::default() }; let render = Webview::new().with_emit(emit); let boxes = render.fragment(&Node::list([Row::new("First").ticking("m-1", false)])); let button = render.fragment(&Node::Act( Act::new("Archive", Action::post("/mail/archive")).over("chosen"), )); assert!(boxes.contains("q-row-select"), "{boxes}"); assert!(button.contains("hx-include=\".q-row-select\""), "{button}"); } #[test] fn an_overlay_is_the_inside_of_a_container_and_not_a_document() { // The whole difference from a screen: what is under it keeps its document. let screen = Screen::sidebar_content("Palette") .with(Slot::new("results", RegionKind::Pane).with(Node::text("Open task"))); let html = Webview::new().overlay(&screen); assert!(!html.contains("<html"), "{html}"); assert!(!html.contains("<body"), "{html}"); assert!(!html.contains("<title"), "{html}"); assert!(html.contains("Open task"), "{html}"); } #[test] fn an_overlay_names_the_container_it_lands_in() { // What `quasi-http` turns into the retarget header. A renderer answering // `None` here is one with no overlay container, and this one has one. assert_eq!( Webview::new().overlay_target(), Some(crate::chrome::OVERLAY_ID) ); } #[test] fn a_document_carries_the_apps_bindings_and_the_container_they_open_into() { use quasi_router::Chrome; let shell = Shell::default().with_chrome(Chrome::new().bind( "ctrl+k", "Search", Action::get("/palette"), )); let html = Webview::new() .with_shell(shell) .screen(&Screen::list_detail("Tasks", false)); assert!(html.contains("hx-get=\"/palette\""), "{html}"); assert!(html.contains("from:body"), "{html}"); assert!(html.contains("id=\"quasi-overlay\""), "{html}"); // After the content and before the body closes: chrome is the app's, so it // sits outside what a screen's markup is. let overlay_at = html.find("id=\"quasi-overlay\"").expect("emitted"); assert!( overlay_at > html.find("</main>").expect("main closes"), "{html}" ); assert!(html.ends_with("</body></html>"), "{html}"); } #[test] fn an_app_declaring_no_chrome_gets_the_document_it_always_got() { // What makes this additive: nothing is emitted, so nothing moved. let html = render(&Screen::list_detail("Tasks", false)); assert!(!html.contains("quasi-overlay"), "{html}"); assert!(!html.contains("data-chrome"), "{html}"); } /// A carousel: three frames, the second up, and no label anywhere. fn gallery() -> Slot { Slot::widget("shots", "carousel") .extend((0..3).map(|n| { Node::Image(quasi_router::screen::Image::new( format!("/shot-{n}.png"), format!("shot {n}"), )) })) .showing_one(1) } #[test] fn a_region_showing_everything_emits_exactly_what_it_always_did() { // The whole additive claim. `Showing::All` is the default, so every // description written before the member existed has to come out unchanged: // no wrapper, no hook, no row. let screen = Screen::list_detail("Tasks", false) .with(Slot::new("main", RegionKind::Pane).with(Node::text("plain"))); let html = render(&screen); assert!(!html.contains("data-showing")); assert!(!html.contains("showing-frame")); assert!(!html.contains("showing-position")); } #[test] fn a_carousel_gets_a_row_without_this_renderer_knowing_what_a_carousel_is() { // The point of the whole design. Nothing below reads the widget's name, and // a second assembly showing one child at a time gets the same row for free. let screen = Screen::list_detail("Product", false).with(gallery()); let html = render(&screen); assert!(html.contains("data-showing=\"one\""), "{html}"); assert!(html.contains("data-shows=\"previous\""), "{html}"); assert!(html.contains("data-shows=\"next\""), "{html}"); // Position counts from one for a reader, and off `current()` rather than // off `shown`, so a clamped index reports where the frame actually is. assert!(html.contains(">2 / 3</span>"), "{html}"); // The name is still there and is still nobody's business here. assert!(html.contains("data-widget=\"carousel\"")); } #[test] fn the_row_sits_under_the_frames_and_overlays_nothing() { // Max, 2026-08-14: the shipped arrows were absolutely positioned over the // picture, which a terminal cannot honestly do and which read as clutter // here. In flow, after the content, on every host. let html = render(&Screen::list_detail("Product", false).with(gallery())); let last_frame = html.rfind("showing-frame").expect("frames are wrapped"); let row = html.rfind("showing-position").expect("a row is derived"); assert!(row > last_frame, "{html}"); } #[test] fn only_the_current_frame_is_marked_and_the_rest_are_still_in_the_document() { // Degradation runs toward more content. Every frame ships; the rule that // collapses them waits for whatever binds the region, so a reader with no // script gets the whole gallery instead of one frame and two dead buttons. let html = render(&Screen::list_detail("Product", false).with(gallery())); assert_eq!(html.matches("showing-frame").count(), 3, "{html}"); assert_eq!(html.matches("showing-frame current").count(), 1, "{html}"); assert!(html.contains("/shot-0.png") && html.contains("/shot-2.png")); } #[test] fn labelled_children_get_a_strip_and_it_is_makeovers_tab_markup() { // A tab strip is already a described thing. Deriving a second spelling of // one is how `tabs` and `segmented` came to render flat. let screen = Screen::list_detail("Project", false).with( Slot::new("detail", RegionKind::TabGroup) .frame( "Overview", Node::Region(Slot::new("overview", RegionKind::Pane)), ) .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane))) .showing_one(1), ); let html = render(&screen); assert!(html.contains("data-selector=\"tab\""), "{html}"); assert!(html.contains("role=\"tablist\""), "{html}"); assert!(html.contains(">Overview</button>"), "{html}"); assert!(html.contains("chosen\" data-shows=\"1\""), "{html}"); assert!(html.contains("aria-selected=\"true\""), "{html}"); assert!(html.contains("aria-selected=\"false\""), "{html}"); // The words are the last thing on the control, after the program that // moves the frame. `0081563e` put that program between them, which is why // the two are asserted apart rather than as one string. assert!(html.contains(">Files</button>"), "{html}"); assert!(html.contains(">Overview</button>"), "{html}"); // A strip, not a counter row: the labels are what the reader steers by. assert!(!html.contains("showing-position"), "{html}"); } #[test] fn a_tab_whose_panel_is_a_route_carries_the_address_and_the_panel_does_not() { // `dfbc88ce`. The strip is what calls the route, so the four panels nobody // pressed do not fetch on load. MNW's library page is the measurement: five // tabs, five database reads per page view under the previous emission. let screen = Screen::list_detail("Library", false).with( Slot::new("tab-content", RegionKind::TabGroup) // The shown panel came with the screen. `9b958e7b`: no placeholder // before first content, and it is the panel being looked at that // would have had one. .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), ); let html = render(&screen); // The address is on the control that was pressed. assert!( html.contains("aria-selected=\"false\" hx-get=\"/library/tabs/feed\""), "{html}" ); // And not on the panel, which is the whole point: one `load` here is five // requests on the page this was measured against. assert!(!html.contains("hx-trigger=\"load\""), "{html}"); // A panel nobody has asked for is not waiting on anything. assert!(!html.contains("aria-busy"), "{html}"); // The frame still moves, so both marks are on the button: htmx makes the // request and the binder shows the frame. assert!(html.contains("data-shows=\"1\""), "{html}"); // The shown panel came with the screen. `9b958e7b`: no placeholder before // first content, and it is the panel being looked at that would have had one. assert!(html.contains("what you bought"), "{html}"); } #[test] fn a_tab_is_a_button_even_though_its_address_is_a_read() { // The one place `control_tag`'s rule inverts. A read of a route is normally // an anchor so middle-click and copy-link work, and a tab's address answers // with a fragment: following that link lands on a shell-less scrap. So the // strip keeps its button and the address stays on `hx-get` alone. let screen = Screen::list_detail("Library", false).with( Slot::new("tab-content", RegionKind::TabGroup) .frame( "Purchases", Node::Region( Slot::new("purchases", RegionKind::Pane) .fed_by(Action::get("/library/tabs/purchases")), ), ) .frame( "Feed", Node::Region( Slot::new("feed", RegionKind::Pane).fed_by(Action::get("/library/tabs/feed")), ), ) .showing_one(0), ); let html = render(&screen); assert!(!html.contains("href="), "{html}"); assert!( html.contains("<button type=\"button\" class=\"tab"), "{html}" ); } #[test] fn a_carousel_of_frames_already_here_still_calls_no_route() { // The other half of the same rule, and the reason nothing reads the region's // kind: presence picks between them. A gallery's frames are downloaded, so // its controls move between them and ask for nothing. let screen = Screen::list_detail("Project", false).with( Slot::new("detail", RegionKind::TabGroup) .frame( "Overview", Node::Region(Slot::new("overview", RegionKind::Pane)), ) .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane))) .showing_one(0), ); let html = render(&screen); assert!(html.contains("data-shows=\"1\""), "{html}"); assert!(!html.contains("hx-get"), "{html}"); } #[test] fn a_carousel_carries_the_program_that_moves_its_frames() { // `0081563e`. The frames are downloaded already, so stepping between them // is local, and until this landed the buttons were marks a host had to bind // before a described gallery worked at all. Nothing hand-written: the // program is emitted from the description, which is the whole of what the // attribute line permits. let html = render(&Screen::list_detail("Product", false).with(gallery())); // Every frame says which region it belongs to and which child it is. That // is what a nested region needs: a tab group over a gallery must not step // through the gallery's frames. assert_eq!(html.matches("data-frame=\"shots\"").count(), 3, "{html}"); assert!(html.contains("data-shown=\"2\""), "{html}"); // The counter is addressed as well as classed, because the step writes into // it and a class is what a stylesheet may rename. assert!(html.contains("data-position=\"shots\""), "{html}"); // Both directions carry a program, and the wrap is in the arithmetic rather // than in a branch. assert_eq!(html.matches(" _=\"on click").count(), 2, "{html}"); assert!(html.contains("mod 3"), "{html}"); } #[test] fn a_tab_moves_the_frame_and_carries_the_strips_marks_with_it() { // Three things say the same fact and all three move together: the frame // that is current, the button that is chosen, and `aria-selected`, which is // what a screen reader hears. let screen = Screen::list_detail("Project", false).with( Slot::new("detail", RegionKind::TabGroup) .frame( "Overview", Node::Region(Slot::new("overview", RegionKind::Pane)), ) .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane))) .showing_one(0), ); let html = render(&screen); assert!(html.contains("take .chosen"), "{html}"); assert!( html.contains("set @aria-selected of me to 'true'"), "{html}" ); assert!( html.contains("take .current from <[data-frame='detail']/>"), "{html}" ); // A strip has no counter row, so nothing writes a position. assert!(!html.contains("data-position"), "{html}"); } #[test] fn a_disclosure_opens_and_closes_the_one_child_under_it() { // The only control here that toggles rather than picks, and the one place // `aria-expanded` is read back off the frame rather than tracked. let screen = Screen::list_detail("Item", false).with( Slot::new("more", RegionKind::Group) .frame("Details", Node::Region(Slot::new("body", RegionKind::Pane))) .showing_at_most_one(Some(0)), ); let html = render(&screen); assert!(html.contains("toggle .current"), "{html}"); assert!(html.contains("set @aria-expanded of me"), "{html}"); } #[test] fn a_region_showing_everything_carries_no_program_either() { // The additive claim, extended. A description written before `Showing` // existed emits no chrome, so there is nothing local to perform and nothing // for an interpreter to read. let screen = Screen::list_detail("Tasks", false) .with(Slot::new("main", RegionKind::Pane).with(Node::text("plain"))); let html = render(&screen); assert!(!html.contains(" _=\""), "{html}"); assert!(!html.contains("data-frame"), "{html}"); } #[test] fn a_region_with_no_frames_is_stepped_by_nothing() { // `mod 0` is not a number. An empty gallery is the measured case: three MNW // pages draw one whenever the creator uploaded nothing, and the counter row // is still emitted because the region is still a carousel. let screen = Screen::list_detail("Product", false) .with(Slot::widget("shots", "carousel").showing_one(0)); let html = render(&screen); assert!(html.contains("data-shows=\"next\""), "{html}"); assert!(!html.contains(" _=\""), "{html}"); assert!(html.contains(">0 / 0</span>"), "{html}"); } #[test] fn a_region_id_that_is_not_a_handle_gets_the_mark_and_no_program() { // A program is code and the id goes inside a selector inside it, so the // gate is the id being a plain handle rather than an escaping pass: // hyperscript interpolates `${...}` inside a query literal, and a CSS hex // escape is read by browsers and not by every selector engine. // // What such a region gets is the markup it got before this existed: the // `data-shows` mark, and whatever the host binds to it. let screen = Screen::list_detail("Product", false).with( Slot::widget("${window.alert(1)}", "carousel") .with(Node::text("a")) .with(Node::text("b")) .showing_one(0), ); let html = render(&screen); assert!(html.contains("data-shows=\"next\""), "{html}"); assert!(!html.contains(" _=\""), "{html}"); assert!(!html.contains("alert(1)/>"), "{html}"); } #[test] fn creator_prose_is_fenced_off_from_the_interpreter() { // What loading hyperscript obliges, and the one node whose content somebody // other than the app wrote. `data-disable-scripting` stops the interpreter // reading `_` attributes anywhere under it. let html = fragment(&Node::rich("**hello**")); assert!(html.contains("data-disable-scripting"), "{html}"); } #[test] fn hyperscript_enters_in_exactly_one_module() { // The same claim `htmx_enters_in_exactly_two_functions` makes, for the other // language this crate emits. If an `_` attribute is written anywhere else, // the provenance rule still holds -- it is emitted -- but the place to read // every program stops being one file. assert!( !include_str!("node.rs").contains(" _=\\\""), "node.rs writes a program" ); // The adoption's one rule, kept where the programs are. `js` is the only // path in the interpreter that reaches `new Function`, and MNW serves // `script-src 'self'` with no `unsafe-eval`. let source = include_str!("hyperscript.rs"); assert!(!source.contains(" js "), "{source}"); } #[test] fn a_region_that_is_not_in_a_strip_still_fetches_itself() { // The suppression is the labelled-strip case and nothing wider. MNW's payout // summary is the case that must not change: one slow region on a screen that // is otherwise local reads, arriving behind its stand-in. let screen = Screen::list_detail("Dashboard", false) .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts"))); let html = render(&screen); assert!(html.contains("hx-trigger=\"load\""), "{html}"); assert!(html.contains("aria-busy=\"true\""), "{html}"); } #[test] fn a_strip_sits_above_the_panes_it_opens() { // The folder semantic. A tab after its pane would not read as the tab of // it, and this is the only placement decision the derivation makes. let screen = Screen::list_detail("Project", false).with( Slot::new("detail", RegionKind::TabGroup) .frame( "Overview", Node::Region(Slot::new("overview", RegionKind::Pane)), ) .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane))) .showing_one(0), ); let html = render(&screen); let strip = html .find("data-selector=\"tab\"") .expect("a strip is derived"); let first_frame = html.find("showing-frame").expect("frames are wrapped"); assert!(strip < first_frame, "{html}"); } #[test] fn a_named_child_that_can_close_is_a_summary_line() { // Disclosure, which is `871e7f21` and is the third findings this member // collapses. A strip of one tab is not what a summary line is, so the // dismissible case is checked before the labelled one. let screen = 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(None), ); let html = render(&screen); assert!(html.contains("data-showing=\"at-most-one\""), "{html}"); assert!(html.contains("aria-expanded=\"false\""), "{html}"); assert!(html.contains(">Technical details</button>"), "{html}"); assert!(!html.contains("data-shows=\"next\""), "{html}"); } #[test] fn a_derived_control_names_no_route_and_the_transport_stays_in_one_function() { // The controls move between children already in the document, so there is // nothing to fetch. Emitting htmx here would put the transport in a second // place, which is the claim `action_attrs` exists to keep true. let html = render(&Screen::list_detail("Product", false).with(gallery())); let row = &html[html.find("showing-position").expect("a row is derived") - 200..]; assert!(!row.contains("hx-get"), "{row}"); assert!(!row.contains("hx-post"), "{row}"); } // ---------------------------------------------------------------- timeline /// A row with one word in it, for placing on an axis. fn placed(at: u16, minutes: u16, text: &str) -> quasi_router::screen::Placed { quasi_router::screen::Placed::new(at, minutes, Row::new(text)) } #[test] fn a_timeline_places_each_entry_as_a_percentage_of_its_span() { // 09:00 for an hour, on a midnight-to-midnight axis: 37.5% down, 4.1667% // tall. The arithmetic is Track::fraction's, asserted here because this is // where a renderer would be tempted to do its own. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![placed(540, 60, "Standup")], focus: None, }; let html = fragment(&node); assert!(html.contains("--track-at:37.5000%"), "{html}"); assert!(html.contains("--track-for:4.1667%"), "{html}"); // Full width when nothing collides, from the defaults rather than from a // special case in the emitter. assert!(html.contains("--track-lane:0"), "{html}"); assert!(html.contains("--track-lanes:1"), "{html}"); } #[test] fn overlapping_entries_take_lanes_and_the_rest_stay_wide() { // Two at once and one after. The two collide, so the track is two lanes // wide; the third does not collide with either but shares the width, // because the lane count is per-track rather than per-cluster. That is a // layout decision this renderer owns and the test records it as one. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![ placed(540, 60, "Standup"), // 09:00-10:00 placed(570, 60, "Interview"), // 09:30-10:30 placed(720, 30, "Lunch"), // 12:00-12:30 ], focus: None, }; let html = fragment(&node); assert!(html.contains("--track-lane:0"), "{html}"); assert!(html.contains("--track-lane:1"), "{html}"); assert!( !html.contains("--track-lane:2"), "two collide, not three: {html}" ); assert_eq!(html.matches("--track-lanes:2").count(), 3, "{html}"); } #[test] fn things_that_only_touch_do_not_collide() { // 09:00-10:00 and 10:00-11:00 are back to back. `to` is exclusive, so they // share no minute and both keep the full width. Off-by-one here is the // classic day-view bug: every hour on the hour reads as a conflict. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![placed(540, 60, "First"), placed(600, 60, "Second")], focus: None, }; let html = fragment(&node); assert!(html.contains("--track-lanes:1"), "{html}"); assert!(!html.contains("--track-lane:1"), "{html}"); } #[test] fn a_timeline_labels_its_ruler_in_wall_clock_and_wraps_past_midnight() { // An overnight span counts past 1440 so it needs no date, and the ruler // wraps that back to a clock a person reads. 22:00 to 02:00, hourly. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::over(layout::Span::new(1320, 1560)), entries: vec![], focus: None, }; let html = fragment(&node); assert!(html.contains(">22:00<"), "{html}"); assert!(html.contains(">00:00<"), "{html}"); assert!(html.contains(">01:00<"), "{html}"); assert!(!html.contains(">25:00<"), "the clock wrapped: {html}"); } #[test] fn a_timeline_carries_its_focus_as_a_moment_and_never_as_an_offset() { // "Show me 09:00", not a pixel. The host decides how to get there, which is // the arrangement that replaces goingson's hardcoded targetHour. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![], focus: Some(540), }; let html = fragment(&node); assert!(html.contains("data-focus=\"540\""), "{html}"); assert!(!html.contains("scrollTop"), "{html}"); assert!(!html.contains("px"), "{html}"); } #[test] fn a_placed_rows_text_cannot_become_markup() { // The property the whole port exists for. An entry's row goes through the // same escaping every other row does; being placed is not a way around it. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![placed(540, 60, "<script>alert('x')</script>")], focus: None, }; let html = fragment(&node); assert!(!html.contains("<script>"), "{html}"); assert!(html.contains("<script>"), "{html}"); } #[test] fn a_timeline_emits_no_size_of_its_own() { // Every number this renderer writes is a percentage of the span or a lane // index. A pixel here would be quasi deciding how tall an hour is, which is // makeover-geometry's question and the line makeover-webview's track_rules // holds from the other side. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![placed(540, 60, "Standup")], focus: None, }; let html = fragment(&node); for unit in ["px", "rem", "em;", "vh"] { assert!(!html.contains(unit), "emitted a {unit}: {html}"); } } // ---------------------------------------------------------------- widths #[test] fn peer_columns_are_a_row_of_fills_and_carry_no_width() { // A board: three columns, no master and no detail. `RegionKind::Columns` // used to say this and is retired (quasicoherent `cf981aaa`); a run of // members that each ask to fill says it, and says it in a type a table // column has always used. What is emitted is the description's own word, // and how much a fill actually gets is the stylesheet's. let column = |id: &str, label: &str| { Node::Region(Slot::new(id, RegionKind::Pane).with(Node::Heading { level: layout::Heading::Section, text: label.into(), })) }; let board = Slot::new("board", RegionKind::Group).across( Run::new(layout::Fallback::Wrap) .spread( column("pending", "Pending"), layout::Priority::Essential, layout::Width::Fill, ) .spread( column("started", "Started"), layout::Priority::Essential, layout::Width::Fill, ) .spread( column("done", "Completed"), layout::Priority::Essential, layout::Width::Fill, ), ); let html = render(&Screen::list_detail("Tasks", false).with(board)); assert_eq!(html.matches("data-width=\"fill\"").count(), 3, "{html}"); // No share, no count, no order index. Every one of those would be this // renderer deciding something peers settle by being peers. assert!(!html.contains("data-share"), "{html}"); assert!(!html.contains("data-columns"), "{html}"); for label in ["Pending", "Started", "Completed"] { assert!(html.contains(label), "{html}"); } } #[test] fn a_split_is_a_pane_that_takes_what_it_needs_beside_one_that_fills() { // The other retired kind. Two panes side by side, the left choosing what // the right shows, is content then fill: exactly what a table row of a // content column and a fill column has always been. let pane = |id: &str| Node::Region(Slot::new(id, RegionKind::Pane)); let split = Slot::new("review", RegionKind::Group).across( Run::new(layout::Fallback::Stack) .beside(pane("listing"), layout::Priority::Essential) .spread( pane("reading"), layout::Priority::Essential, layout::Width::Fill, ), ); let html = render(&Screen::list_detail("Review", false).with(split)); // Only the filling half says anything, because content is what a run // member already drew and saying it would be a hook that changes nothing. assert_eq!(html.matches("data-width=").count(), 1, "{html}"); assert!(html.contains("data-width=\"fill\""), "{html}"); let listing = html.find("id=\"listing\"").expect("the left pane renders"); let reading = html.find("id=\"reading\"").expect("the right pane renders"); assert!(listing < reading, "{html}"); } #[test] fn a_member_that_says_nothing_about_width_is_wrapped_in_nothing() { // The default is the drawing every run member had before the width // existed, and the test for that is markup identity rather than a class // this renderer promises not to emit. let bare = Slot::new("bar", RegionKind::Band).across( Run::new(layout::Fallback::Wrap).beside(Node::text("title"), layout::Priority::Essential), ); let said = Slot::new("bar", RegionKind::Band).across(Run::new(layout::Fallback::Wrap).spread( Node::text("title"), layout::Priority::Essential, layout::Width::Content, )); assert_eq!( render(&Screen::list_detail("T", false).with(bare)), render(&Screen::list_detail("T", false).with(said)), ); } #[test] fn a_day_strip_labels_days_and_not_a_wall_clock() { // The defect this fixes, kept as its test. A fifteen-day span on a // thirty-one day month rendered with exact geometry under an 00:00 ruler, // because Track::fraction is unit-agnostic and nothing else noticed. let march = layout::Track::days(layout::Span::new(0, 31)); let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: march, entries: vec![quasi_router::screen::Placed::new(2, 15, Row::new("Leave"))], focus: None, }; let html = fragment(&node); // Day one, not day zero: offsets are zero-based like every axis here and // nobody calls the first of the month the zeroth. assert!(html.contains(">1<"), "{html}"); assert!(html.contains(">8<"), "{html}"); assert!( !html.contains("00:00"), "a month strip under a wall clock: {html}" ); // And the geometry that was always right stays right: day 3 of 31 for 15. assert!(html.contains("--track-at:6.4516%"), "{html}"); assert!(html.contains("--track-for:48.3871%"), "{html}"); } #[test] fn a_day_view_still_labels_a_wall_clock() { // The other half of the same guard: fixing the strip must not cost the // screen this vocabulary was added for. let node = Node::Timeline { marks: ::quasi_router::stage::Marks::none(), track: layout::Track::DAY, entries: vec![], focus: None, }; let html = fragment(&node); assert!(html.contains(">09:00<"), "{html}"); } #[test] fn a_field_says_how_much_of_its_row_it_asked_for() { // `6d6a9160`: fill is determined at the description stage. A column has // said this since the beginning and a leaf control could not, which was an // inconsistency rather than a principle. // // An attribute rather than a class, for `data-tone`'s reason: this is a // fact the description carried, not a hook this renderer invented. let sized = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "query", "Search").width(layout::Width::Content), ))); assert!(sized.contains("data-width=\"content\"")); // `Fill` is the default and is what a control did before the member // existed, so saying it would be a hook that changes nothing -- and it // emits no wrapper either. let quiet = fragment(&Node::Field(Box::new(Field::new( layout::FieldKind::Text, "query", "Search", )))); assert!(!quiet.contains("data-width")); assert!(!quiet.contains("field-writes")); } #[test] fn a_field_that_consults_asks_on_the_keystroke_after_the_value_settles() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "username", "Username") .consults(Action::get("/api/validate/username").replacing("username-status")), ))); // `keyup changed`, not `change`: the question is about the value being // written, and `change` does not fire until the box is left. assert!(html.contains("hx-trigger=\"keyup changed delay:500ms\"")); assert!(html.contains("hx-get=\"/api/validate/username\"")); // The verdict lands where the description said, because a validate route // is not described and cannot name a region itself. assert!(html.contains("hx-target=\"#username-status\"")); // The control is inside the wrapper, so the value is found back rather // than assumed -- the same reason a field that writes carries this. assert!(html.contains("hx-include=\"find input, find select, find textarea\"")); assert!(html.contains("field-consults")); } #[test] fn consulting_and_writing_are_two_routes_and_so_two_wrappers() { // One element takes one verb and one address. A field that asks about its // value while it is typed and writes it when it settles names two routes, // and collapsing them onto one element would silently drop one. let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "slug", "Slug") .consults(Action::get("/api/validate/slug")) .writes(Action::post("/projects/1/slug")), ))); assert_eq!(html.matches("field-consults").count(), 1); assert_eq!(html.matches("field-writes").count(), 1); assert!(html.contains("hx-get=\"/api/validate/slug\"")); assert!(html.contains("hx-post=\"/projects/1/slug\"")); // Consult outside, write inside: the width and the tick-gathering stay on // the wrapper that already carried them. assert!( html.find("field-consults") < html.find("field-writes"), "{html}" ); } #[test] fn a_consult_waits_as_long_as_the_description_says() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "q", "Search").consulting( Consult::new(Action::get("/discover/tag-suggest")) .after(std::time::Duration::from_millis(120)), ), ))); assert!(html.contains("delay:120ms"), "{html}"); } #[test] fn a_box_asking_two_routes_gets_a_wrapper_each() { // MNW's discover search. Two routes cannot share one element -- htmx takes // one verb and one address per element -- which is the same reason a // consult and a write are two wrappers. let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "q", "Search") .consulting( Consult::new(Action::get("/discover/suggestions").replacing("suggestions")) .after(std::time::Duration::from_millis(200)), ) .consulting( Consult::new(Action::get("/discover/results").replacing("results")) .after(std::time::Duration::from_millis(150)), ), ))); assert_eq!(html.matches("field-consults").count(), 2, "{html}"); assert!(html.contains(r#"hx-get="/discover/suggestions""#), "{html}"); assert!(html.contains(r#"hx-get="/discover/results""#), "{html}"); assert!(html.contains("delay:200ms"), "{html}"); assert!(html.contains("delay:150ms"), "{html}"); } #[test] fn a_question_gathers_the_controls_it_says_it_carries() { // The half that made discover's results route unsayable: it answers about // the current filters, so a question asked without them answers about a // screen the user is not looking at. Named by field name rather than by // the `.discover-filter` class the shipped markup groups with, because a // class is a fact about the document and a terminal has none. let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "q", "Search") .consulting(Consult::new(Action::get("/discover/results")).sending(["mode", "sort"])), ))); assert!( html.contains("[name='mode'], [name='sort']"), "{html}" ); // Its own control is still found back, which is what the field group has // always needed: the value being typed belongs to the control inside the // wrapper the trigger sits on. assert!(html.contains("find input"), "{html}"); } #[test] fn a_question_that_carries_nothing_gathers_only_its_own_control() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "username", "Username") .consults(Action::get("/api/validate/username")), ))); assert!(!html.contains("[name="), "{html}"); } #[test] fn a_consult_with_a_floor_does_not_ask_about_one_letter() { // MNW's two comboboxes, measured: both refuse under two characters, and // both routes guard emptiness only. Without the floor in the markup a // described typeahead asks `a%` over the whole catalogue. let html = fragment(&Node::Field(Box::new( 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), ), ))); // The filter belongs to the event and the delay is a modifier after it. // `event.target` because the trigger is on the wrapper and the value is on // the control inside it. assert!( html.contains("hx-trigger=\"keyup[event.target.value.length>=2] changed delay:150ms\""), "{html}" ); } #[test] fn a_select_that_consults_is_heard_on_input_rather_than_on_a_keystroke() { // `aeb44860`. A folder select that re-asks for a list of mail is the same // question a search box asks, and `keyup` never hears it move: a pointer // never releases a key over one. let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Select, "folder", "Folder").consulting( Consult::new(Action::get("/mail/list").replacing("mail-list")) .after(std::time::Duration::ZERO), ), ))); assert!( html.contains("hx-trigger=\"input changed delay:0ms\""), "{html}" ); assert!(html.contains("hx-get=\"/mail/list\""), "{html}"); assert!(html.contains("field-consults"), "{html}"); } #[test] fn a_dragged_value_that_consults_is_heard_on_input_too() { // A range is built up rather than chosen in one go, which is the other // question `typed_into` is deliberately not answering: nobody types into a // slider either. let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Range, "cap", "Cap") .consults(Action::get("/price").replacing("price")), ))); assert!( html.contains("hx-trigger=\"input changed delay:500ms\""), "{html}" ); } #[test] fn a_typed_box_keeps_the_keystroke_it_had() { // The half that does not move. `input` would serve here as well, and the // pair is kept because a box being typed into is the shape the delay and // the character floor were measured on. for kind in [ layout::FieldKind::Text, layout::FieldKind::Secret, layout::FieldKind::Number, layout::FieldKind::Email, layout::FieldKind::Url, layout::FieldKind::Tel, layout::FieldKind::Textarea, ] { let html = fragment(&Node::Field(Box::new( Field::new(kind, "q", "Search").consults(Action::get("/find")), ))); assert!( html.contains("hx-trigger=\"keyup changed delay:500ms\""), "{kind:?}: {html}" ); } } #[test] fn a_consult_without_a_floor_asks_whatever_is_there() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "username", "Username") .consults(Action::get("/api/validate/username")), ))); // No filter at all rather than one that always passes: the four wizard // fields this member was built from ask about a single letter on purpose, // since one letter can already be taken. assert!( html.contains("hx-trigger=\"keyup changed delay:500ms\""), "{html}" ); assert!(!html.contains("value.length"), "{html}"); } #[test] fn a_field_that_both_writes_and_sizes_itself_says_both_on_one_wrapper() { let both = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "query", "Search") .width(layout::Width::Content) .writes(Action::post("/search")), ))); assert_eq!(both.matches("field-writes").count(), 1); assert!(both.contains("data-width=\"content\"")); } /// 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. Shared by the three renderers' path-independence tests, written out /// in each rather than lifted into a crate they would all have to depend on: /// the fixture is six lines and a shared one would be a new public surface. 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 the_markup_is_not_a_function_of_the_width() { // "Any width, one answer", `makeover-layout` 0.27.4. This renderer keeps // the property the cheapest way there is: it is never told the width, so // narrowing is the stylesheet's and lives in `@media`, where there is // nowhere to keep the width you came from. // // Two halves. The first is that a reused renderer answers the same as a // fresh one, which is what would fail the day someone memoised a track // list on the `Webview`. The second is that the document carries no // measurement at all, which is what would fail the day someone reached for // an inline `style` to size something -- an inline width is a number // computed once, and a number computed once is the previous frame's // answer. let screen = every_shape_that_could_cache(); let reused = Webview::new(); let first = reused.screen(&screen); for _ in 0..4 { assert_eq!(reused.screen(&screen), first); } assert_eq!(Webview::new().screen(&screen), first); // No absolute length reaches the markup. The one inline style the // document carries is the arrangement's share, and it is in `fr`: a // proportion of whatever there turns out to be, which is a fact the // description stated rather than a size this renderer worked out. for style in first.split("style=\"").skip(1) { let value = style.split('"').next().expect("an attribute closes"); for unit in ["px", "vw", "vh", "ch", "pt"] { assert!(!value.contains(unit), "{unit} in {value}"); } assert!(value.contains("fr"), "{value}"); } } #[test] fn a_region_member_says_when_it_drops() { // The other half of "Any width, one answer": a placement that is not a // column can finally say what it is worth, so a band narrows by the rule // the stylesheet already applies to tables rather than by a breakpoint the // app hand-rolled. let screen = Screen::sidebar_content("Toolbar").with( Slot::new("bar", quasi_router::RegionKind::Band) .with(Node::text("Library")) .with_ranked(Node::text("Filter"), layout::Priority::Secondary) .with_ranked(Node::text("Sort"), layout::Priority::Optional), ); let html = render(&screen); // The classes are `makeover-webview`'s, and the rules that hide them come // off its stylesheet, gated by `@media` per size class. Nothing here is // told a width. assert!(html.contains("cell-drops-next"), "{html}"); assert!(html.contains("cell-drops-first"), "{html}"); // An essential member emits no wrapper at all, which is why the change // reached every existing screen without moving any of their markup. assert_eq!(html.matches("<div class=\"cell-drops").count(), 2); assert!(html.contains("<p class=\"text\">Library</p>"), "{html}"); } #[test] fn an_awaiting_control_locks_itself_while_it_waits() { // `d8d6f380`. The guard, which is the half the shipped server writes twice // against 57 spinners, and the half a double-submitted purchase needs. let html = fragment(&Node::Act(Act::new( "Buy", Action::post("/checkout").awaiting(), ))); assert!(html.contains(r#"hx-disable="this""#), "{html}"); assert!(html.contains(r#"data-awaiting="indeterminate""#), "{html}"); assert!(!html.contains("data-awaiting-amount"), "{html}"); // No mark, nothing said. A control that waits on nothing worth saying so // about draws exactly what it drew before this existed. let plain = fragment(&Node::Act(Act::new("Save", Action::post("/save")))); assert!(!plain.contains("hx-disable"), "{plain}"); assert!(!plain.contains("data-awaiting"), "{plain}"); } #[test] fn an_awaiting_form_locks_its_button_and_not_the_form() { // `930947c3`. The test above builds an `Act`, which is why this got through: // `hx-disable="this"` locks a button and does nothing at all to a // `<form>`. htmx sets the `disabled` attribute on whatever the selector // resolves to, `<form>` has no such attribute, and it does not reach the // controls inside the way `<fieldset disabled>` would. So the guard has to // name the button, or a form marked as waiting accepts a second submit // while the first is still in flight. let html = fragment(&Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/api/users/me/ssh-keys").awaiting(), submit: "Add SSH Key".into(), fields: vec![Field::new(layout::FieldKind::Text, "label", "Label")], }); assert!( html.contains(r#"hx-disable="find button[type='submit']""#), "{html}" ); assert!(!html.contains(r#"hx-disable="this""#), "{html}"); assert!(html.contains(r#"data-awaiting="indeterminate""#), "{html}"); // The thing the selector has to find is really there, spelled the way the // selector spells it. Two places, one shape, and this is the seam. assert!(html.contains(r#"<button type="submit""#), "{html}"); // The fields stay usable. A guard that took the text away from a reader // still correcting it would be a worse bug than the one this fixes. assert!(html.contains(r#"<input type="text""#), "{html}"); assert!(!html.contains("<input disabled"), "{html}"); } #[test] fn a_measured_wait_carries_its_amount_and_never_a_duration() { let html = fragment(&Node::Act(Act::new( "Upload", Action::post("/media").awaiting_amount(41_943_040), ))); assert!(html.contains(r#"data-awaiting="determinate""#), "{html}"); assert!( html.contains(r#"data-awaiting-amount="41943040""#), "{html}" ); // The amount is what there is, not how long it will take. Nothing in the // markup may read as a prediction, because the description carries none. assert!(!html.contains("duration"), "{html}"); assert!(!html.contains("eta"), "{html}"); } #[test] fn a_tab_strip_and_what_shares_its_row_land_in_one_flex_row() { // goingson's bug, rendered. The toolbar is inside the same row as the // strip and neither is positioned, which is the whole of the fix: the // stylesheet rule this replaces pinned the toolbar over the strip with // position: absolute and so took it out of flow. let screen = Screen::sidebar_content("Work").with( Slot::new("work-view", RegionKind::TabGroup) .across(Run::new(layout::Fallback::Menu).beside( Node::Region(Slot::new("work-toolbar", RegionKind::Band)), layout::Priority::Secondary, )) .frame("Tasks", Node::Region(Slot::new("tasks", RegionKind::Pane))) .showing_one(0), ); let html = render(&screen); assert!(html.contains("class=\"run run-menu\""), "{html}"); // The strip leads the row and the member follows it, which is the folder // semantic the unwrapped strip already had. let run = html .split("class=\"run run-menu\"") .nth(1) .expect("the row"); let strip = run.find("Tasks").expect("the strip is in the row"); let toolbar = run.find("work-toolbar").expect("the member is in the row"); assert!(strip < toolbar, "{run}"); assert!(!html.contains("position:"), "{html}"); } #[test] fn a_region_with_no_run_emits_exactly_the_markup_it_always_did() { // The additivity claim, checked rather than asserted in a doc comment. let bare = Screen::sidebar_content("Work").with( Slot::new("work-view", RegionKind::TabGroup) .frame("Tasks", Node::Region(Slot::new("tasks", RegionKind::Pane))), ); assert!(!render(&bare).contains("class=\"run")); } #[test] fn a_region_fed_by_a_call_asks_for_itself() { // MNW's payout summary: one slow part of a screen that is otherwise local // reads. A hand-split route before there was a word for it. let screen = Screen::list_detail("Payments", false).with( Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()), ); let html = render(&screen); assert!(html.contains(r#"hx-trigger="load""#), "{html}"); assert!(html.contains(r#"hx-get="/dashboard/payouts""#), "{html}"); // Pending while it is on its way, which is the state that was already // sayable and had no way to fill itself. assert!(html.contains(r#"aria-busy="true""#), "{html}"); // A region is not somewhere to go, so the read does not become an anchor. assert!(!html.contains(r#"href="/dashboard/payouts""#), "{html}"); // Nothing aims the answer. The router names what it changed, which is // decision 7 and is not weakened by the request starting here. assert!(!html.contains("hx-target"), "{html}"); } #[test] fn a_live_region_asks_again_on_the_renderer_cadence() { // MNW's admin queue summary, which hand-writes `every 10s` in the template // today. The description says the number moves without the user and the // interval is this crate's, so every live region in every screen it draws // moves at one speed. let screen = Screen::list_detail("Admin", false).with( Slot::new("queue", RegionKind::Pane) .fed_by(Action::get("/admin/queue")) .live(), ); let html = render(&screen); let expected = format!(r#"hx-trigger="load, every {}s""#, crate::CADENCE.as_secs()); assert!(html.contains(&expected), "{html}"); assert!(html.contains(r#"hx-get="/admin/queue""#), "{html}"); // `load` is kept beside the interval: htmx waits out a whole period first, // and a region blank for ten seconds is slower than what this replaces. assert!(!html.contains(r#"hx-trigger="every"#), "{html}"); // Still not somewhere to go. assert!(!html.contains(r#"href="/admin/queue""#), "{html}"); } #[test] fn a_still_region_asks_once_as_it_always_did() { // The default is the old behaviour, asserted from the markup rather than // from the field, so a description written before `live` existed renders // byte for byte as it did. let screen = Screen::list_detail("Payments", false) .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts"))); let html = render(&screen); assert!(html.contains(r#"hx-trigger="load""#), "{html}"); assert!(!html.contains("every"), "{html}"); } // ── The frame a mount puts around a screen ── #[test] fn a_mount_that_declares_no_frame_draws_none() { // The default has to be the old markup, byte for byte, or every host that // serves a screen changes what it emits when this arrives. let html = render( &Screen::list_detail("Compose", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))), ); assert!(!html.contains("frame"), "{html}"); } #[test] fn a_framed_mount_offers_its_verbs_outside_the_screen() { // goingson's compose window. The verbs belong to the mount, so they sit // outside `<main>`: a fragment replaces what is inside it, and a verb row // swept away by the first navigation is the per-screen repetition this // member exists to end. let webview = Webview::new().with_frame( Frame::new() .offering(Act::new("Send", Action::post("/compose/send"))) .offering( Act::new("Discard", Action::post("/compose/discard")) .confirm("Discard this draft?"), ) .reporting(), ); let html = webview.screen( &Screen::list_detail("Compose", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))), ); let main_ends = html.find("</main>").expect("a screen has a main"); let verbs = html.find("Send").expect("the verb is drawn"); assert!(verbs > main_ends, "the frame outlives the screen inside it"); assert!(html.contains("hx-post=\"/compose/send\""), "{html}"); // A verb is an ordinary act, so it carries its confirmation with no second // vocabulary. assert!( html.contains("hx-confirm=\"Discard this draft?\""), "{html}" ); } #[test] fn a_mount_that_reports_has_somewhere_for_a_banner_to_rest() { let webview = Webview::new().with_frame(Frame::new().reporting()); let html = webview.screen( &Screen::list_detail("Compose", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))), ); assert!(html.contains(crate::frame::STATUS_ID), "{html}"); // Polite: a status line reports what happened and does not interrupt what // is being typed, which is the difference from the toast a modal raises. assert!(html.contains("aria-live=\"polite\""), "{html}"); } #[test] fn a_mount_that_only_offers_verbs_has_no_status_line() { // The modal half of the measured pair: it raises a toast, and its messages // stack the way every other screen's do. let webview = Webview::new() .with_frame(Frame::new().offering(Act::new("Cancel", Action::post("/close")))); let html = webview.screen( &Screen::list_detail("Compose", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))), ); assert!(html.contains("Cancel"), "{html}"); assert!(!html.contains(crate::frame::STATUS_ID), "{html}"); } #[test] fn a_banner_rests_in_the_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 // rather than adding a second way to say it. let mut screen = Screen::list_detail("Compose", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))); screen .notices .push(Node::banner(quasi_router::layout::Tone::Danger, "Not sent")); screen.notices.push(Node::Notice { kind: quasi_router::layout::Notice::Toast, tone: quasi_router::layout::Tone::Info, text: "Saved".into(), act: None, }); let html = Webview::new() .with_frame(Frame::new().reporting()) .screen(&screen); let status = html.find(crate::frame::STATUS_ID).expect("a status line"); let banner = html.find("Not sent").expect("the banner is drawn"); let toast = html.find("Saved").expect("the toast is drawn"); assert!(banner > status, "a banner rests in the frame"); assert!( toast < status, "a toast floats over the screen as it always did" ); } #[test] fn an_unreporting_mount_leaves_both_kinds_where_they_were() { // The default has to be the old markup. A mount with no place for one to // rest changes nothing about either kind. let mut screen = Screen::list_detail("Compose", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))); screen .notices .push(Node::banner(quasi_router::layout::Tone::Danger, "Not sent")); let framed = Webview::new() .with_frame(Frame::new().offering(Act::new("Cancel", Action::post("/close")))) .screen(&screen); let bare = render(&screen); let notices = framed.find("notices").expect("the notices block"); assert!(framed.find("Not sent").expect("drawn") > notices); assert!(bare.contains("Not sent")); } #[test] fn a_readout_carries_the_instant_and_which_way_it_runs() { // The two attributes the script reads, and the first words beside them so a // reader with no script sees a time rather than a hole. let started = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); let html = fragment(&Node::since(started)); assert!(html.contains("data-clock=\"since\""), "{html}"); assert!(html.contains("data-at=\"1000000000\""), "{html}"); assert!(html.contains("class=\"clock\""), "{html}"); assert!(!html.contains("></span>"), "the first words are emitted"); assert!(fragment(&Node::until(started)).contains("data-clock=\"until\"")); assert!(fragment(&Node::age(started)).contains("data-clock=\"age\"")); } #[test] fn a_readout_sits_in_a_row_and_in_a_cell() { // The measured shape: an elapsed time beside the title of the thing it is // about, rather than a block of its own. let started = std::time::SystemTime::now(); let row = fragment(&Node::list([ Row::new("First").part(layout::RowPart::Meta, Node::since(started)) ])); assert!(row.contains("data-clock=\"since\""), "{row}"); let table = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("title"), Column::new("elapsed")], rows: vec![Row::cells([ Cell::new("First"), Cell { content: vec![Node::since(started)], ..Cell::default() }, ])], more: None, }); assert!(table.contains("data-clock=\"since\""), "{table}"); } #[test] fn the_clock_script_is_linked_and_can_be_left_out() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-clock.js"), "{with}"); // Dropping it fails safe, like dropping the selection script: the words the // server emitted stay on the page and stop moving. let without = Webview::new() .with_shell(Shell::default().without_clock()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-clock.js"), "{without}"); // And it survives a document that calls no route, which the selection // script does not: a page with no routes can still say how long ago // something happened. let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("quasi-clock.js"), "{embed}"); assert!(!embed.contains("quasi-selection.js"), "{embed}"); } #[test] fn the_runtime_for_what_the_emitter_writes_is_linked_and_can_be_left_out() { // `0081563e`. The programs on a region's chrome are read by _hyperscript // and by nothing else, so the document that carries them names it. let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("_hyperscript.min.js"), "{with}"); let without = Webview::new() .with_shell(Shell::default().without_hyperscript()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("_hyperscript.min.js"), "{without}"); // Independent of the transport, unlike the selection script: the frames of // a carousel are in the document, so a page that calls no route can still // have something local to perform. let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("_hyperscript.min.js"), "{embed}"); } #[test] fn a_toast_carries_the_hook_the_script_takes_it_away_by_and_a_banner_does_not() { // `4453bf82`. The description says a toast goes away on its own and never // says when. In this renderer the markup is emitted once and then held by a // browser, so the mark is what the browser's clock finds it by, and a // banner carries none because a banner has no deadline. let toast = fragment(&Node::toast(layout::Tone::Success, "Saved")); assert!(toast.contains(r#"data-notice="toast""#), "{toast}"); let banner = fragment(&Node::banner(layout::Tone::Danger, "Not sent")); assert!(!banner.contains("data-notice"), "{banner}"); // The hook is an attribute rather than the class, because a class carries // the host's prefix and a script shipped by this crate cannot know it. let emit = crate::Emit { class_prefix: "qs-", ..crate::Emit::default() }; let prefixed = Webview::new() .with_emit(emit) .fragment(&Node::toast(layout::Tone::Success, "Saved")); assert!(prefixed.contains(r#"data-notice="toast""#), "{prefixed}"); assert!(prefixed.contains("qs-toast"), "{prefixed}"); } #[test] fn the_clock_script_takes_a_toast_away_on_this_renderers_own_schedule() { assert!(crate::CLOCK_JS.contains("data-notice"), "the toast hook"); assert!(crate::CLOCK_JS.contains("remove()"), "and it is taken away"); // It reads the duration rather than carrying it: `makeover-build` writes // `--timing-dismiss` into every consumer's `timing.css` from the same // `Intent::Dismiss` the terminal and egui renderers resolve. assert!( crate::CLOCK_JS.contains("--timing-dismiss"), "named, not numbered" ); } /// The script's fallback is a number in a second language, which is the /// arrangement the two elapsed-time format implementations already run on: /// allowed, so long as something fails when the two part. This is that /// something. A page with no `timing.css` gets the crate's value and not a /// stale copy of it. #[test] fn the_clock_scripts_fallback_is_the_duration_the_crate_names() { let named = format!( "LINGER_FALLBACK = {};", makeover_timing::Intent::Dismiss.ms() ); assert!(crate::CLOCK_JS.contains(&named), "{named}"); // The same arrangement for the leaving half, added with `43bdbff7`. let fade = format!("FADE_FALLBACK = {};", makeover_timing::Motion::Fade.ms()); assert!(crate::CLOCK_JS.contains(&fade), "{fade}"); } /// `Intent::Dismiss` is how long a notice lives *before it starts to leave*, /// and the leaving is `Motion::Fade`. /// /// The two-step is what this asserts, because the failure it replaces looked /// identical from the outside: a toast that goes away. #[test] fn a_toast_leaves_before_it_is_removed_rather_than_vanishing_at_dismiss() { assert!( crate::CLOCK_JS.contains("--motion-fade"), "the leaving duration is read, not numbered" ); assert!( crate::CLOCK_JS.contains("data-leaving"), "and the attribute makeover-webview's rule transitions on is set" ); } /// The node goes on a timer and never on `transitionend`. /// /// `timing.css` zeroes `--motion-fade` under `prefers-reduced-motion`, and a /// zero-length transition may fire no event at all. A listener would hang there /// forever, for exactly the reader who asked for less motion -- so the one /// reader this could strand is the one it must not. #[test] fn the_leaving_step_cannot_strand_a_toast_under_reduced_motion() { // The listener, not the word: the comment above the timer names the event // it is deliberately not using, and a test that could not tell those apart // would forbid explaining the decision. assert!( !crate::CLOCK_JS.contains("addEventListener(\"transitionend"), "a transition that never fires is a toast that never leaves" ); assert!( !crate::CLOCK_JS.contains("ontransitionend"), "and not by property either" ); } #[test] fn the_clock_script_reads_only_the_hooks_this_crate_emits() { // It ships from here rather than being copied per app so that the script // and the markup that feeds it move together. This is the assertion that // they still agree. assert!(crate::CLOCK_JS.contains("data-clock"), "the kind hook"); assert!(crate::CLOCK_JS.contains("data-at"), "the instant hook"); for kind in ["since", "until", "age"] { assert!(crate::CLOCK_JS.contains(kind), "the script knows {kind}"); } // And that it names nothing of any app's, for the reason the selection // script may not: it is shipped by a renderer and read by every app. for word in ["task", "email", "contact", "goingson"] { assert!( !crate::CLOCK_JS.to_lowercase().contains(word), "the script names {word}" ); } } #[test] fn the_shell_serves_the_clock_script_from_where_the_host_says() { let html = Webview::under("/assets").screen(&Screen::list_detail("A", false)); assert!(html.contains("src=\"/assets/quasi-clock.js\""), "{html}"); } /// Three elements and not one: the wrapper that asks, the box that is a /// combobox, and the list it owns. Every id derives from the field's name. #[test] fn a_field_that_owns_a_suggestion_list_is_a_combobox() { let html = fragment(&Node::Field(Box::new( 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!(html.contains("field-suggests"), "{html}"); assert!(html.contains(r#"hx-get="/discover/suggestions""#), "{html}"); assert!(html.contains("delay:200ms"), "{html}"); // The answer lands in the list the field owns, from an id nobody authored. assert!(html.contains(r##"hx-target="#q-suggestions""##), "{html}"); assert!(html.contains(r#"id="q-suggestions""#), "{html}"); assert!(html.contains(r#"role="listbox""#), "{html}"); assert!(html.contains(r#"role="combobox""#), "{html}"); assert!(html.contains(r#"aria-controls="q-suggestions""#), "{html}"); assert!(html.contains(r#"aria-expanded="false""#), "{html}"); // The list is named by the box it belongs to, so a reader hears what it is. assert!(html.contains(r#"aria-label="Search""#), "{html}"); } /// MNW's fee calculator: five dials that are peers, and a panel that /// recomputes when any of them moves. The question is the region's, the values /// are gathered by containment, and nothing nominates one dial as the owner of /// the recompute. #[test] fn a_region_recomputes_from_the_dials_inside_it() { let html = render( &Screen::list_detail("Pricing", false).with( Slot::group("pricing-calculator") .with(Node::Field(Box::new(Field::new( layout::FieldKind::Number, "item_price", "Price per item", )))) .with(Node::Region( Slot::group("results-panel").with(Node::text("You keep $9.00")), )) .consulting( Consult::new(Action::get("/pricing/compare").replacing("results-panel")) .after(std::time::Duration::from_millis(300)), ), ), ); assert!(html.contains("region-consults"), "{html}"); assert!(html.contains(r#"hx-get="/pricing/compare""#), "{html}"); assert!(html.contains(r##"hx-target="#results-panel""##), "{html}"); // `input` and not `keyup`: a tier radio is a dial too, and `keyup` never // hears it. Not `change` either, which would fire again on blur. assert!( html.contains(r#"hx-trigger="input changed delay:300ms""#), "{html}" ); // The dials are gathered by containment, which is the whole of what moving // the question up to the region bought. assert!( html.contains(r#"hx-include="find input, find select, find textarea""#), "{html}" ); // Outside the region, because a region that also names a `fed_by` has // already spent htmx's one address per element. let wrapper = html.find("region-consults").unwrap(); let region = html.find(r#"id="pricing-calculator""#).unwrap(); assert!(wrapper < region, "{html}"); } /// A dial that sits outside the panel names itself the way a field's consult /// names a sibling: by field name. What is inside needs no naming. #[test] fn a_region_consult_carries_what_it_says_it_carries_from_outside() { let html = render( &Screen::list_detail("Pricing", false).with( Slot::group("calculator") .with(Node::Field(Box::new(Field::new( layout::FieldKind::Number, "sales", "Sales per month", )))) .consulting( Consult::new(Action::get("/pricing/compare").replacing("results")) .sending(["tier"]), ), ), ); // What the caller named first, then what the trigger has to find back. Two // `hx-include` attributes on one element is markup a parser drops half of, // so they are joined rather than written twice. assert!( html.contains( r#"hx-include="find input, find select, find textarea, [name='tier']""# ), "{html}" ); } /// Two panels recomputing from one set of dials are two questions, and one /// element cannot carry both: htmx takes one verb and one address. #[test] fn a_region_asking_twice_is_two_wrappers() { let html = render( &Screen::list_detail("Pricing", false).with( Slot::group("calculator") .with(Node::Field(Box::new(Field::new( layout::FieldKind::Number, "sales", "Sales", )))) .consulting(Consult::new( Action::get("/pricing/compare").replacing("results"), )) .consulting(Consult::new( Action::get("/pricing/chart").replacing("chart"), )), ), ); assert_eq!(html.matches("region-consults").count(), 2, "{html}"); assert!(html.contains(r##"hx-target="#results""##), "{html}"); assert!(html.contains(r##"hx-target="#chart""##), "{html}"); } /// A suggestion source is not one of the questions the field merely asks: MNW's /// discover box owns a list AND re-reads its results, and the two are separate /// wrappers pointing at separate places. #[test] fn owning_a_list_is_not_the_same_as_asking_a_question() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "q", "Search") .suggesting(Consult::new(Action::get("/discover/suggestions")).at_least(2)) .consulting( Consult::new(Action::get("/discover/results").replacing("results")) .sending(["mode", "sort"]), ), ))); assert_eq!(html.matches("field-suggests").count(), 1, "{html}"); assert_eq!(html.matches("field-consults").count(), 1, "{html}"); assert!(html.contains(r##"hx-target="#q-suggestions""##), "{html}"); assert!(html.contains(r##"hx-target="#results""##), "{html}"); } /// The candidates, drawn as the inside of the list. A `Candidate`'s value is /// what the box is set to, its label is what is read, and its detail is the /// line that tells it from a row reading the same. #[test] fn the_answer_to_a_suggestion_route_is_the_inside_of_the_list() { let html = Webview::new().suggestions( "q", &[ Candidate::new("rust-lang", "Rust"), Candidate::plain("format").detailed("Audio"), ], ); assert!(html.contains(r#"role="option""#), "{html}"); assert!(html.contains(r#"id="q-suggestions-0""#), "{html}"); assert!(html.contains(r#"data-at="0""#), "{html}"); assert!(html.contains(r#"data-value="rust-lang""#), "{html}"); assert!(html.contains(">Rust<"), "{html}"); // The second line is its own element, because it is styled apart and is not // part of what the typed value matches. `1fcf2e9b`. assert!(html.contains("form-suggestion-detail"), "{html}"); assert!(html.contains(">Audio<"), "{html}"); // A candidate that says nothing about picking still picks locally, so both // rows carry the writing program and neither carries a route. assert_eq!(html.matches("on click set box").count(), 2, "{html}"); assert!(!html.contains("hx-get"), "{html}"); } /// Picking is local by default, and a candidate carrying an action has that /// performed instead: nothing is written, and the row is an htmx control /// rather than a value the program copies. #[test] fn a_candidate_that_carries_an_action_does_not_write_its_value() { let html = Webview::new().suggestions( "q", &[ Candidate::new("rust-lang", "Rust").picking(Action::get("/projects/rust-lang")), Candidate::plain("ruby"), ], ); // The acting row: htmx calls the route, and there is no value for the // writing program to copy into the box. assert!(html.contains(r#"hx-get="/projects/rust-lang""#), "{html}"); assert!(!html.contains(r#"data-value="rust-lang""#), "{html}"); // It still closes the list, which is the half `suggestion_acting` keeps, // and it keeps none of the writing half: the write appears once across the // two rows, on the local one. assert!(html.contains("set @aria-expanded of box"), "{html}"); assert_eq!(html.matches("set the value of box").count(), 1, "{html}"); // The local row beside it is untouched, which is what "local stays the // default" has to mean for every site that exists today. assert!(html.contains(r#"data-value="ruby""#), "{html}"); assert_eq!( html.matches("set picked to @data-value").count(), 1, "{html}" ); } /// At the row rather than at an act: a pick that replaces the document is /// drawn as an anchor, because an anchor is what a browser navigates. `href` /// on a `div` is an attribute nothing reads. #[test] fn a_candidate_whose_pick_navigates_is_a_link() { let html = Webview::new().suggestions( "q", &[ Candidate::new("/p/rust-lang", "Rust") .picking(Action::get("/p/rust-lang").navigating()), Candidate::new("audio.genre.ambient", "Ambient") .picking(Action::get("/discover/results").replacing("results-container")), ], ); assert!(html.contains(r#"<a class="form-suggestion""#), "{html}"); assert!(html.contains(r#"href="/p/rust-lang""#), "{html}"); assert!(html.contains("</a>"), "{html}"); // The one beside it is a call htmx makes, so it stays the element it was: // a link there would offer a fragment to middle-click. assert!(html.contains(r#"<div class="form-suggestion""#), "{html}"); assert_eq!(html.matches("hx-get").count(), 1, "{html}"); assert!(!html.contains("hx-get=\"/p/rust-lang\""), "{html}"); } /// Enter reaches a candidate that acts. Such a row carries no `data-value`, /// because nothing is written when picking performs a call, and writing one /// anyway put `null` in the box and sent no call at all. #[test] fn the_keyboard_presses_a_candidate_that_acts() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "q", "Search") .suggesting(Consult::new(Action::get("/discover/suggestions")).at_least(2)), ))); assert!(html.contains("if no (@data-value of cur)"), "{html}"); assert!(html.contains("call cur.click()"), "{html}"); } /// A candidate is app text, so it reaches a program through the document and /// never through the program's own source. #[test] fn a_candidate_cannot_write_its_own_program() { let html = Webview::new().suggestions( "q", &[Candidate::new( "' then log 'pwned", "<script>alert(1)</script>", )], ); assert!(!html.contains("<script>"), "{html}"); // The payload is in the attribute the program reads from, once, and the // program itself is this crate's own text with nothing interpolated into // it. assert_eq!(html.matches("then log").count(), 1, "{html}"); assert!( html.contains(r#"data-value="' then log 'pwned""#), "{html}" ); assert!(html.contains("set picked to @data-value"), "{html}"); } #[test] fn a_field_whose_name_is_not_a_handle_draws_no_list() { let html = fragment(&Node::Field(Box::new( Field::new(layout::FieldKind::Text, "q[]", "Search") .suggests(Action::get("/discover/suggestions")), ))); assert!(!html.contains("role=\"combobox\""), "{html}"); assert!(!html.contains("role=\"listbox\""), "{html}"); assert!( Webview::new() .suggestions("q[]", &[Candidate::plain("a")]) .is_empty() ); assert_eq!(Webview::new().suggestions_target("q[]"), None); } /// The header is the whole story for a plain link and none of it for an htmx /// control, so the script that turns an XHR into a download is linked by /// default -- and, unlike the clock, goes when htmx goes, because there is no /// XHR left to intercept. #[test] fn the_download_script_is_linked_and_goes_with_htmx() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-download.js"), "{with}"); let without = Webview::new() .with_shell(Shell::default().without_download()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-download.js"), "{without}"); let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(!embed.contains("quasi-download.js"), "{embed}"); } /// It reads the header `quasi-http` writes, so the two are one fact rather than /// two that can drift. This is the cheap half of that: the name the adapter /// emits appears in the script that parses it. #[test] fn the_download_script_reads_the_header_the_adapter_writes() { assert!(crate::DOWNLOAD_JS.contains("content-disposition")); // As it appears in the script's regex, where the star is escaped. assert!(crate::DOWNLOAD_JS.contains(r"filename\*=UTF-8''")); assert!(crate::DOWNLOAD_JS.contains("attachment")); } /// The two attributes the fill script reads, and what makes them attributes /// rather than an emitted program: a media file's name is app text, and text a /// user typed is the one thing `crate::hyperscript` never builds a program out /// of. #[test] fn an_act_that_deposits_a_value_names_the_box_and_carries_what_lands_in_it() { let html = Webview::new().screen(&Screen::list_detail("Write", false).with( Slot::new("picker", RegionKind::Modal).with(Node::Act( Act::new("kick.png", Action::local()).filling("body", "![](media/kick.png)"), )), )); assert!(html.contains(r#"data-fills="body""#), "{html}"); assert!( html.contains(r#"data-fill="![](media/kick.png)""#), "{html}" ); // Local, so no transport at all: the deposit is the whole of what the press // does and there is no address to call. assert!(html.contains("data-local"), "{html}"); assert!(!html.contains("hx-get"), "{html}"); } /// A value is a value wherever it came from. The escaping is the emitter's /// ordinary one, which is exactly the property that lets this be data. #[test] fn a_deposited_value_that_looks_like_markup_stays_a_value() { let html = Webview::new().screen(&Screen::list_detail("Write", false).with( Slot::new("picker", RegionKind::Modal).with(Node::Act( Act::new("odd", Action::local()).filling("body", r#"" onclick="alert(1)"#), )), )); // The quotes are encoded, so the value cannot close the attribute it is in // and the `onclick` never becomes one. assert!( html.contains(r#"data-fill="" onclick="alert(1)""#), "{html}" ); assert!(!html.contains(r#"" onclick=""#), "{html}"); } /// Absent by default, or every control on every screen starts claiming a box. #[test] fn an_ordinary_control_deposits_nothing() { let html = Webview::new().screen( &Screen::list_detail("Tasks", false).with( Slot::new("body", RegionKind::Pane) .with(Node::Act(Act::new("Delete", Action::post("/delete")))), ), ); assert!(!html.contains("data-fills"), "{html}"); assert!(!html.contains("data-fill="), "{html}"); } /// Linked by default, and it stays when htmx goes: what it reads is two /// attributes on a control, so a document that calls no route can still deposit /// a value in a box. #[test] fn the_fill_script_is_linked_and_outlives_htmx() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-fill.js"), "{with}"); let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("quasi-fill.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_fill()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-fill.js"), "{without}"); } /// The script reads the hooks this emitter writes, and puts the value where a /// browser puts inserted text. A stale copy in an app's static directory is a /// picker that silently writes nothing, which is what pairing the two here /// catches. #[test] fn the_fill_script_reads_the_hooks_the_emitter_writes() { assert!( crate::FILL_JS.contains("data-fills"), "the destination hook" ); assert!(crate::FILL_JS.contains("data-fill"), "the value hook"); // At the selection, and it says so the way a browser does. assert!(crate::FILL_JS.contains("selectionStart")); assert!(crate::FILL_JS.contains("setSelectionRange")); // And it tells the page the value settled, or an autosave never sees it. assert!(crate::FILL_JS.contains(r#"new Event("input""#)); } /// Every class in a rendered document, unprefixed. /// /// Scraped from `class="..."` rather than predicted, which is the point: a name /// this crate emits and nobody wrote down shows up here. fn classes_emitted(html: &str) -> std::collections::BTreeSet<String> { let mut found = std::collections::BTreeSet::new(); let mut rest = html; while let Some(at) = rest.find(" class=\"") { rest = &rest[at + " class=\"".len()..]; let end = rest.find('"').expect("an attribute closes"); for name in rest[..end].split_whitespace() { found.insert(name.to_owned()); } rest = &rest[end..]; } found } /// A screen exercising every [`Node`] kind and every affordance that carries a /// class of its own. /// /// Deliberately one screen rather than a list of fragments: the chrome, the /// frame and the region wrappers only exist around a whole document, and they /// are where most of this crate's own vocabulary lives. fn everything() -> Screen { // Reordering, which is what makes a heading sortable and draws // `table-sort`; ticks on the rows draw `table-select-head` beside them. let sortable_columns = vec![ Column::new("Name") .width(layout::Width::Fill) .reorder(Action::get("/by-name")), Column::new("Size").width(layout::Width::Fill), ]; Screen::list_detail("Everything", true) .with( // No label: a screen's own slot is revealed by nothing, so since // `2cdc6761` it cannot carry the name of a control that reveals it. Slot::new("main", RegionKind::Pane) // A question of the region's own, which is what draws // `region-consults` around it. `cb62a9dc`. .consulting(Consult::new(Action::get("/recompute").replacing("detail"))) .with(Node::page("A heading")) .with(Node::section("A section")) .with(Node::text("Some text")) .with(Node::rich("**rich**")) .with(Node::Act(Act::new("Press", Action::post("/press")))) .with(Node::Act( Act::new("Ask first", Action::post("/ask")).asking(Field::new( layout::FieldKind::Text, "why", "Why", )), )) .with(Node::Link { text: "A link".into(), action: Action::get("/elsewhere"), }) .with(Node::Act(Act::new( "Open in a window", Action::get("/there").elsewhere(), ))) .with(Node::since(instant())) .with(Node::until(instant())) .with(Node::age(instant())) .with(Node::Image( quasi_router::screen::Image::new("/a.png", "A picture").caption("Its caption"), )) .with(Node::Token(Tag::badge("Badge"))) // Removable, which is the only way `chip-remove` is emitted. .with(Node::Token(Tag::removable("Chip", Action::post("/x")))) // Latched, for the state class. .with(Node::Token( Tag::chip("Latched", Action::post("/t")) .tone(layout::Tone::Info) .latched(), )) .with(Node::banner(layout::Tone::Info, "A banner")) .with(Node::toast(layout::Tone::Success, "A toast")) .with(Node::empty("Nothing here")) .with(Node::failed("It went wrong")) .with(Node::field( Field::new(layout::FieldKind::Text, "name", "Name") .writes(Action::post("/name")), )) .with(Node::field( Field::new(layout::FieldKind::Text, "who", "Who").consulting( Consult::new(Action::get("/people")) .after(std::time::Duration::from_millis(120)), ), )) // Suggesting, which is the wiring that draws `field-suggests` // around a listbox. .with(Node::field( Field::new(layout::FieldKind::Text, "person", "Person") .suggesting(Consult::new(Action::get("/people"))), )) .with(Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/go"), submit: "Go".into(), fields: vec![Field::new(layout::FieldKind::Text, "q", "Query")], }) .with(Node::list([ // Activating, for `row-activate`. Row::new("First").meta("meta").activate(Action::get("/1")), // Current, for `row-current`. Row { current: true, ..Row::new("Second") }, // Chosen, for `row-chosen`: a live selection, which is // neither of the two above it. Row { chosen: Some(true), ..Row::new("Second and a half") }, // A tick and a menu, for `row-select` and `row-menu`. Two // acts, because one is drawn beside the row and a menu is // what the second one makes. Row::new("Third") .ticking("third", true) .offers(Act::new("Delete", Action::post("/del"))) .offers(Act::new("Archive", Action::post("/arch"))) .secondary("underneath") .relaxed(), // An outline, for `row-branch`, `row-disclose` and // `row-nested`: an open branch with a child under it, and a // shut one whose child is in the document and hidden. Row::new("Fourth").disclosing(true), Row::new("Fourth, inside").depth(quasi_router::layout::Nesting::at(1)), Row::new("Fifth").disclosing(false), Row::new("Fifth, inside").depth(quasi_router::layout::Nesting::at(1)), ])) .with(Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: sortable_columns, rows: vec![ // Ticked and unticked, for `table-select` and // `table-row-selected`. Row::cells(["kick.wav", "2.1 MB"]).ticking("kick", false), Row::cells(["snare.wav", "1.4 MB"]) .ticking("snare", true) .current(true), // Chosen, for `table-row-chosen`. Row::cells(["tom.wav", "0.9 MB"]).choosing("tom", true), // Two acts, so the row carries a menu. Row::cells(["hat.wav", "0.3 MB"]) .ticking("hat", false) .offers(Act::new("Delete", Action::post("/del"))) .offers(Act::new("Archive", Action::post("/arch"))), // An outline in a table, for `table-disclose` and its // head cell. Row::cells(["kit", ""]).disclosing(true), Row::cells(["kit/ride.wav", "1.1 MB"]) .depth(quasi_router::layout::Nesting::at(1)), ], // A pager with both directions and a position, for the four // `rest-*` classes. more: Some( Rest::page(100, 50) .of(400) .back(Action::get("/t?page=2")) .forward(Action::get("/t?page=4")), ), }) // A pager that offers numbered pages, for the three // `rest-page*` classes. Its own list rather than added to the // table above, because a strip replaces the position readout // and that one is what covers `rest-position`. .with( Node::list([Row::new("One")]).and_more( Rest::page(100, 50) .of(400) .back(Action::get("/t?page=2")) .forward(Action::get("/t?page=4")) .jumping(Jump::new(2, Action::get("/t?page=2"))) .jumping(Jump::new(3, Action::get("/t?page=3")).here()) .jumping(Jump::new(4, Action::get("/t?page=4"))), ), ) // A tab strip, for `selector` and `tab`. Derived from a region // showing one labelled child at a time, which is the only way // there is to describe one. .with(Node::Region( Slot::new("tabs", RegionKind::TabGroup) .showing_one(0) .frame( "Open", Node::Region( Slot::new("open", RegionKind::Group) .with(Node::text("what is open")), ), ) .frame("Done", Node::Region(Slot::new("done", RegionKind::Group))), )) .with(Node::Meter(Meter::new(3, 10))) // One figure that answers a click, for `figure-act`. .with(Node::Stats { marks: ::quasi_router::stage::Marks::none(), figures: vec![ (Figure::new("12", "Open"), None), ( Figure::new("3", "Overdue"), Some(Action::get("/tasks?due=past")), ), ], }) .with(Node::Region( Slot::new("nested", RegionKind::Pane).with(Node::text("inside")), )), ) .with(Slot::new("aside", RegionKind::Pane).with(Node::text("beside"))) // A screen that says something, two ways. The banner rests in the // frame when the mount reports; the toast floats regardless, which is // what draws the `notices` container. .saying(Node::banner(layout::Tone::Warning, "Careful")) .saying(Node::toast(layout::Tone::Success, "Saved")) } /// One screen per region kind, arrangement and measure. /// /// [`everything`] covers the nodes; these cover the wrappers, which is the /// other half of this renderer's own vocabulary and the half a fragment can /// never show. fn every_shape() -> Vec<Screen> { let kinds = [ RegionKind::Band, RegionKind::Sidebar, RegionKind::Pane, RegionKind::Group, RegionKind::TabGroup, RegionKind::Modal, RegionKind::Handover { name: "graph".into(), }, RegionKind::Ceded { name: "revenue-chart".into(), }, RegionKind::Widget { name: "timer".into(), }, ]; let mut screens: Vec<Screen> = kinds .into_iter() .map(|kind| { Screen::list_detail("Shapes", false) .with(Slot::new("r", kind).with(Node::text("in it"))) }) .collect(); screens.push(Screen::list_detail("Tabbed", true)); screens.push(Screen::sidebar_content("Sidebar")); for measure in [ layout::Measure::Wide, layout::Measure::Contained, layout::Measure::Reading, ] { screens.push(Screen::list_detail("Measured", false).measured(measure)); } screens } /// A fixed instant, so the readouts render without a clock. fn instant() -> std::time::SystemTime { std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000) } #[test] fn every_class_this_renderer_emits_is_one_it_wrote_down() { // goingson `43a682b0`, and the guard that makes `vocabulary::OWN` honest. // // makeover can scrape the stylesheet it just generated and have no second // source to drift from. This crate generates no CSS -- it emits markup, and // there is nothing to read back -- so the list is written by hand and this // is what keeps it true. A name added to an emitter and not to the list // fails here rather than quietly shrinking the set an app checks against, // which would make that app's dead-CSS answer wrong in the safe-looking // direction. let opts = Emit::default(); let document = Webview::new() .with_frame( Frame::new() .reporting() .offering(Act::new("Save", Action::post("/save"))), ) .with_shell( Shell::under("/static").with_chrome( quasi_router::Chrome::new() .offering(quasi_router::Place::new( "tasks", "Tasks", Action::get("/tasks"), )) .banded(banded()) .presenting( "timer", quasi_router::Role::Activity, Node::text("00:12:04"), ), ), ) .screen(&everything()); let mut emitted = classes_emitted(&document); for screen in every_shape() { emitted.extend(classes_emitted(&render(&screen))); } assert!( !emitted.is_empty(), "the corpus rendered no classes at all, so this test proves nothing" ); let stranger: Vec<&String> = emitted .iter() .filter(|class| !crate::vocabulary::covers(class, &opts)) .collect(); assert!( stranger.is_empty(), "{} class(es) emitted that `vocabulary` does not name. Add them to \ `vocabulary::OWN`, or to makeover if makeover should be styling them:\n{}", stranger.len(), stranger .iter() .map(|c| format!(" .{c}")) .collect::<Vec<_>>() .join("\n") ); } #[test] fn every_name_this_renderer_wrote_down_is_one_it_still_emits() { // The other direction, and the one that fails unsafely if it is missing. A // stale entry in `OWN` is a class an app's drift check believes is live, so // the app keeps a rule for markup nothing produces -- which is precisely // the dead CSS the check exists to find, preserved by the check itself. // // Only this crate's own names. makeover's half legitimately contains // vocabulary these screens never reach (`facet-*`, `well`, `card`), because // makeover styles more than a described screen asks for. let opts = Emit::default(); let mut emitted = classes_emitted( &Webview::new() .with_frame( Frame::new() .reporting() .offering(Act::new("Save", Action::post("/save"))), ) .with_shell( Shell::under("/static").with_chrome( quasi_router::Chrome::new() .offering(quasi_router::Place::new( "tasks", "Tasks", Action::get("/tasks"), )) .banded(banded()) .presenting( "timer", quasi_router::Role::Activity, Node::text("00:12:04"), ), ), ) .screen(&everything()), ); for screen in every_shape() { emitted.extend(classes_emitted(&render(&screen))); } let stale: Vec<&&str> = crate::vocabulary::OWN .iter() .filter(|name| !emitted.contains(&makeover_webview::class(name, &opts))) .collect(); assert!( stale.is_empty(), "{} name(s) in `vocabulary::OWN` that the corpus never emits. Either an \ emitter stopped writing them -- delete them, and the app rules that \ match them -- or this corpus stopped covering them:\n{}", stale.len(), stale .iter() .map(|c| format!(" .{c}")) .collect::<Vec<_>>() .join("\n") ); } /// The region says which control and which value bring it out, and the emitter /// writes exactly that: no verb, no trigger, no target, because there is no /// request in a reveal. #[test] fn a_conditional_region_emits_its_condition_and_no_transport() { let html = render( &Screen::list_detail("Pricing", false).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(Reveal::ticked("pwyw")) .with(Node::text("Suggested price")), )), ), ); assert!(html.contains(r#"data-reveal="pwyw""#), "{html}"); assert!(html.contains(r#"data-reveal-when="any""#), "{html}"); // The whole point of the member: the section is already in the document, so // nothing is fetched to show it. assert!(!html.contains("hx-get=\"/pwyw"), "{html}"); assert!(!html.contains("data-reveal-values"), "{html}"); } /// The value shapes, as the six MNW sites need them: one value for a select /// resting on `custom`, several for a position that takes any of two. #[test] fn a_condition_on_a_value_carries_the_values_it_accepts() { let one = render( &Screen::list_detail("Licensing", false).with( Slot::new("body", RegionKind::Pane).with(Node::Region( Slot::group("dash-custom-license") .revealed_by(Reveal::holding("license_kind", "custom")), )), ), ); assert!(one.contains(r#"data-reveal="license_kind""#), "{one}"); assert!(one.contains(r#"data-reveal-when="value""#), "{one}"); assert!( one.contains(r#"data-reveal-values="["custom"]""#), "{one}" ); let several = render( &Screen::list_detail("Placement", false).with( Slot::new("body", RegionKind::Pane) .with(Node::Region(Slot::group("offset-input").revealed_by( Reveal::holding_one_of("position", ["before", "after"]), ))), ), ); assert!( several.contains(r#"data-reveal-values="["before","after"]""#), "{several}" ); let unticked = render(&Screen::list_detail("Defaults", false).with( Slot::new("body", RegionKind::Pane).with(Node::Region( Slot::group("advanced").revealed_by(Reveal::unticked("use_defaults")), )), )); assert!( unticked.contains(r#"data-reveal-when="none""#), "{unticked}" ); } /// A watched value is app text and goes into an attribute, so it is escaped as /// JSON and then as HTML, the way every other payload this emitter writes is. #[test] fn a_watched_value_cannot_close_the_attribute_it_sits_in() { let html = render(&Screen::list_detail("Odd", false).with( Slot::new("body", RegionKind::Pane).with(Node::Region( Slot::group("odd").revealed_by(Reveal::holding("kind", r#"" onclick="alert(1)"#)), )), )); assert!( html.contains(r#"data-reveal-values="["\" onclick=\"alert(1)"]""#), "{html}" ); assert!(!html.contains(r#"" onclick=""#), "{html}"); } /// Absent by default, or every region on every screen would start claiming a /// control to watch. #[test] fn an_ordinary_region_names_no_condition() { let html = render( &Screen::list_detail("Tasks", false) .with(Slot::new("body", RegionKind::Pane).with(Node::text("plain"))), ); assert!(!html.contains("data-reveal"), "{html}"); } /// Linked by default, and it stays when htmx goes: a document that calls no /// route can still carry a form with a section that only sometimes applies. #[test] fn the_reveal_script_is_linked_and_outlives_htmx() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-reveal.js"), "{with}"); let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("quasi-reveal.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_reveal()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-reveal.js"), "{without}"); } /// The script reads the three attributes this emitter writes and nothing else. /// A stale copy in an app's static directory is a form whose sections all show /// at once, which is what pairing the two here catches. #[test] fn the_reveal_script_reads_the_marks_the_emitter_writes() { for mark in ["data-reveal", "data-reveal-when", "data-reveal-values"] { assert!(crate::REVEAL_JS.contains(mark), "{mark} is not read"); } // No request in it, and no program built from app text: the values arrive // as data and are parsed as JSON. assert!( !crate::REVEAL_JS.contains("fetch("), "the reveal makes no request" ); assert!(!crate::REVEAL_JS.contains("new Function"), "no eval"); } // One conditional question inside a form: `8fdb814c`, goingson's zone picker. /// A form's questions are a flat list, so a single conditional question inside /// one carries the condition itself. The box stays inside the form and still /// submits, which is the half a region outside the form could not have. #[test] fn a_conditional_question_carries_its_condition_and_stays_in_its_form() { let html = render(&Screen::list_detail("Event", false).with( Slot::new("body", RegionKind::Pane).with(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("local", "Anchored to a place")], ), Field::new(layout::FieldKind::Text, "timezone", "Anchored to") .revealed_by(Reveal::holding("tz_kind", "local")), ], }), )); assert!(html.contains(r#"data-reveal="tz_kind""#), "{html}"); assert!(html.contains(r#"data-reveal-when="value""#), "{html}"); assert!( html.contains(r#"data-reveal-values="["local"]""#), "{html}" ); // One form, and the conditional box is inside it: a browser submits a // hidden input, and that is what keeps a closed section's value. assert_eq!(html.matches("<form").count(), 1, "{html}"); let form = &html[html.find("<form").expect("a form")..]; let box_at = form.find(r#"name="timezone""#).expect("the box"); assert!(box_at < form.find("</form>").expect("the end"), "{form}"); // No request in a reveal. assert!(!html.contains("hx-get=\"/events/timezone"), "{html}"); } /// Absent by default, or every question on every form would start claiming a /// control to watch. #[test] fn an_ordinary_question_names_no_condition() { let html = render(&form_with(Field::new( layout::FieldKind::Text, "title", "Title", ))); assert!(!html.contains("data-reveal"), "{html}"); } // A question answered N times: `60d1753c`, ruled 2026-08-25. /// The reminders form goingson `8fdb814c` restores: one question, three /// answers, eight at most. fn reminders() -> Field { Field::new(layout::FieldKind::Number, "reminder", "Reminder").repeating( Repeat::answered(["300", "900", "3600"]) .most(8) .adding("Add reminder") .removing("Remove"), ) } fn form_with(field: Field) -> Screen { Screen::list_detail("Event", false).with(Slot::new("body", RegionKind::Pane).with(Node::Form { marks: ::quasi_router::stage::Marks::none(), action: Action::post("/events"), submit: "Save".into(), fields: vec![field], })) } /// One submit carrying N values under one question, which is the whole of what /// the member is for. Each slot submits under `name[i]`, so a host reads them /// back as a list and nothing is a second form. #[test] fn every_slot_of_a_repeating_question_submits_under_its_own_indexed_name() { let html = render(&form_with(reminders())); let standing = &html[..html.find("<template").expect("a blank slot")]; for at in 0..3 { assert!( standing.contains(&format!(r#"name="reminder[{at}]""#)), "{standing}" ); } // The fourth name is in the blank the add control clones and nowhere else, // so nothing outside the template submits it. assert!(!standing.contains(r#"name="reminder[3]""#), "{standing}"); assert_eq!(html.matches(r#"name="reminder[3]""#).count(), 1, "{html}"); // One form and one submit, not three. assert_eq!(html.matches("<form").count(), 1, "{html}"); assert_eq!(html.matches("act-submit").count(), 1, "{html}"); // And the values the description offered are back in their own boxes. assert!(html.contains(r#"value="300""#), "{html}"); assert!(html.contains(r#"value="3600""#), "{html}"); } /// The second hard part. A refusal names one answer, and the message lands on /// that box rather than on the question. #[test] fn an_error_can_belong_to_one_slot_rather_than_to_the_question() { let field = Field::new(layout::FieldKind::Number, "reminder", "Reminder") .repeating(Repeat::answered(["300", "-1", "3600"]).wrong(1, "Must not be negative")); let html = render(&form_with(field)); // The message rides on the slot that owns it: makeover-webview writes the // error's id off the field's name, and the field's name is the slot's. assert!(html.contains(r#"id="reminder[1]-error""#), "{html}"); assert!(html.contains("Must not be negative"), "{html}"); assert!(!html.contains(r#"id="reminder[0]-error""#), "{html}"); assert!(!html.contains(r#"id="reminder[2]-error""#), "{html}"); } /// What is wrong with the *set* is the question's own error, and it is not any /// slot's. #[test] fn an_error_about_the_set_sits_on_the_question() { let mut field = reminders(); field.error = Some("At most eight reminders".into()); let html = render(&form_with(field)); assert!(html.contains("At most eight reminders"), "{html}"); assert!(!html.contains(r#"id="reminder[0]-error""#), "{html}"); } /// The third hard part, and it costs no round trip: the blank slot is already /// in the document, so adding one is a clone rather than a request. #[test] fn adding_and_removing_a_slot_asks_no_route() { let html = render(&form_with(reminders())); assert!(html.contains("<template"), "{html}"); assert!(html.contains("data-repeat-add"), "{html}"); assert!(html.contains("data-repeat-remove"), "{html}"); assert!(html.contains("Add reminder"), "{html}"); // The two controls call nothing, and they do not submit the form they sit // in either. let group = &html[html.find("field-repeat").expect("a group")..]; let controls = group.match_indices("data-repeat-add").count() + group.match_indices("data-repeat-remove").count(); assert_eq!(controls, 5, "three slots, a blank, and the add: {group}"); assert_eq!( group.matches("type=\"button\"").count(), 5, "no control here submits: {group}" ); assert!(!group.contains("hx-get"), "{group}"); assert!(!group.contains("hx-post"), "{group}"); } /// The floor and the ceiling are the description's, and the first paint already /// says which control is spent. The script recomputes the same answer after /// every change. #[test] fn the_floor_and_the_ceiling_reach_the_controls() { let full = render(&form_with( Field::new(layout::FieldKind::Number, "reminder", "Reminder") .repeating(Repeat::answered(["300", "900"]).most(2)), )); assert!(full.contains(r#"data-repeat-most="2""#), "{full}"); assert!(full.contains("data-repeat-add disabled"), "{full}"); let floored = render(&form_with( Field::new(layout::FieldKind::Text, "guest", "Guest") .repeating(Repeat::answered(["ana"]).least(1)), )); assert!(floored.contains(r#"data-repeat-least="1""#), "{floored}"); assert!(floored.contains("data-repeat-remove disabled"), "{floored}"); } /// Zero answers is a real state and draws the add control alone, which is the /// "zero or more" half of the done condition. #[test] fn a_question_nobody_has_answered_still_offers_a_slot_to_add() { let html = render(&form_with( Field::new(layout::FieldKind::Number, "reminder", "Reminder") .repeating(Repeat::new().adding("Add reminder")), )); // Nothing standing: the only `reminder[0]` on the page is the blank the add // control clones, which submits nothing until it is taken out. assert!(html.contains("data-repeat-slots></div>"), "{html}"); assert_eq!(html.matches(r#"name="reminder[0]""#).count(), 1, "{html}"); assert!(html.contains("data-repeat-add"), "{html}"); assert!(html.contains("Add reminder"), "{html}"); } /// Every slot is an ordinary field of the same emitter, so a repeating question /// inherits what a field already gets: its kind, its bounds, its unit. #[test] fn a_slot_keeps_everything_the_question_said_about_itself() { let mut field = reminders(); field.min = Some("0".into()); field.unit = Some("s".into()); let html = render(&form_with(field)); assert_eq!(html.matches(r#"type="number""#).count(), 4, "{html}"); assert_eq!(html.matches(r#"min="0""#).count(), 4, "{html}"); assert!(html.contains(r#"id="reminder[0]-unit""#), "{html}"); } /// The name and the label are app text and both go into attributes, so both are /// escaped like every other payload this emitter writes. #[test] fn a_repeating_question_cannot_close_the_attribute_it_sits_in() { let html = render(&form_with( Field::new(layout::FieldKind::Text, "odd", r#"" onclick="alert(1)"#) .repeating(Repeat::answered(["one"])), )); assert!(!html.contains(r#"" onclick=""#), "{html}"); assert!(html.contains("" onclick="alert(1)"), "{html}"); } /// Absent by default, or every field in every form would start drawing a /// fieldset and two controls. #[test] fn an_ordinary_field_repeats_nothing() { let html = render(&form_with(Field::new( layout::FieldKind::Text, "title", "Title", ))); assert!(!html.contains("data-repeat"), "{html}"); assert!(!html.contains("<fieldset"), "{html}"); assert!(html.contains(r#"name="title""#), "{html}"); } /// Linked by default, and it goes when htmx does: without htmx every response /// is a navigation, and a navigation has no focus to preserve. /// /// The failure it fixes is silent to anyone not navigating by keyboard, which /// is why the default matters more here than elsewhere: nobody would report /// the script being absent. #[test] fn the_focus_script_is_linked_by_default_and_goes_with_htmx() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-focus.js"), "{with}"); let without = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-focus.js"), "{without}"); } /// Every script this renderer ships has to be served by whoever embeds it, so /// the constant and the default path have to agree. They are written in two /// files and nothing else ties them together. #[test] fn the_focus_script_is_served_under_the_name_the_shell_asks_for() { let shell = Shell::default(); assert_eq!(shell.focus_src.as_deref(), Some("/static/quasi-focus.js")); assert!( crate::FOCUS_JS.contains("htmx:after:settle"), "the settle hook" ); assert!( crate::FOCUS_JS.contains("htmx:before:request"), "and the record taken before it" ); } /// Linked by default, and it stays when htmx goes: folding a branch is the /// reader tidying their own view, and the rows it hides and shows are already /// in the page. #[test] fn the_outline_script_is_linked_and_outlives_htmx() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-outline.js"), "{with}"); let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("quasi-outline.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_outline()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-outline.js"), "{without}"); } /// Linked by default, and it stays when htmx goes: adding a slot is a clone of /// markup already in the page, so a document that calls no route can still /// carry a repeating question. #[test] fn the_repeat_script_is_linked_and_outlives_htmx() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-repeat.js"), "{with}"); let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("quasi-repeat.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_repeat()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-repeat.js"), "{without}"); } /// The script reads the marks this emitter writes and nothing else. A stale /// copy in an app's static directory is a form whose add control is dead, which /// is what pairing the two here catches. #[test] fn the_repeat_script_reads_the_marks_the_emitter_writes() { for mark in [ "data-repeat", "data-repeat-label", "data-repeat-least", "data-repeat-most", "data-repeat-slots", "data-repeat-at", "data-repeat-add", "data-repeat-remove", ] { assert!(crate::REPEAT_JS.contains(mark), "{mark} is not read"); } // No request in it, and no program built from app text: the question's name // and its label arrive as escaped attributes. assert!( !crate::REPEAT_JS.contains("fetch("), "the repeat makes no request" ); assert!(!crate::REPEAT_JS.contains("new Function"), "no eval"); } #[test] fn the_shell_serves_the_binder_that_makes_a_wait_visible() { // `d43ea1c5`. The emitter said which calls wait, the design system drew // what a wait looks like, and nothing connected the two: `aria-busy` is // true only between two events, so it is this script's to set. let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-awaiting.js"), "{with}"); // Not independent of htmx, unlike reveal, repeat and fill: what it listens // to is the request lifecycle, and a document that makes no requests has // none. let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(!embed.contains("quasi-awaiting.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_awaiting()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-awaiting.js"), "{without}"); } #[test] fn the_shell_serves_the_script_that_submits_a_moment_rather_than_the_words() { // makeover-layout 0.37.0's `Field::as_instant`. The description says a // wall-clock value is submitted as the moment it names and declines to say // how, because the browser is the only party that knows what "your // computer's time zone" means. let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-instant.js"), "{with}"); // Not independent of htmx: it rewrites the request htmx is about to make. let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(!embed.contains("quasi-instant.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_instant()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-instant.js"), "{without}"); } #[test] fn the_shell_serves_the_script_that_folds_a_run_that_ran_out_of_room() { // `Fallback::Menu` says the members that no longer fit move into one // overflow control. makeover-webview's CSS cannot ask whether they fit -- // @container compares against a length -- so the measuring is a script. let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("quasi-menu.js"), "{with}"); // Independent of htmx: a run has room whether or not the page has routes. let embed = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(embed.contains("quasi-menu.js"), "{embed}"); let without = Webview::new() .with_shell(Shell::default().without_menu()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-menu.js"), "{without}"); } #[test] fn the_menu_script_marks_the_run_before_it_measures_it() { // The mark is what turns `flex-wrap: wrap` off. Measuring first would // always find room, because a wrapped run always fits, and the fold would // never happen. It is also the whole of the degrade: a page that never runs // this keeps wrap and every member stays reachable. let at_mark = crate::MENU_JS.find("data-menu").expect("writes the mark"); let at_measure = crate::MENU_JS .find("scrollWidth") .expect("measures the overflow"); assert!( at_mark < at_measure, "the mark is written before the measure" ); // Priority, read off what `Ranked` already emits rather than off a second // spelling of the same fact. assert!( crate::MENU_JS.contains("cell-drops-first"), "reads priority" ); assert!(crate::MENU_JS.contains("cell-drops-next"), "reads priority"); // A tab strip is one member of its run and its tabs are what fold. A // reading that only looked at the run's children would find one strip that // always fits. assert!(crate::MENU_JS.contains("tablist"), "folds a strip's tabs"); assert!(!crate::MENU_JS.contains("new Function"), "no eval"); } #[test] fn the_instant_script_leaves_an_unparseable_value_for_the_route_to_answer() { // Rewriting it would turn a legible validation error into `Invalid Date`. assert!( crate::INSTANT_JS.contains("Number.isNaN"), "an unparseable value has to be left alone" ); // It converts on the way out and never writes the box, so the reader keeps // seeing the time they typed. assert!( !crate::INSTANT_JS.contains(".value ="), "the control is not rewritten" ); assert!(!crate::INSTANT_JS.contains("new Function"), "no eval"); } #[test] fn the_binder_reads_delivery_and_never_a_clock() { // Rule 1 of wiki `loading-and-progress-standard`, asserted on the file // itself. A timer advancing the share is the easiest thing in the world to // add here and it is exactly the confidently-wrong drawing the rule // forbids: a bar walking forward on a stalled transfer. assert!(crate::AWAITING_JS.contains("loaded"), "the numerator"); assert!(crate::AWAITING_JS.contains("aria-busy"), "the running flag"); for banned in [ "setInterval", "setTimeout", "requestAnimationFrame", "Date.now", ] { assert!( !crate::AWAITING_JS.contains(banned), "{banned} would make the share a prediction" ); } } #[test] fn an_act_hint_is_a_title_and_an_accessible_description() { // `ca7b5200`. Both, because `title` alone is a hover: a touch reader never // sees it and several screen readers are configured not to announce it. The // shipped templates said it 90 times with `title` and reached one audience. let html = fragment(&Node::Act( Act::new("Verify library integrity", Action::post("/verify")) .hint("The result appears in the status line."), )); assert!( html.contains(r#"title="The result appears in the status line.""#), "{html}" ); assert!( html.contains(r#"aria-description="The result appears in the status line.""#), "{html}" ); } #[test] fn an_act_with_no_hint_says_neither() { // The member is additive: a control written before it existed emits exactly // the markup it always did. let html = fragment(&Node::Act(Act::new("Save", Action::post("/save")))); assert!(!html.contains("title="), "{html}"); assert!(!html.contains("aria-description="), "{html}"); } #[test] fn a_regions_own_name_lands_on_the_region() { // `aad33ecc`. `Slot::label` is the name a *parent* reads off this region and // is announced by nothing when it is set on the region itself. This is the // member that is the region's own. let html = render( &Screen::list_detail("Library", false).with( Slot::new("chrome", RegionKind::Pane) .named("Tag breadcrumbs") .with(Node::text("in it")), ), ); assert!(html.contains(r#"aria-label="Tag breadcrumbs""#), "{html}"); } #[test] fn a_tab_strips_name_lands_on_the_tablist_and_not_twice() { // The strip is the element carrying `role="tablist"`, so it is the element a // screen reader announces. A named `div` with no role is announced by // nothing, so writing it in both places would be one announcement and one // wasted attribute. let html = render( &Screen::list_detail("Library", false).with( Slot::new("sections", RegionKind::TabGroup) .named("Library sections") .showing_one(0) .frame("Feed", Node::Region(Slot::new("feed", RegionKind::Pane))) .frame( "Purchases", Node::Region(Slot::new("purchases", RegionKind::Pane)), ), ), ); assert_eq!( html.matches(r#"aria-label="Library sections""#).count(), 1, "{html}" ); let strip = html .find(r#"role="tablist""#) .expect("a labelled tab group emits a strip"); let name = html .find(r#"aria-label="Library sections""#) .expect("the name is emitted"); // On the strip's own tag: the name follows the role within the same element. assert!(name > strip, "{html}"); } #[test] fn a_region_with_no_name_of_its_own_says_nothing() { let html = render( &Screen::list_detail("Library", false) .with(Slot::new("chrome", RegionKind::Pane).with(Node::text("in it"))), ); assert!(!html.contains("aria-label="), "{html}"); } #[test] fn a_named_region_is_still_an_id_target() { // `Replaces::Region` is what `replaces` was before it was three things, and // the emission is unchanged: the escape hatch of decision 7, for a route // the description layer does not serve. let html = fragment(&Node::act( "Remove", Action::post("/api/links/7/delete").replacing("link-list"), )); assert!(html.contains("hx-target=\"#link-list\""), "{html}"); } #[test] fn an_enclosing_target_is_the_row_and_not_an_id() { // `5aa43815`. The two measured sites are shared partials called from // several parents, so there is no id to name and the row has to be found by // where the act is standing. let html = fragment(&Node::act( "Remove", Action::post("/api/links/7/delete").replacing_enclosing(), )); assert!(html.contains("hx-target=\"closest [data-row]\""), "{html}"); // Replaces the row rather than filling it: the answer would otherwise nest // inside the element it was meant to remove. assert!(html.contains("hx-swap=\"outerHTML\""), "{html}"); assert!(!html.contains("hx-target=\"#"), "{html}"); } #[test] fn both_kinds_of_row_carry_the_hook_an_enclosing_target_finds() { // One attribute across the two, matching `data-menu="row"`: a screen mixing // a list and a table needs one selector rather than two that look alike. let list = fragment(&Node::list([Row::new("One")])); assert!(list.contains("<li"), "{list}"); assert!(list.contains("data-row"), "{list}"); let table = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Name")], rows: vec![Row::cells([Cell::new("One")])], more: None, }); assert!(table.contains("role=\"row\""), "{table}"); assert!(table.contains("data-row"), "{table}"); } #[test] fn the_row_hook_survives_a_class_prefix() { // Why it is an attribute and not a class, which is `data-act`'s reason: the // selector is built where `Emit` is not in hand. let emit = crate::Emit { class_prefix: "qs-", ..crate::Emit::default() }; let html = Webview::new() .with_emit(emit) .fragment(&Node::list([Row::new("One")])); assert!(html.contains("data-row"), "{html}"); assert!(!html.contains("qs-data-row"), "{html}"); } #[test] fn an_act_contained_by_nothing_names_no_target_at_all() { // `f4cc9c6c`. Fifteen shipped acts fire and leave the surface they sit on // stale. htmx has no request-side spelling for that, so it is a named hook // the host acts on, the way `data-saves` is. let html = fragment(&Node::act( "Disconnect Stripe", Action::post("/api/stripe/disconnect").invalidating(), )); assert!(html.contains("data-replaces=\"everything\""), "{html}"); assert!(!html.contains("hx-target"), "{html}"); } #[test] fn a_copying_act_carries_its_value_and_asks_nothing() { // `c3e145e0`. Seven MNW globals across 14 sites are this. The value rides in // an escaped attribute and `COPY_JS` reads it; the act's action is local, so // no transport attribute is emitted at all. let html = fragment(&Node::act("Copy", quasi_router::Action::local())); assert!(!html.contains("data-copies"), "{html}"); let act = quasi_router::Act::new("Copy", quasi_router::Action::local()).copying("mnw_live_abc123"); let html = fragment(&Node::Act(act)); assert!(html.contains("data-copies=\"mnw_live_abc123\""), "{html}"); assert!(!html.contains("hx-get"), "{html}"); assert!(!html.contains("hx-post"), "{html}"); } #[test] fn a_copied_value_is_escaped_and_never_a_program() { // `FILL_JS`' rule, and the reason this is a script reading an attribute // rather than an emitted _hyperscript program: the value is app text. let act = quasi_router::Act::new("Copy", quasi_router::Action::local()).copying(r#""; alert(1); ""#); let html = fragment(&Node::Act(act)); assert!(!html.contains("alert(1);\""), "{html}"); assert!(html.contains("""), "{html}"); } #[test] fn the_copy_script_reads_the_hook_the_emitter_writes() { assert!(crate::COPY_JS.contains("data-copies"), "the value hook"); // Delegated, so a control arriving in a swap is covered. assert!(crate::COPY_JS.contains("addEventListener")); // No program is built out of the value. assert!(!crate::COPY_JS.contains("new Function"), "no eval"); assert!( !crate::COPY_JS.contains("innerHTML"), "no markup from a value" ); // The acknowledgement is deliberately not here: that is `033c722f`. Tested // as "it relabels nothing", not as "the word is absent" -- the file explains // in a comment why it does not do this, and that comment should stay. assert!( !crate::COPY_JS.contains("textContent"), "no temporary label" ); assert!(!crate::COPY_JS.contains("setTimeout"), "no revert timer"); } #[test] fn the_shell_links_the_copy_script_and_can_drop_it() { let with = Webview::new().screen(&Screen::list_detail("A", false)); assert!(with.contains("/static/quasi-copy.js"), "{with}"); let under = Webview::under("/assets").screen(&Screen::list_detail("A", false)); assert!(under.contains("/assets/quasi-copy.js"), "{under}"); // Independent of htmx: what it reads is one attribute on a control. let bare = Webview::new() .with_shell(Shell::default().without_htmx()) .screen(&Screen::list_detail("A", false)); assert!(bare.contains("quasi-copy.js"), "{bare}"); let without = Webview::new() .with_shell(Shell::default().without_copy()) .screen(&Screen::list_detail("A", false)); assert!(!without.contains("quasi-copy.js"), "{without}"); } #[test] fn a_control_can_show_a_picture_and_still_say_its_name() { // `db998898`. The picture goes inside the control, so aiming at the // thumbnail hits the control -- which is the whole affordance of a picture // picker, and what the shipped media-picker tile loses by being a region // with the picture and the act as siblings. use quasi_router::screen::Image; let act = quasi_router::Act::new("kick.wav", quasi_router::Action::get("/media/1")) .showing(Image::new("/m/1.png", "kick.wav")); let html = fragment(&Node::Act(act)); let control = html .split_once("<a") .map(|(_, rest)| rest.split_once("</a>").map_or(rest, |(inner, _)| inner)) .unwrap_or_default(); assert!(control.contains("<img"), "the picture is inside it: {html}"); assert!(control.contains("kick.wav"), "and so is the name: {html}"); } #[test] fn a_shown_picture_is_the_same_element_a_region_body_writes() { // One spelling of `<img>`, which is why it was extracted. A picture that // drifted between the two would be a thumbnail that lazy-loads in a region // and not in a control. use quasi_router::screen::Image; let picture = Image::new("/m/1.png", "kick.wav").lazy(); let in_body = fragment(&Node::Image(picture.clone())); let in_control = fragment(&Node::Act( quasi_router::Act::new("kick.wav", quasi_router::Action::get("/media/1")).showing(picture), )); let tag = |html: &str| { let at = html.find("<img").expect("an img"); html[at..].split_once('>').expect("it closes").0.to_owned() }; assert_eq!(tag(&in_body), tag(&in_control), "{in_body} / {in_control}"); assert!(tag(&in_body).contains("loading=\"lazy\"")); } #[test] fn a_control_showing_nothing_is_exactly_what_it_was() { // Additive: a description written before the member emits the same bytes. let plain = fragment(&Node::act("Save", quasi_router::Action::post("/save"))); assert!(!plain.contains("<img"), "{plain}"); } /// Every region carries the container an anchored menu lands in, and /// `anchored_target` names it -- so a route answering `Anchor::Region` /// retargets at something the document has. #[test] fn an_anchored_menu_is_aimed_at_the_region_it_names() { use quasi_http::Serves; let screen = Screen::list_detail("Files", false) .with(Slot::new("browser", RegionKind::Pane).with(Node::text("rows"))); let html = render(&screen); assert!( html.contains("id=\"browser-anchored\" data-menu=\"anchored\" hidden></div>"), "{html}" ); assert_eq!( Webview::new().anchored_target(&quasi_router::Anchor::Region("browser".into())), Some("browser-anchored".to_owned()) ); } /// A control gets a container only when the description named it, which is what /// keeps `Act::id` additive: an unnamed control emits exactly what it did. #[test] fn only_a_named_control_carries_an_anchor_container() { use quasi_http::Serves; let named = render( &Screen::list_detail("Files", false).with( Slot::new("bar", RegionKind::Band) .with(Node::Act(Act::new("Sort", Action::get("/sort")).id("sort"))), ), ); assert!(named.contains("id=\"sort\""), "{named}"); assert!( named.contains("id=\"sort-anchored\" data-menu=\"anchored\" hidden></div>"), "{named}" ); let bare = render(&Screen::list_detail("Files", false).with( Slot::new("bar", RegionKind::Band).with(Node::Act(Act::new("Sort", Action::get("/sort")))), )); assert!(!bare.contains("sort-anchored"), "{bare}"); assert_eq!( Webview::new().anchored_target(&quasi_router::Anchor::Control("sort".into())), Some("sort-anchored".to_owned()) ); } /// The selection's container is fixed and emitted only for a screen that holds /// a selection, so every other document is untouched by the member. #[test] fn the_selection_container_arrives_with_the_selection() { use quasi_http::Serves; let plain = render(&Screen::list_detail("Files", false)); assert!(!plain.contains("quasi-selection-anchored"), "{plain}"); let selecting = render(&Screen::list_detail("Files", false).selecting("chosen")); assert!( selecting.contains("id=\"quasi-selection-anchored\""), "{selecting}" ); assert_eq!( Webview::new().anchored_target(&quasi_router::Anchor::Selection), Some("quasi-selection-anchored".to_owned()) ); } /// An anchor naming something no document can carry degrades rather than /// panicking: `hyperscript::handle` refuses the name, no container was emitted /// for it, and the answer is sent unaimed. That is the bargain `Suggestions` /// already strikes. #[test] fn an_anchor_that_cannot_be_an_id_is_aimed_at_nothing() { use quasi_http::Serves; assert_eq!( Webview::new().anchored_target(&quasi_router::Anchor::Region("has space".into())), None ); // And the region that could not have one emitted none, so the two halves // agree about what the document contains. let html = render(&Screen::list_detail("Files", false).with(Slot::new("has space", RegionKind::Pane))); assert!(!html.contains("data-menu=\"anchored\""), "{html}"); } /// A bespoke region is a place and nothing else (decision 4), so its container /// is a sibling rather than a child -- and the region between its tags is still /// exactly what the host put there. #[test] fn a_bespoke_regions_container_sits_outside_it() { let html = render(&Screen::list_detail("Files", false).with(Slot::handover("player", "media-player"))); assert!( html.contains("data-bespoke=\"media-player\"></div>"), "{html}" ); assert!( html.contains("</div><div class=\"anchored\" id=\"player-anchored\""), "{html}" ); } /// `19d7602d`. The runs arrived classified and this renderer writes a span per /// class and no highlighter of its own. A plain run gets no span at all, which /// is most of a file. #[test] fn a_code_block_writes_one_span_per_classified_run_and_none_for_plain() { use quasi_router::screen::Lexeme; let html = Webview::new().fragment(&Node::Code { runs: vec![ Lexeme::new("fn", layout::Syntax::Keyword), Lexeme::plain(" main() { "), Lexeme::new("\"hi\"", layout::Syntax::String), Lexeme::plain(" }"), ], language: Some("rust".to_owned()), inline: false, }); assert!(html.contains("<pre"), "{html}"); assert!(html.contains("data-language=\"rust\""), "{html}"); assert!( html.contains("<span class=\"lex-keyword\">fn</span>"), "{html}" ); assert!( html.contains("<span class=\"lex-string\">"hi"</span>") || html.contains("<span class=\"lex-string\">\"hi\"</span>"), "{html}" ); // Two classified runs, two spans. The plain ones are bare text. assert_eq!(html.matches("<span class=\"lex-").count(), 2, "{html}"); assert!(html.contains(" main() { "), "{html}"); } /// Concatenating the runs gives the source back, which is what `Lexeme::text` /// guarantees and what makes a code view usable at all. #[test] fn a_code_block_loses_no_character_of_its_source() { use quasi_router::screen::Lexeme; let source = " let x = 1;\n // done\n"; let html = Webview::new().fragment(&Node::Code { runs: vec![ Lexeme::plain(" let x = "), Lexeme::new("1", layout::Syntax::Constant), Lexeme::plain(";\n "), Lexeme::new("// done", layout::Syntax::Comment), Lexeme::plain("\n"), ], language: None, inline: false, }); let stripped = html .replace("<span class=\"lex-constant\">", "") .replace("<span class=\"lex-comment\">", "") .replace("</span>", ""); let body = stripped .split_once('>') .expect("an opening tag") .1 .rsplit_once("</pre>") .expect("a closing tag") .0; assert_eq!(body, source, "{html}"); } /// An inline literal is a leaf and comes out as one, which is the containment /// half of why `inline` is a flag rather than a second member. #[test] fn an_inline_literal_is_a_code_element_and_not_a_block() { use quasi_router::screen::Lexeme; let html = Webview::new().fragment(&Node::Code { runs: vec![Lexeme::plain("git clone https://example.com/r.git")], language: None, inline: true, }); assert!(html.contains("<code"), "{html}"); assert!(!html.contains("<pre"), "{html}"); } /// A diff line says which side it is on, and an ordinary table says nothing, /// which is what keeps every list in the tree from reading as a diff. #[test] fn only_a_diff_row_carries_a_change_and_an_ordinary_row_carries_none() { use quasi_router::screen::Column; let diff = Webview::new().fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("line")], rows: vec![ Row::cells(["+ added"]).changed(layout::Change::Added), Row::cells(["- gone"]).changed(layout::Change::Removed), Row::cells([" same"]).changed(layout::Change::Context), ], more: None, }); assert!(diff.contains("data-change=\"added\""), "{diff}"); assert!(diff.contains("data-change=\"removed\""), "{diff}"); assert!(diff.contains("data-change=\"context\""), "{diff}"); // The tone rides beside it, so a host that has never heard of a diff still // gets the success and danger it already styles. assert!(diff.contains("data-tone=\"success\""), "{diff}"); assert!(diff.contains("data-tone=\"danger\""), "{diff}"); let plain = Webview::new().fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("name")], rows: vec![Row::cells(["kick.wav"])], more: None, }); assert!(!plain.contains("data-change"), "{plain}"); } /// A row's identity reaches the document whether or not the row is tickable. /// /// `Row::identified` says what the app calls this row, and a host that cannot /// read it back has been told nothing. MNW's file view is the measured /// consumer: its rows are lines and `#L42` is a line's identity. #[test] fn a_row_that_names_itself_says_so() { let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Line").width(layout::Width::Fill)], rows: vec![Row::cells([Cell::new("fn main() {}")]).identified("L1")], more: None, }); assert!(html.contains("data-value=\"L1\""), "{html}"); // Not an id: a row identity is unique within its table, and two tables of // the same things on one screen is an ordinary description. assert!(!html.contains("id=\"L1\""), "{html}"); } /// A row that carries a document address reaches it as an `id`. /// /// The other half of the test above, and the member that fixes what it /// documents. `Row::address` is the one row member promising uniqueness in /// the document rather than in the table, so it is the one that earns an id. /// MNW's file view links every line number to `#L42`; before this member, the /// address it pointed at was in no document. #[test] fn a_row_that_carries_an_address_is_reachable_at_it() { let html = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: vec![Column::new("Line").width(layout::Width::Fill)], rows: vec![Row::cells([Cell::new("fn main() {}")]).addressed("L1")], more: None, }); assert!(html.contains("id=\"L1\""), "{html}"); // The address is not the value. A row can carry both, and a row carrying // only an address contributes nothing to a selection. assert!(!html.contains("data-value"), "{html}"); // A list row says it the same way. let list = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: Vec::new(), rows: vec![Row::new("fn main() {}").addressed("L1")], more: None, }); assert!(list.contains("id=\"L1\""), "{list}"); // And the escaping is the document's, not the description's. let hostile = fragment(&Node::Table { marks: ::quasi_router::stage::Marks::none(), columns: Vec::new(), rows: vec![Row::new("x").addressed("a\"onload=\"x")], more: None, }); assert!(!hostile.contains("onload=\"x\""), "{hostile}"); }