//! 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::{ Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Prose, Rest, Row, Tag, }; use quasi_router::{Action, Node, RegionKind, Screen, Slot}; 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 } fn render(screen: &Screen) -> String { Webview::new().screen(screen) } fn fragment(node: &Node) -> String { Webview::new().fragment(node) } #[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_carries_the_response_handling_config() { // Decision 9's gap. Without this a 4xx does not swap and the notice the // adapter carefully classified reaches nobody. let html = render(&Screen::list_detail("Tasks", false)); assert!(html.contains(quasi_http::htmx::CONFIG_META)); assert!(html.contains(r#"{"code":"[45]..","swap":true,"error":true}"#)); } #[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 { 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 { 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(Action::get("/tasks?page=2")).remaining(350)), ); assert!(counted.contains("350 remaining")); 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(Action::get("/more")))); assert!(uncounted.contains("Show more")); assert!(!uncounted.contains("remaining")); let plain = fragment(&Node::list([Row::new("One")])); assert!(!plain.contains("rest")); } #[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 { 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![Cells::new(["Ship it", "Tomorrow", "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 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 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 { kind: layout::Token::Chip { removable: true }, label: hostile.into(), tone: layout::Tone::Neutral, latched: false, action: Some(Action::get("/z")), })), ); let html = render(&screen); assert!(!html.contains(" [x](javascript:alert(1))", )); let node = Node::list(vec![row]); let html = Webview::new().fragment(&node); assert!(!html.contains(""), "{html}" ); assert!( html.contains(""), "{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. assert!(!html.contains("og:description"), "{html}"); assert!(!html.contains("og:image"), "{html}"); assert!(!html.contains("canonical"), "{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(""), "{html}" ); } #[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(""), "{html}" ); assert!( html.contains(""), "{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(""), "{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\" & waved")); assert!(!html.contains("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 { let mut htmls = Vec::new(); for kind in [ RegionKind::Band, RegionKind::Sidebar, RegionKind::Pane, RegionKind::Split, 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::bespoke("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(true)), 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").changes(Action::post("/live")), ), 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 { 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(Action::get("/keys?page=2")).remaining(40)), Node::list([Row::new("astra").toggling(true, Action::post("/keys/8/pin"))]), Node::Table { 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![ Cells::new([ Cell::new("deploy"), Cell::tag(Tag::badge("Paid").tone(layout::Tone::Success)), ]) .activate(Action::get("/runs/1")), Cells::new([Cell::new("build"), Cell::acts([acts()])]), ], }, Node::Meter(Meter::new(7, 10).label("7 of 10")), Node::stats([Figure::new("$12.00", "Revenue").change("+3%")]), ] { htmls.push(fragment(&node)); } for kind in [ layout::Selector::Tabs, layout::Selector::Segmented, layout::Selector::Toggle, ] { htmls.push(fragment(&Node::Select { kind, options: vec![ (Choice::plain("open"), Some(Action::get("/tasks?open=1"))), (Choice::new("done", "Done"), None), ], chosen: Some("open".into()), action: Some(Action::get("/tasks")), })); } 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 { 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", "modal", "pane", "region", "sidebar", "split", "tabgroup", // Containers. Each holds things that carry their own styled classes. "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", "rest-more", "row-actions", "row-activate", "row-proportion", "row-select", "row-selected", "row-tokens", "cell-actions", "cell-tokens", "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::>() .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("
"), "{html}" ); assert!(html.contains("4 left"), "{html}"); assert!(html.ends_with("
"), "{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\">", &Node::text("hi")); assert!(!html.contains("