Skip to main content

max / quasi

114.1 KB · 2965 lines History Blame Raw
1 //! What the renderer promises, asserted.
2 //!
3 //! Two kinds of test here, and the second is the interesting one. The first
4 //! checks that a description comes out as the markup it should. The second
5 //! checks the architectural claims the design rests on — that no `hx-target`
6 //! is ever emitted, that htmx appears in exactly one function, that a
7 //! description's text cannot become markup — because those are the properties
8 //! that would decay silently, one convenient exception at a time.
9
10 use makeover_layout as layout;
11 use quasi_http::Serves;
12 use quasi_router::screen::{
13 Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Prose, Rest, Row, Tag,
14 };
15 use quasi_router::{Action, Node, RegionKind, Screen, Slot};
16
17 use crate::{Emit, Shell, Webview};
18
19 /// A head with the screen's discovery tags removed.
20 ///
21 /// They are the one part of the head that comes off the `Screen` rather than
22 /// off the `Shell`, so a comparison against the shell's own parts has to drop
23 /// them or it is comparing two different questions.
24 fn strip_discovery(head: &str) -> String {
25 let mut out = String::with_capacity(head.len());
26 let mut rest = head;
27 while let Some(start) = rest.find("<meta property=\"og:").or_else(|| {
28 rest.find("<meta name=\"twitter:")
29 .or_else(|| rest.find("<meta name=\"robots\""))
30 .or_else(|| rest.find("<link rel=\"canonical\""))
31 }) {
32 out.push_str(&rest[..start]);
33 let end = rest[start..].find('>').expect("a tag closes") + start + 1;
34 rest = &rest[end..];
35 }
36 out.push_str(rest);
37 out
38 }
39
40 fn render(screen: &Screen) -> String {
41 Webview::new().screen(screen)
42 }
43
44 fn fragment(node: &Node) -> String {
45 Webview::new().fragment(node)
46 }
47
48 #[test]
49 fn a_screen_is_a_whole_document() {
50 let html = render(&Screen::list_detail("Tasks", false));
51 assert!(html.starts_with("<!doctype html><html lang=\"en\">"));
52 assert!(html.contains("<title>Tasks</title>"));
53 assert!(html.ends_with("</body></html>"));
54 }
55
56 #[test]
57 fn a_fragment_is_not() {
58 let html = fragment(&Node::text("hello"));
59 assert!(!html.contains("<html"));
60 assert!(!html.contains("<body"));
61 assert_eq!(html, "<p class=\"text\">hello</p>");
62 }
63
64 #[test]
65 fn the_document_carries_the_response_handling_config() {
66 // Decision 9's gap. Without this a 4xx does not swap and the notice the
67 // adapter carefully classified reaches nobody.
68 let html = render(&Screen::list_detail("Tasks", false));
69 assert!(html.contains(quasi_http::htmx::CONFIG_META));
70 assert!(html.contains(r#"{"code":"[45]..","swap":true,"error":true}"#));
71 }
72
73 #[test]
74 fn the_title_is_escaped_into_the_head() {
75 let html = render(&Screen::list_detail("</title><script>x()</script>", false));
76 assert!(!html.contains("<script>x()"));
77 assert!(html.contains("&lt;/title&gt;"));
78 }
79
80 #[test]
81 fn morph_is_emitted_only_when_the_extension_is_loaded() {
82 let with = render(&Screen::list_detail("Tasks", false));
83 assert!(with.contains("hx-ext=\"morph\""));
84
85 let without = Webview::new()
86 .with_shell(Shell::default().without_morph())
87 .screen(&Screen::list_detail("Tasks", false));
88 assert!(!without.contains("hx-ext=\"morph\""));
89 assert!(!without.contains("idiomorph"));
90
91 // The pair that matters: no element may ask for a swap the page cannot
92 // perform. htmx falls back to innerHTML silently, and silently destructive
93 // is the outcome decision 7 exists to avoid.
94 let screen = Screen::list_detail("Tasks", false)
95 .with(Slot::new("main", RegionKind::Pane).with(Node::act("Go", Action::get("/go"))));
96 let quiet = Webview::new()
97 .with_shell(Shell::default().without_morph())
98 .screen(&screen);
99 assert!(!quiet.contains("hx-swap"));
100 }
101
102 #[test]
103 fn an_action_becomes_the_verb_it_names() {
104 let get = fragment(&Node::act("Open", Action::get("/tasks/1")));
105 assert!(get.contains("hx-get=\"/tasks/1\""));
106 assert!(!get.contains("hx-post"));
107
108 let post = fragment(&Node::act("Delete", Action::post("/tasks/1/delete")));
109 assert!(post.contains("hx-post=\"/tasks/1/delete\""));
110 assert!(!post.contains("hx-get"));
111 }
112
113 #[test]
114 fn a_delete_and_a_put_reach_the_route_the_server_actually_answers() {
115 // `61e1b069`. Every described write was a POST, so a REST-shaped route was
116 // unaddressable, and the first tab described worked around it by posting to
117 // a `/delete` path that was never registered: the button rendered and
118 // answered 404. 53 sites across 34 of MNW's templates use one of these.
119 let removed = fragment(&Node::act("Delete", Action::delete("/api/blog/7")));
120 assert!(removed.contains("hx-delete=\"/api/blog/7\""), "{removed}");
121
122 let replaced = fragment(&Node::act("Move", Action::put("/api/items/7/move")));
123 assert!(
124 replaced.contains("hx-put=\"/api/items/7/move\""),
125 "{replaced}"
126 );
127
128 // Both mutate, so both are buttons. An anchor is something a browser may
129 // prefetch and a crawler will follow, and neither is allowed to delete.
130 for html in [&removed, &replaced] {
131 assert!(html.contains("<button"), "{html}");
132 assert!(!html.contains("href"), "{html}");
133 }
134 }
135
136 #[test]
137 fn a_read_of_a_route_is_a_link_and_a_write_is_not() {
138 // A GET to a route this app answers is a link in every host, and it was a
139 // `<button hx-get>` until 2026-08-10: a control that only worked once
140 // JavaScript had run, with no middle-click, no copy-link and nothing for a
141 // crawler. The anchor costs the webview host nothing, because htmx still
142 // has its own attributes and prevents the default.
143 let read = fragment(&Node::act("Open", Action::get("/tasks/1")));
144 assert!(read.contains("<a "));
145 assert!(read.contains("href=\"/tasks/1\""));
146 assert!(read.contains("hx-get=\"/tasks/1\""));
147 assert!(!read.contains("<button"));
148
149 // The other half, and the reason this is not simply "GET is an anchor
150 // everywhere": a write is never a link however it is spelled. An anchor is
151 // something a browser may prefetch and a crawler will follow, and neither
152 // is allowed to delete a task.
153 let write = fragment(&Node::act("Delete", Action::post("/tasks/1/delete")));
154 assert!(write.contains("<button"));
155 assert!(!write.contains("href"));
156
157 // An external address is still a plain link that leaves, with no transport
158 // on it at all.
159 let away = fragment(&Node::act("Docs", Action::external("https://example.com")));
160 assert!(away.contains("href=\"https://example.com\""));
161 assert!(away.contains("rel=\"noopener noreferrer\""));
162 assert!(!away.contains("hx-get"));
163 }
164
165 #[test]
166 fn a_reads_values_are_its_address_and_nothing_is_sent_beside_it() {
167 let action = Action::get("/tasks")
168 .with("filter", "open")
169 .with("sort", "due");
170 let html = fragment(&Node::act("Filter", action));
171
172 // A read sends nothing: its values say where it is going, so they belong on
173 // the address rather than in a payload htmx would fold in for it.
174 assert!(html.contains("hx-get=\"/tasks?filter=open&amp;sort=due\""));
175 assert!(!html.contains("hx-vals"));
176
177 // The href is the same address for the reader who is not htmx, and it
178 // keeps the parameters: a link to a filtered list that drops the filter is
179 // a different place.
180 assert!(html.contains("href=\"/tasks?filter=open&amp;sort=due\""));
181 }
182
183 #[test]
184 fn a_write_from_a_filtered_list_keeps_the_two_status_values_apart() {
185 // The collision this split exists for. goingson's problems inbox filters on
186 // `status` and writes a `status`; with one bag the two met in an `hx-vals`
187 // object under one key, and dismissing from the Open list answered with the
188 // Dismissed list. The view goes on the address, the payload stays in
189 // `hx-vals`, and neither can reach the other.
190 let action = Action::post("/problems/7/status")
191 .with("status", "Dismissed")
192 .carrying("status", "Open");
193 let html = fragment(&Node::act("Dismiss", action));
194
195 assert!(html.contains("hx-post=\"/problems/7/status?status=Open\""));
196 assert!(html.contains("hx-vals=\"{&quot;status&quot;:&quot;Dismissed&quot;}\""));
197
198 // A write is not a link, so it gets no href to middle-click into.
199 assert!(!html.contains("href="));
200 }
201
202 #[test]
203 fn a_repeated_name_survives_the_wire_rather_than_collapsing() {
204 // `Params::get_all` promises repeats, and an `hx-vals` object literal could
205 // not keep that promise: two entries under one name emitted a duplicate JSON
206 // key and every parser kept the last. A query string repeats a name happily,
207 // which is the other half of why the view travels on the address.
208 let action = Action::get("/tags")
209 .with("tag", "rust")
210 .with("tag", "router");
211 let html = fragment(&Node::act("Both", action));
212
213 assert!(html.contains("hx-get=\"/tags?tag=rust&amp;tag=router\""));
214 }
215
216 #[test]
217 fn a_param_value_cannot_escape_the_attribute_or_the_json() {
218 let action = Action::post("/search").with("q", "\" onload=\"steal()");
219 let html = fragment(&Node::act("Search", action));
220
221 assert!(!html.contains("onload=\"steal()"));
222 assert!(html.contains("\\&quot;"));
223
224 let action = Action::post("/search").with("q", "a\\b\nc");
225 let html = fragment(&Node::act("Search", action));
226 assert!(html.contains("a\\\\b\\nc"));
227 }
228
229 #[test]
230 fn no_control_ever_names_its_own_target() {
231 // Decision 7: the response says what it replaces, through HX-Retarget,
232 // because the router is the only party that knows what it just changed.
233 let screen = Screen::list_detail("Tasks", false)
234 .with(
235 Slot::new("list", RegionKind::Pane)
236 .with(Node::list([
237 Row::new("One").activate(Action::get("/tasks/1"))
238 ]))
239 .with(Node::act("New", Action::post("/tasks"))),
240 )
241 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")));
242
243 let html = render(&screen);
244 assert!(!html.contains("hx-target"));
245 assert!(!html.contains("hx-retarget"));
246 }
247
248 /// Code lines naming htmx, ignoring doc comments and ordinary comments.
249 ///
250 /// The comments are where the transport is *explained*, and there are more of
251 /// those than there are emissions. Counting them would make this test pass by
252 /// being talked at.
253 fn htmx_lines(source: &str) -> usize {
254 source
255 .lines()
256 .filter(|line| !line.trim_start().starts_with("//"))
257 .filter(|line| line.contains("hx-"))
258 .count()
259 }
260
261 /// One function's body: from its signature to the next item's doc comment.
262 fn body_of<'a>(source: &'a str, signature: &str) -> &'a str {
263 source
264 .split(signature)
265 .nth(1)
266 .unwrap_or_else(|| panic!("{signature} exists"))
267 .split("\n/// ")
268 .next()
269 .expect("the function has a body")
270 }
271
272 #[test]
273 fn htmx_enters_in_exactly_two_functions() {
274 // The check on decision 13's claim that the transport is replaceable. If
275 // this fails, `hx-` has leaked out of the transport seam and swapping htmx
276 // for fixi stopped being a bounded change.
277 //
278 // Two functions rather than one since 2026-08-12, when a response gained
279 // the slots it invalidates: `action_attrs` says where a control sends and
280 // where its answer lands, `oob_html` says where a piece of the answer
281 // lands that no control asked for. Both are the same fact and both move
282 // together, so the claim is unchanged and only the count is.
283 //
284 // It also stopped matching on `" hx-`. That pattern wanted a quote and a
285 // space before the attribute, which is what an attribute appended to an
286 // open tag looks like — and `oob_html` opens the tag itself, so the leak
287 // this test exists to catch would have gone straight past it.
288 let source = include_str!("node.rs");
289 let seam = ["pub(crate) fn action_attrs", "pub(crate) fn oob_html"]
290 .iter()
291 .map(|signature| htmx_lines(body_of(source, signature)))
292 .sum::<usize>();
293
294 assert_eq!(
295 htmx_lines(source),
296 seam,
297 "every emitted hx- attribute should come from action_attrs or oob_html"
298 );
299 assert!(seam >= 4, "verb, vals, swap and the out-of-band address");
300 }
301
302 #[test]
303 fn a_disabled_control_carries_no_address() {
304 let act = Act::new("Delete", Action::post("/tasks/1/delete")).disabled();
305 let html = fragment(&Node::Act(act));
306
307 assert!(html.contains("disabled"));
308 assert!(!html.contains("hx-post"));
309 }
310
311 #[test]
312 fn a_row_is_a_control_only_when_selecting_it_does_something() {
313 // Opening a row is a read of a route, so it is a link and has an address.
314 let live = fragment(&Node::list([Row::new("One").activate(Action::get("/1"))]));
315 assert!(live.contains("<a class=\"row-activate\""));
316 assert!(live.contains("href=\"/1\""));
317
318 let inert = fragment(&Node::list([Row::new("One")]));
319 assert!(!inert.contains("<button"));
320 assert!(!inert.contains("<a "));
321 assert!(inert.contains("<span class=\"row-primary\">One</span>"));
322 }
323
324 #[test]
325 fn the_current_row_says_so_to_a_screen_reader() {
326 // This field was called `selected` until 2026-08-08 and this test asserted
327 // both meanings at once, because there was only one field to assert. It is
328 // the app's own pointer: what the detail pane is showing.
329 let html = fragment(&Node::list([Row {
330 current: true,
331 ..Row::new("One")
332 }]));
333 assert!(html.contains("aria-current=\"true\""));
334 assert!(html.contains("row-current"));
335 // Not a tick. Nothing here is selectable, so no checkbox.
336 assert!(!html.contains("type=\"checkbox\""));
337 }
338
339 #[test]
340 fn a_selectable_row_gets_a_real_checkbox_and_an_unselectable_one_gets_nothing() {
341 // The other half of the split. `selected` is now the user's tick, and
342 // `Option` is what tells "not ticked" from "not tickable" -- the ambiguity
343 // that made goingson's bulk-selection checkbox undescribable.
344 let untickable = fragment(&Node::list([Row::new("One")]));
345 assert!(!untickable.contains("type=\"checkbox\""));
346
347 let unticked = fragment(&Node::list([Row::new("One").selectable(false)]));
348 assert!(unticked.contains("type=\"checkbox\""));
349 assert!(!unticked.contains(" checked"));
350 assert!(!unticked.contains("row-selected"));
351
352 let ticked = fragment(&Node::list([Row::new("One").selectable(true)]));
353 assert!(ticked.contains("type=\"checkbox\""));
354 assert!(ticked.contains(" checked"));
355 assert!(ticked.contains("row-selected"));
356 // A tick is not the app's pointer, so it claims no `aria-current`.
357 assert!(!ticked.contains("aria-current"));
358 }
359
360 #[test]
361 fn a_row_carries_its_tokens_as_tokens_rather_than_as_joined_text() {
362 // What makeover-layout 0.9.0's `RowPart::Tokens` was added for. Both
363 // goingson ports had to join two trailing facts into `meta` and lost what
364 // the second one was; here the tone survives to the markup.
365 let html = fragment(&Node::list([Row::new("Mine")
366 .meta("3 files")
367 .token(Tag::badge("Side Project"))
368 .token(Tag::badge("On Hold").tone(layout::Tone::Warning))]));
369
370 assert!(html.contains("class=\"row-tokens\""));
371 assert!(html.contains("On Hold"));
372 // The thing the join could not keep.
373 assert!(html.contains("warning"));
374 // And the plain fact stays a plain fact rather than becoming a badge.
375 assert!(html.contains("class=\"row-meta\">3 files</span>"));
376 }
377
378 #[test]
379 fn an_external_destination_is_an_anchor_and_never_a_route() {
380 // The contacts screen's social handles. A button that navigates away lies
381 // to middle-click and to a screen reader, so the element changes with the
382 // destination and not just the attributes.
383 let html = fragment(&Node::act(
384 "Profile",
385 Action::external("https://example.com/@ada"),
386 ));
387
388 assert!(html.contains("<a "));
389 assert!(html.contains("href=\"https://example.com/@ada\""));
390 assert!(html.contains("rel=\"noopener noreferrer\""));
391 // Nothing here is htmx's business: no route is called and nothing swaps.
392 assert!(!html.contains("hx-get"));
393 assert!(!html.contains("hx-post"));
394 assert!(!html.contains("<button"));
395 }
396
397 #[test]
398 fn an_external_url_cannot_break_out_of_its_own_attribute() {
399 let html = fragment(&Node::act(
400 "Profile",
401 Action::external("\"><script>alert(1)</script>"),
402 ));
403 assert!(!html.contains("<script>"));
404 }
405
406 #[test]
407 fn a_meter_renders_through_makeover_and_not_through_a_second_emitter() {
408 // The rule this crate is held to: the markup and the CSS that has to match
409 // it are both makeover-webview's. If this ever diverges, the trough and its
410 // fill stop agreeing about which classes exist.
411 let html = fragment(&Node::Meter(
412 Meter::new(3, 7)
413 .tone(layout::Tone::Success)
414 .label("subtasks"),
415 ));
416 assert_eq!(
417 html,
418 makeover_webview::meter::meter_html(
419 &layout::Meter::new(3, 7)
420 .tone(layout::Tone::Success)
421 .label("subtasks"),
422 &Emit::default(),
423 )
424 );
425 assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
426 }
427
428 #[test]
429 fn a_link_and_a_figure_in_a_row_cost_no_release() {
430 // The two gaps the enumeration left open on the row side. Each was a
431 // `RowPart` variant, a member on `Row`, a renderer arm and a
432 // makeover-layout release away, which is what put the third of the seven
433 // matrix pairings out of reach. Under the run they are leaves, and a leaf
434 // in a run is already sayable.
435 let html = fragment(&Node::list([Row::new("Invoice 41")
436 .part(
437 layout::RowPart::Meta,
438 Node::Link {
439 text: "makenot.work".into(),
440 action: Action::get("/sites/1"),
441 },
442 )
443 .part(
444 layout::RowPart::Tokens,
445 Node::Figure(Figure::new("41", "days")),
446 )]));
447
448 assert!(html.contains("makenot.work"), "{html}");
449 assert!(html.contains("/sites/1"), "{html}");
450 assert!(html.contains("41"), "{html}");
451 // Each takes the class of the role it was given, and the leaves inside keep
452 // makeover's own classes for what they are.
453 assert!(html.contains("class=\"row-meta\""), "{html}");
454 assert!(html.contains("class=\"row-tokens\""), "{html}");
455 }
456
457 #[test]
458 #[should_panic(expected = "a row is an inline run and holds leaves")]
459 fn a_row_refuses_a_block() {
460 // The bound, at the call site. A row is drawable on one wrapped line, and a
461 // list inside one is the door the 2026-08-08 rider was trying to shut with
462 // a doc comment. This is the same rule, checked.
463 let _ = Row::new("Ship it").part(
464 layout::RowPart::Meta,
465 Node::List {
466 rows: Vec::new(),
467 more: None,
468 },
469 );
470 }
471
472 #[test]
473 fn a_rows_parts_draw_in_the_order_the_description_says_them() {
474 // The members were drawn in a fixed sequence whatever order they were built
475 // in, so a row wanting a tag between two facts got the tag hoisted to the
476 // end. The run says the order itself.
477 let html = fragment(&Node::list([Row::new("Ship it")
478 .token(Tag::badge("beta"))
479 .meta("2 files")]));
480
481 let tokens = html.find("row-tokens").expect("a tokens strip");
482 let meta = html.find("row-meta").expect("a meta part");
483 assert!(tokens < meta, "{html}");
484 }
485
486 #[test]
487 fn consecutive_parts_of_one_role_share_a_strip() {
488 // Two badges are a strip and not two unrelated spans: the part class
489 // carries the gap between siblings, which is the same reason a cell's
490 // tokens group. Two badges with a fact between them are two strips, and
491 // that is the description saying so.
492 let html = fragment(&Node::list([Row::new("Ship it")
493 .token(Tag::badge("beta"))
494 .token(Tag::badge("draft"))]));
495
496 assert_eq!(html.matches("row-tokens").count(), 1, "{html}");
497 assert!(html.contains("beta"), "{html}");
498 assert!(html.contains("draft"), "{html}");
499 }
500
501 #[test]
502 fn a_proportion_in_a_row_is_a_bar_and_not_flattened_text() {
503 // `da5666ae`. `Meter` closed two of its seven sites at 0.10.0 and could not
504 // reach the five that sit in rows, because a row held no nodes. The row
505 // carried the description of a bar instead; under the run it carries the
506 // meter as the leaf it always was, and `Row::meter` still builds it.
507 let html = fragment(&Node::list([
508 Row::new("Ship it").meter(Meter::new(3, 7).label("subtasks"))
509 ]));
510
511 assert!(html.contains("class=\"row-proportion\""));
512 assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
513 // The markup is makeover-webview's, so the trough matches the CSS emitted
514 // for it. A second emitter here is what this crate does not do.
515 assert!(html.contains(&makeover_webview::meter::meter_html(
516 &layout::Meter::new(3, 7).label("subtasks"),
517 &Emit::default(),
518 )));
519 }
520
521 #[test]
522 fn a_strip_of_figures_is_one_node_because_a_renderer_cannot_infer_a_set() {
523 // `93c6a174`. The port had been making each of these out of a `Row` with
524 // the caption as `primary` and the figure as `meta`, which reads backwards.
525 let html = fragment(&Node::stats([
526 Figure::new("17", "Current Streak"),
527 Figure::new("84%", "Completion Rate").tone(layout::Tone::Success),
528 ]));
529
530 assert!(html.starts_with("<div class=\"figures\">"));
531 assert!(html.contains(r#"aria-label="Current Streak: 17""#));
532 assert!(html.contains(r#"data-tone="success""#));
533 // Nothing answers a click here, so nothing is a control.
534 assert!(!html.contains("<button"));
535 }
536
537 #[test]
538 fn a_figure_whose_value_is_a_control_becomes_one_without_a_second_emitter() {
539 // The consumer no candidate shape accounted for: sync's "Not Applied: 3"
540 // opens the list. makeover-layout cannot name an action, so the address
541 // rides beside the figure here rather than inside it.
542 let html = fragment(&Node::Stats {
543 figures: vec![(
544 Figure::new("3", "Not Applied").tone(layout::Tone::Warning),
545 Some(Action::get("/settings/held")),
546 )],
547 });
548
549 assert!(html.contains("<a class=\"figure-act\""));
550 assert!(html.contains("href=\"/settings/held\""));
551 assert!(html.contains("hx-get=\"/settings/held\""));
552 // And the figure inside it is the same markup an inert one would be.
553 assert!(html.contains(&makeover_webview::figure::figure_html(
554 &layout::Figure::new("3", "Not Applied").tone(layout::Tone::Warning),
555 &Emit::default(),
556 )));
557 }
558
559 #[test]
560 fn a_meter_that_ran_over_says_so_after_the_width_is_clamped() {
561 // The finding this member closed. "45m tracked / 30m est" was heading text
562 // because nothing named a bar; a bar alone would report it as exactly full.
563 let html = fragment(&Node::Meter(
564 Meter::new(45, 30)
565 .tone(layout::Tone::Danger)
566 .label("minutes"),
567 ));
568 assert!(html.contains("width: 100%"));
569 assert!(html.contains(r#"data-over="true""#));
570 assert!(html.contains(r#"aria-label="45 of 30 minutes""#));
571 }
572
573 #[test]
574 fn a_badge_is_not_a_button_and_a_chip_is() {
575 let badge = fragment(&Node::Token(Tag {
576 kind: layout::Token::Badge,
577 label: "3".into(),
578 tone: layout::Tone::Info,
579 latched: false,
580 action: Some(Action::get("/x")),
581 }));
582 // A badge answers no click however it is dressed, so it emits no transport
583 // even when a description hands it an action.
584 assert!(!badge.contains("<button"));
585 assert!(!badge.contains("<a "));
586 assert!(!badge.contains("hx-get"));
587 assert!(!badge.contains("href"));
588
589 let chip = fragment(&Node::Token(Tag {
590 kind: layout::Token::Chip { removable: false },
591 label: "open".into(),
592 tone: layout::Tone::Neutral,
593 latched: true,
594 action: Some(Action::get("/x")),
595 }));
596 // A read of a route, so the chip is a link and says its state the way a
597 // link says it. aria-pressed is a button's word and means nothing here.
598 assert!(chip.contains("<a "));
599 assert!(chip.contains("aria-current=\"true\""));
600 assert!(!chip.contains("aria-pressed"));
601 assert!(chip.contains("href=\"/x\""));
602 assert!(chip.contains("hx-get=\"/x\""));
603
604 // A chip whose activation writes is still a button, and still says so.
605 let toggle = fragment(&Node::Token(Tag {
606 kind: layout::Token::Chip { removable: false },
607 label: "open".into(),
608 tone: layout::Tone::Neutral,
609 latched: true,
610 action: Some(Action::post("/x/toggle")),
611 }));
612 assert!(toggle.contains("<button"));
613 assert!(toggle.contains("aria-pressed=\"true\""));
614 assert!(!toggle.contains("href"));
615 }
616
617 #[test]
618 fn a_select_sends_its_value_under_the_one_agreed_name() {
619 let html = fragment(&Node::Select {
620 kind: layout::Selector::Tabs,
621 options: vec![
622 (
623 Choice {
624 value: "open".into(),
625 label: "Open".into(),
626 },
627 None,
628 ),
629 (
630 Choice {
631 value: "done".into(),
632 label: "Done".into(),
633 },
634 None,
635 ),
636 ],
637 chosen: Some("open".into()),
638 action: Some(Action::get("/tasks")),
639 });
640
641 assert!(html.contains("role=\"tablist\""));
642 assert!(html.contains("aria-selected=\"true\""));
643 assert!(html.contains("aria-selected=\"false\""));
644 // The action is a read, so the picked value is part of where the control
645 // goes rather than something it sends. That also makes the chosen tab a
646 // place: the address names it, so it is linkable and survives a reload.
647 assert!(html.contains(&format!("/tasks?{}=open", Node::SELECTED)));
648 assert!(html.contains(&format!("/tasks?{}=done", Node::SELECTED)));
649 }
650
651 #[test]
652 fn a_pending_region_says_it_is_waiting() {
653 let screen =
654 Screen::list_detail("Tasks", false).with(Slot::new("detail", RegionKind::Pane).pending());
655 assert!(render(&screen).contains("aria-busy=\"true\""));
656 }
657
658 #[test]
659 fn a_bespoke_region_is_a_place_and_nothing_else() {
660 // Decision 4: the renderer hands the space over under the name the app
661 // chose and never interprets it.
662 let screen = Screen::list_detail("Tasks", false).with(Slot::bespoke("player", "media-player"));
663 let html = render(&screen);
664
665 assert!(html.contains("id=\"player\""));
666 assert!(html.contains("data-bespoke=\"media-player\""));
667 // Empty. Whatever fills it is the app's, per host.
668 assert!(html.contains("data-bespoke=\"media-player\"></div>"));
669 }
670
671 #[test]
672 fn a_bespoke_region_the_host_filled_carries_its_markup() {
673 // The server case: there is no client moment, so the fill has to be in the
674 // bytes the browser gets or it is absent from first paint, absent with JS
675 // off and absent to a crawler.
676 let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file"));
677 let html = Webview::new()
678 .with_fill("file", "<pre class=\"hl\">fn main() {}</pre>")
679 .screen(&screen);
680
681 assert!(
682 html.contains("<pre class=\"hl\">fn main() {}</pre></div>"),
683 "{html}"
684 );
685 }
686
687 #[test]
688 fn a_bespoke_region_with_no_fill_is_still_empty() {
689 // What every client host relies on: the div is a place, and a host that
690 // fills one region has not changed what the others are.
691 let screen = Screen::list_detail("Files", false)
692 .with(Slot::bespoke("file", "git-file"))
693 .with(Slot::bespoke("player", "media-player"));
694 let html = Webview::new()
695 .with_fill("file", "<pre></pre>")
696 .screen(&screen);
697
698 assert!(
699 html.contains("data-bespoke=\"media-player\"></div>"),
700 "{html}"
701 );
702 }
703
704 #[test]
705 fn two_regions_sharing_a_name_are_filled_by_id() {
706 // The re-entrancy case, and the reason fills are keyed by slot id: a page
707 // of N rows each carrying one shares a single bespoke name.
708 let screen = Screen::list_detail("Files", false)
709 .with(Slot::bespoke("row-1", "diff"))
710 .with(Slot::bespoke("row-2", "diff"));
711 let html = Webview::new()
712 .with_fill("row-1", "<i>one</i>")
713 .with_fill("row-2", "<i>two</i>")
714 .screen(&screen);
715
716 assert!(html.contains("id=\"row-1\""), "{html}");
717 let one = html.find("<i>one</i>").expect("the first row is filled");
718 let two = html.find("<i>two</i>").expect("the second row is filled");
719 assert!(one < two);
720 assert!(html.find("id=\"row-2\"").expect("the second row exists") < two);
721 }
722
723 #[test]
724 fn a_fill_is_markup_and_is_not_escaped() {
725 // The one string in this renderer that is not escaped, and the reason it
726 // is safe: it comes from host code, never from a description. A
727 // description still cannot produce markup, which is what `Node::Html` was
728 // refused to protect.
729 let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file"));
730 let html = Webview::new()
731 .with_fill("file", "<span data-x=\"1\">&amp;</span>")
732 .screen(&screen);
733
734 assert!(html.contains("<span data-x=\"1\">&amp;</span>"), "{html}");
735 assert!(!html.contains("&lt;span"), "{html}");
736 }
737
738 #[test]
739 fn a_fill_for_a_region_the_screen_lacks_goes_nowhere() {
740 // Ignored rather than appended somewhere. A renderer built for one screen
741 // and handed another emits that other screen unchanged.
742 let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file"));
743 let plain = render(&screen);
744 let html = Webview::new()
745 .with_fill("absent", "<b>stray</b>")
746 .screen(&screen);
747
748 assert!(!html.contains("stray"), "{html}");
749 assert_eq!(html, plain);
750 }
751
752 #[test]
753 fn a_fill_is_only_for_a_bespoke_region() {
754 // A pane's contents are the description's. A host reaching into one is
755 // reaching past the vocabulary rather than into the space it was given.
756 let screen = Screen::list_detail("Files", false).with(Slot::new("detail", RegionKind::Pane));
757 let html = Webview::new()
758 .with_fill("detail", "<b>stray</b>")
759 .screen(&screen);
760
761 assert!(!html.contains("stray"), "{html}");
762 }
763
764 #[test]
765 fn a_slot_id_survives_intact_because_a_fragment_is_aimed_at_it() {
766 let screen = Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar));
767 assert!(render(&screen).contains("id=\"feed-list\""));
768 }
769
770 #[test]
771 fn a_modal_says_it_takes_input_until_dismissed() {
772 let screen = Screen::list_detail("Tasks", false).with(Slot::new("confirm", RegionKind::Modal));
773 let html = render(&screen);
774 assert!(html.contains("role=\"dialog\""));
775 assert!(html.contains("aria-modal=\"true\""));
776 }
777
778 #[test]
779 fn a_table_addresses_its_cells_by_column_never_by_position() {
780 let columns = vec![
781 Column::new("Name").width(layout::Width::Fill),
782 Column::new("Size").width(layout::Width::Content),
783 ];
784 let html = fragment(&Node::Table {
785 columns,
786 rows: vec![Cells::new(["kick.wav", "2.1 MB"])],
787 });
788
789 assert!(html.contains("role=\"table\""));
790 assert!(html.contains("role=\"columnheader\""));
791 assert!(html.contains("kick.wav"));
792
793 // The tracks are the stylesheet's, emitted by `narrowing_css` alongside the
794 // hiding rules. Inline tracks here would be the widest layout outranking
795 // the narrow ones from inside the markup.
796 assert!(!html.contains("grid-template-columns"));
797 assert!(!html.contains("style="));
798 }
799
800 #[test]
801 fn a_table_row_says_current_the_same_way_a_list_row_does() {
802 // The 2026-08-08 rename applied where it was missed. `Cells` kept
803 // `selected` while meaning the app's pointer, so the class said one thing
804 // and the `aria-current` beside it said the other. A table row and a list
805 // row are the same fact in two arrangements and a stylesheet should not
806 // have to know which it is reading.
807 let html = fragment(&Node::Table {
808 columns: vec![Column::new("Name").width(layout::Width::Fill)],
809 rows: vec![Cells {
810 current: true,
811 ..Cells::new(["kick.wav"])
812 }],
813 });
814 assert!(html.contains("aria-current=\"true\""));
815 assert!(html.contains("table-row-current"));
816 assert!(!html.contains("table-row-selected"));
817
818 // And no tick, because a table has none to describe.
819 assert!(!html.contains("type=\"checkbox\""));
820 }
821
822 #[test]
823 fn a_cell_value_is_text_and_cannot_become_markup() {
824 let html = fragment(&Node::Table {
825 columns: vec![Column::new("Name").width(layout::Width::Fill)],
826 rows: vec![Cells::new(["<img src=x onerror=alert(1)>"])],
827 });
828 assert!(!html.contains("<img"));
829 assert!(html.contains("&lt;img"));
830 }
831
832 #[test]
833 fn a_table_row_carries_its_controls_in_the_cell_they_belong_to() {
834 // `022f0c59`. The SSH-keys table: three values and a Remove, which had to be
835 // described as a list until a cell could hold the button, losing the column
836 // headers that were the reason it was a table.
837 let html = fragment(&Node::Table {
838 columns: vec![
839 Column::new("Fingerprint").width(layout::Width::Fill),
840 Column::new("Label").width(layout::Width::Content),
841 Column::new("").width(layout::Width::Content),
842 ],
843 rows: vec![Cells::new([
844 Cell::new("SHA256:abc"),
845 Cell::new("fw13"),
846 Cell::acts([
847 Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger)
848 ]),
849 ])],
850 });
851
852 assert!(html.contains("role=\"table\""));
853 assert!(html.contains("SHA256:abc"));
854 assert!(html.contains("hx-post=\"/keys/7/delete\""));
855 assert!(html.contains("cell-actions"));
856
857 // Not `row-actions`, which is a list row's class. A table's actions column
858 // exists to show the button, and `cell-actions` is the class the generated
859 // stylesheet gives no colour, so the button is not painted as text.
860 assert!(!html.contains("row-actions"));
861
862 // A cell that is only its value says so on the container, where a wrapper
863 // span would add an element and no information.
864 assert!(html.contains("cell-value"), "{html}");
865 assert!(!html.contains("<span class=\"cell-value\""), "{html}");
866 }
867
868 #[test]
869 fn a_cell_that_mixes_parts_names_them_inside_rather_than_on_itself() {
870 // makeover-layout 0.14.0's CellPart, and the reason it is four members
871 // rather than a flag. A cell holding text AND tokens AND a control is three
872 // parts in one container: a content colour on the container would reach the
873 // badge and the button, which is the drift the vocabulary ends.
874 let html = fragment(&Node::Table {
875 columns: vec![Column::new("Item").width(layout::Width::Fill)],
876 rows: vec![Cells::new([Cell::new("Release notes")
877 .token(Tag::badge("Published"))
878 .act(Act::new("Remove", Action::post("/blog/7/delete")))])],
879 });
880
881 // Each part named where it is, inside the cell.
882 assert!(html.contains("<span class=\"cell-value\">"), "{html}");
883 assert!(html.contains("cell-tokens"), "{html}");
884 assert!(html.contains("cell-actions"), "{html}");
885
886 // And the container claims none of them, or the colour would cascade.
887 assert!(!html.contains("cell-keeps cell-value"), "{html}");
888 }
889
890 #[test]
891 fn a_figure_carries_its_delta_through_to_the_strip() {
892 // makeover-layout 0.13.0. Four of MNW's screens put a label, a value and a
893 // delta in one stat card, and the delta is the toned part. A description
894 // that dropped it left the tone with nothing to colour.
895 let html = fragment(&Node::Stats {
896 figures: vec![(
897 Figure::new("1,204", "Views")
898 .change("+12.5%")
899 .tone(layout::Tone::Success),
900 None,
901 )],
902 });
903
904 assert!(html.contains("+12.5%"), "{html}");
905 assert!(html.contains("figure-change"), "{html}");
906 assert!(html.contains("data-tone=\"success\""), "{html}");
907 // The accessible name carries it too, since every span in a figure is
908 // `aria-hidden` and a delta outside the name would reach a reader not at all.
909 assert!(html.contains("Views: 1,204, +12.5%"), "{html}");
910
911 // A figure with nothing to compare against emits no empty delta.
912 let plain = fragment(&Node::Stats {
913 figures: vec![(Figure::new("3.1%", "Conversion"), None)],
914 });
915 assert!(!plain.contains("figure-change"), "{plain}");
916 }
917
918 #[test]
919 fn a_control_calling_an_undescribed_route_can_say_where_the_answer_goes() {
920 // Decision 7 holds for a described responder and cannot for any other. A
921 // plain API route answers with a status or a hand-rendered fragment and
922 // names no region, so without this htmx swaps that answer into the button
923 // that was pressed. Which is what the MNW server's described Remove button
924 // did: the delete endpoint answers with the whole re-rendered list, and it
925 // landed inside the control.
926 let html = fragment(&Node::list([Row::new("fw13").act(Act::new(
927 "Remove",
928 Action::delete("/api/users/me/ssh-keys/7").replacing("ssh-keys-list"),
929 ))]));
930
931 assert!(html.contains("hx-target=\"#ssh-keys-list\""), "{html}");
932 assert!(
933 html.contains("hx-delete=\"/api/users/me/ssh-keys/7\""),
934 "{html}"
935 );
936 }
937
938 #[test]
939 fn nothing_else_ever_emits_a_target() {
940 // The rule the carve-out is carved out of. A control calling a described
941 // route says nothing about where its answer lands, because the answer says
942 // so itself.
943 let html = fragment(&Node::list([Row::new("fw13")
944 .act(Act::new("Open", Action::get("/keys/7")))
945 .activate(Action::get("/keys/7"))]));
946 assert!(!html.contains("hx-target"), "{html}");
947
948 let form = fragment(&Node::Form {
949 action: Action::post("/keys"),
950 submit: "Add".into(),
951 fields: vec![Field::new(layout::FieldKind::Text, "label", "Label")],
952 });
953 assert!(!form.contains("hx-target"), "{form}");
954 }
955
956 #[test]
957 fn a_region_id_cannot_break_out_of_the_target_attribute() {
958 let html = fragment(&Node::act(
959 "Remove",
960 Action::delete("/api/x").replacing("a\" onload=\"x()"),
961 ));
962 assert!(!html.contains("\" onload="), "{html}");
963 }
964
965 #[test]
966 fn a_control_whose_answer_is_a_file_says_so() {
967 // Nine sites in MNW: five CSV exports across four templates, a sixth in the
968 // item-sales script, three anchors carrying `download`. Six are writes,
969 // which is why this is a property of the action and not a kind of link.
970 //
971 // A read is the whole job: the browser saves it and the control still works
972 // with JS off.
973 let read = fragment(&Node::act(
974 "Download LICENSE.txt",
975 Action::get("/api/items/7/license.txt").saving("LICENSE.txt"),
976 ));
977 assert!(read.contains("download=\"LICENSE.txt\""), "{read}");
978 assert!(
979 read.contains("<a "),
980 "a read that saves is still a link: {read}"
981 );
982
983 // A write cannot be a link, so the intent is a named hook the host acts on.
984 let write = fragment(&Node::act(
985 "Export CSV",
986 Action::post("/api/export/contacts").saving("contacts.csv"),
987 ));
988 assert!(write.contains("data-saves=\"contacts.csv\""), "{write}");
989 assert!(
990 write.contains("hx-post=\"/api/export/contacts\""),
991 "{write}"
992 );
993 assert!(
994 !write.contains("download="),
995 "a button is not a link: {write}"
996 );
997 }
998
999 #[test]
1000 fn a_control_that_saves_nothing_says_nothing() {
1001 let plain = fragment(&Node::act("Export", Action::post("/api/export/contacts")));
1002 assert!(!plain.contains("data-saves"), "{plain}");
1003 assert!(!plain.contains("download="), "{plain}");
1004 }
1005
1006 #[test]
1007 fn a_filename_cannot_break_out_of_its_attribute() {
1008 // A filename is chosen by the screen today, but it is a string in an
1009 // attribute and the escaping is not optional for that reason.
1010 let html = fragment(&Node::act(
1011 "Export",
1012 Action::post("/api/export").saving("\" onload=\"x()"),
1013 ));
1014 assert!(!html.contains("\" onload="), "{html}");
1015 }
1016
1017 #[test]
1018 fn a_cell_value_that_is_a_link_is_the_link() {
1019 // 35 cells across 18 of MNW's templates are a title that goes somewhere.
1020 // The value carries the address itself rather than growing an `Edit` button
1021 // beside it, which is what a title column looks like.
1022 let html = fragment(&Node::Table {
1023 columns: vec![
1024 Column::new("Title").width(layout::Width::Fill),
1025 Column::new("Status").width(layout::Width::Content),
1026 ],
1027 rows: vec![Cells::new([
1028 Cell::new("Release notes").activate(Action::get("/blog/7")),
1029 Cell::tag(Tag::badge("Published")),
1030 ])],
1031 });
1032
1033 // A read is an anchor with a real href, so middle-click and copy-link work
1034 // and the page is still navigable with JS off.
1035 assert!(html.contains("href=\"/blog/7\""), "{html}");
1036 // `cell-link`, which is what makeover-layout 0.14.0's `CellPart::Link` is
1037 // spelled as in the generated stylesheet. It was `cell-activate` while this
1038 // crate was inventing the name itself.
1039 assert!(html.contains("cell-link"), "{html}");
1040 assert!(html.contains(">Release notes</a>"), "{html}");
1041
1042 // The value is still text, whatever the value happens to say.
1043 let hostile = fragment(&Node::Table {
1044 columns: vec![Column::new("Title").width(layout::Width::Fill)],
1045 rows: vec![Cells::new([
1046 Cell::new("<img src=x onerror=alert(1)>").activate(Action::get("/blog/7"))
1047 ])],
1048 });
1049 assert!(!hostile.contains("<img"), "{hostile}");
1050 }
1051
1052 #[test]
1053 fn a_linked_value_does_not_also_open_the_row() {
1054 // The same double-fire `Cell::acts` has, through a different member: a click
1055 // on the title would follow the link and swap the row's destination in
1056 // underneath it. The filter keys on `data-act`, so the link carries it.
1057 let html = fragment(&Node::Table {
1058 columns: vec![Column::new("Title").width(layout::Width::Fill)],
1059 rows: vec![
1060 Cells::new([Cell::new("Release notes").activate(Action::get("/blog/7"))])
1061 .activate(Action::get("/blog/7/edit")),
1062 ],
1063 });
1064
1065 assert!(
1066 html.contains("closest("),
1067 "the row filters the link out: {html}"
1068 );
1069 assert!(html.contains("hx-get=\"/blog/7/edit\""), "{html}");
1070 assert!(html.contains("hx-get=\"/blog/7\""), "{html}");
1071 }
1072
1073 #[test]
1074 fn a_control_beside_a_value_does_not_also_open_the_row() {
1075 // The five of MNW's thirty action-bearing rows that put a control next to a
1076 // value rather than alone in the last cell: a position with reorder arrows,
1077 // a slug with "Set slug", a use count that is itself the button. The row is
1078 // openable too, and a click on the button bubbles to it.
1079 let html = fragment(&Node::Table {
1080 columns: vec![Column::new("Slug").width(layout::Width::Fill)],
1081 rows: vec![
1082 Cells::new([
1083 Cell::new("my-app").act(Act::new("Set slug", Action::post("/apps/3/slug")))
1084 ])
1085 .activate(Action::get("/apps/3")),
1086 ],
1087 });
1088
1089 assert!(html.contains("hx-get=\"/apps/3\""));
1090 assert!(html.contains("hx-post=\"/apps/3/slug\""));
1091 assert!(html.contains("data-act"));
1092 assert!(html.contains("closest("));
1093
1094 // A row with no controls in it keeps htmx's bare default, so the filter is
1095 // paid for only where it is needed.
1096 let plain = fragment(&Node::Table {
1097 columns: vec![Column::new("Slug").width(layout::Width::Fill)],
1098 rows: vec![Cells::new(["my-app"]).activate(Action::get("/apps/3"))],
1099 });
1100 assert!(!plain.contains("hx-trigger"));
1101 }
1102
1103 #[test]
1104 fn the_ssh_keys_tab_gaps_stay_shut() {
1105 // The third gap the SSH-keys tab found. A described screen was emitting
1106 // `act`, `tone-danger` and `chip-latched`, none of which makeover has ever
1107 // defined, so a described control rendered as unstyled text next to a
1108 // hand-written one that did not.
1109 let html = fragment(&Node::list([Row::new("fw13").act(
1110 Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger),
1111 )]));
1112
1113 assert!(html.contains("class=\"button\""), "{html}");
1114 assert!(html.contains("data-tone=\"danger\""), "{html}");
1115 assert!(!html.contains("\"act\""), "{html}");
1116 assert!(!html.contains("tone-danger"), "{html}");
1117
1118 let chip = fragment(&Node::Token(
1119 Tag::chip("Open", Action::get("/tasks?open=1")).latched(true),
1120 ));
1121 assert!(chip.contains("latched"), "{chip}");
1122 assert!(!chip.contains("chip-latched"), "{chip}");
1123 }
1124
1125 #[test]
1126 fn a_table_heading_drops_with_the_cells_below_it() {
1127 // The header is emitted here rather than by `cells_html`, so it is the one
1128 // place the two class lists could disagree. If they do, a narrow viewport
1129 // drops a column's cells and keeps its heading, and every heading past the
1130 // cut sits over the wrong values.
1131 let html = fragment(&Node::Table {
1132 columns: vec![
1133 Column::new("Name")
1134 .width(layout::Width::Fill)
1135 .priority(layout::Priority::Essential),
1136 Column::new("Used")
1137 .width(layout::Width::Content)
1138 .priority(layout::Priority::Optional),
1139 ],
1140 rows: vec![Cells::new(["deploy", "Aug 9"])],
1141 });
1142
1143 for classes in [
1144 "col-Name cell-fill cell-keeps",
1145 "col-Used cell-content cell-drops-first",
1146 ] {
1147 assert_eq!(
1148 html.matches(classes).count(),
1149 2,
1150 "the heading and its cell must carry the same classes: {html}"
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn a_status_column_keeps_its_tone_instead_of_flattening_to_text() {
1157 // 33 table cells across 22 of MNW's templates carry a badge or a chip, more
1158 // sites than the acts earned. A status column is the common one, and it is
1159 // why this cannot be folded into the cell's text: `Refunded` and `Paid` are
1160 // the same string to a renderer and different tones to a reader.
1161 let html = fragment(&Node::Table {
1162 columns: vec![
1163 Column::new("Amount").width(layout::Width::Content),
1164 Column::new("Status").width(layout::Width::Content),
1165 ],
1166 rows: vec![Cells::new([
1167 Cell::new("$12.00"),
1168 Cell::tag(Tag::badge("Refunded").tone(layout::Tone::Warning)),
1169 ])],
1170 });
1171
1172 assert!(html.contains("cell-tokens"), "{html}");
1173 assert!(
1174 html.contains("class=\"badge\" data-tone=\"warning\""),
1175 "{html}"
1176 );
1177 assert!(html.contains("Refunded"), "{html}");
1178 }
1179
1180 #[test]
1181 fn a_meter_and_a_figure_in_a_cell_were_the_two_gaps_the_enumeration_left_open() {
1182 // The whole argument for the containment model in one test. Under the
1183 // per-pairing enumeration these were the two cells of the matrix nobody had
1184 // filled, and each would have cost a member on `Cell`, three renderer arms
1185 // and a release. Under the run they arrive by already being leaves, and the
1186 // renderer draws them through the emitters it already had.
1187 let html = fragment(&Node::Table {
1188 columns: vec![
1189 Column::new("Task").width(layout::Width::Fill),
1190 Column::new("Done").width(layout::Width::Content),
1191 Column::new("Revenue").width(layout::Width::Content),
1192 ],
1193 rows: vec![Cells::new([
1194 Cell::new("Write the renderer"),
1195 Cell::new("").part(Node::Meter(Meter::new(3, 7))),
1196 Cell::new("").part(Node::Figure(Figure::new("$12.00", "this month"))),
1197 ])],
1198 });
1199
1200 assert!(html.contains("progress"), "the meter is drawn: {html}");
1201 assert!(html.contains("figure"), "the figure is drawn: {html}");
1202 assert!(html.contains("$12.00"), "{html}");
1203 }
1204
1205 #[test]
1206 fn a_run_says_its_own_order_rather_than_hoisting_the_tags_to_the_end() {
1207 // What a part per cell could not express. The old renderer emitted the
1208 // value, then every token, then every act, whatever order the description
1209 // put them in, because the cell had one member per kind and members have no
1210 // order between them.
1211 let html = fragment(&Node::Table {
1212 columns: vec![Column::new("Note").width(layout::Width::Fill)],
1213 rows: vec![Cells::new([Cell::new("shipped")
1214 .token(Tag::badge("beta"))
1215 .part(Node::text("to staging"))])],
1216 });
1217
1218 let badge = html.find("beta").expect("the tag is drawn");
1219 let before = html.find("shipped").expect("the first text");
1220 let after = html.find("to staging").expect("the second text");
1221 assert!(before < badge && badge < after, "{html}");
1222 }
1223
1224 #[test]
1225 #[should_panic(expected = "a cell is an inline run and holds leaves")]
1226 fn a_cell_refuses_a_block() {
1227 // The bound, at a call site. A list in a cell is the thing the 2026-08-08
1228 // rider was defending against and never actually checked; here it is a
1229 // panic with the offending node in the message.
1230 let _ = Cell::new("x").part(Node::List {
1231 rows: Vec::new(),
1232 more: None,
1233 });
1234 }
1235
1236 #[test]
1237 fn a_chip_in_a_cell_is_a_control_and_a_badge_is_not() {
1238 // The bubbling guard has to cover both kinds of control a cell can hold,
1239 // or a chip that answers a click opens the row as well as answering it.
1240 let chip = fragment(&Node::Table {
1241 columns: vec![Column::new("Tag").width(layout::Width::Content)],
1242 rows: vec![
1243 Cells::new([Cell::tag(Tag::chip("Open", Action::get("/tasks?open=1")))])
1244 .activate(Action::get("/tasks/1")),
1245 ],
1246 });
1247 assert!(chip.contains("data-act"), "{chip}");
1248 assert!(chip.contains("closest("), "{chip}");
1249
1250 // A badge answers nothing, so it is not a control and the row keeps htmx's
1251 // bare default.
1252 let badge = fragment(&Node::Table {
1253 columns: vec![Column::new("Status").width(layout::Width::Content)],
1254 rows: vec![Cells::new([Cell::tag(Tag::badge("Paid"))]).activate(Action::get("/sales/1"))],
1255 });
1256 assert!(!badge.contains("data-act"), "{badge}");
1257 assert!(!badge.contains("hx-trigger"), "{badge}");
1258 }
1259
1260 #[test]
1261 fn a_cell_of_plain_text_is_still_a_string() {
1262 // The `From<&str>` that keeps every value-only table unchanged. Without it
1263 // the member would have cost every existing caller a rewrite for a feature
1264 // it does not use.
1265 let cells = Cells::new(["kick.wav", "2.1 MB"]);
1266 assert_eq!(
1267 cells.values,
1268 vec![Cell::new("kick.wav"), Cell::new("2.1 MB")]
1269 );
1270 // Each is one piece of text and nothing else, which is what the container
1271 // reads to decide the cell needs no wrapper span.
1272 assert!(
1273 cells
1274 .values
1275 .iter()
1276 .all(|cell| matches!(cell.parts.as_slice(), [Node::Text { .. }]))
1277 );
1278 }
1279
1280 #[test]
1281 fn a_form_borrows_its_fields_rather_than_emitting_them_twice() {
1282 let html = fragment(&Node::Form {
1283 action: Action::post("/tasks"),
1284 submit: "Save".into(),
1285 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title").required()],
1286 });
1287
1288 assert!(html.contains("hx-post=\"/tasks\""));
1289 assert!(html.contains("name=\"title\""));
1290 assert!(html.contains("required"));
1291 assert!(html.contains("<button type=\"submit\""));
1292 // The anatomy is makeover-webview's, so the label association it emits is
1293 // the one every app already gets.
1294 assert!(html.contains("<label"));
1295 }
1296
1297 #[test]
1298 fn a_control_that_writes_on_change_says_so_without_a_form_around_it() {
1299 // `14612ed8`. goingson reached 13 of these through `dispatch.js`, which
1300 // exists because the description could not say this.
1301 let html = fragment(&Node::field(
1302 Field::select(
1303 "theme",
1304 "Theme",
1305 vec![Choice::new("dark", "Dark"), Choice::new("light", "Light")],
1306 )
1307 .changes(Action::post("/settings/theme")),
1308 ));
1309
1310 assert!(html.contains("hx-post=\"/settings/theme\""));
1311 assert!(html.contains("hx-trigger=\"change\""));
1312 // The value is found back rather than assumed, because the control could
1313 // have been any of three elements.
1314 assert!(html.contains("hx-include="));
1315 assert!(html.contains("name=\"theme\""));
1316 // No form and no submit: that is the whole difference from `Node::Form`.
1317 assert!(!html.contains("<form"));
1318 assert!(!html.contains("type=\"submit\""));
1319 }
1320
1321 #[test]
1322 fn a_field_with_no_route_is_the_plain_group_it_always_was() {
1323 let html = fragment(&Node::field(Field::new(
1324 layout::FieldKind::Text,
1325 "title",
1326 "Title",
1327 )));
1328 assert!(html.contains("name=\"title\""));
1329 assert!(!html.contains("hx-"));
1330 }
1331
1332 #[test]
1333 fn a_tick_that_is_the_write_calls_a_route_and_a_tick_that_is_not_does_not() {
1334 // The two cases `Row::selected` could not tell apart. A bulk checkbox is
1335 // client state feeding a later action; a checklist item is the write.
1336 let checklist = fragment(&Node::list(vec![
1337 Row::new("Buy milk").toggling(false, Action::post("/subtasks/1/toggle")),
1338 ]));
1339 assert!(checklist.contains("type=\"checkbox\""));
1340 assert!(checklist.contains("hx-post=\"/subtasks/1/toggle\""));
1341 assert!(checklist.contains("hx-trigger=\"change\""));
1342
1343 let mut bulk = Row::new("Ada Lovelace");
1344 bulk.selected = Some(false);
1345 let bulk = fragment(&Node::list(vec![bulk]));
1346 assert!(bulk.contains("type=\"checkbox\""));
1347 assert!(!bulk.contains("hx-"));
1348 }
1349
1350 #[test]
1351 fn rich_text_is_rendered_from_source_and_still_cannot_smuggle_markup() {
1352 // `25822137`. The member is only defensible because what it carries is
1353 // source: the renderer decides what becomes markup, so a script tag in a
1354 // description is no more dangerous here than in `Node::Text`.
1355 let html = fragment(&Node::rich("A **bold** claim\n\n<script>alert(1)</script>"));
1356
1357 assert!(html.contains("<strong>bold</strong>"));
1358 assert!(!html.contains("<script"));
1359 // The node names itself, so a stylesheet has something to hang typography
1360 // on without the app wrapping it in a region of its own.
1361 assert!(html.contains("class=\"rich\""));
1362 }
1363
1364 #[test]
1365 fn a_refused_form_comes_back_with_what_was_typed_in_it() {
1366 // `1c4a66a4`. The value the user lost is the whole point of the finding, so
1367 // the assertion is that it is in the markup rather than that a field exists.
1368 let params = quasi_router::Params::new()
1369 .with("title", "a name with <angles> in it")
1370 .with("done", "on");
1371 let html = fragment(&Node::Form {
1372 action: Action::post("/tasks"),
1373 submit: "Save".into(),
1374 fields: vec![
1375 Field::new(layout::FieldKind::Text, "title", "Title").refilled(&params),
1376 Field::new(layout::FieldKind::Checkbox, "done", "Done").refilled(&params),
1377 // Nothing was submitted under this name, so it stays empty rather
1378 // than coming back as the empty string.
1379 Field::new(layout::FieldKind::Text, "notes", "Notes").refilled(&params),
1380 ],
1381 });
1382
1383 assert!(html.contains("value=\"a name with &lt;angles&gt; in it\""));
1384 assert!(html.contains("checked"));
1385 // The escaping guarantee is not weakened by carrying a value.
1386 assert!(!html.contains("<angles>"));
1387 // `notes` had nothing submitted under it, so it comes back empty rather
1388 // than carrying a neighbour's value.
1389 assert!(html.contains("name=\"notes\" value=\"\""));
1390 }
1391
1392 #[test]
1393 fn a_secret_is_never_offered_back_however_it_was_set() {
1394 // Two halves of one guarantee. The builder refuses to store it, and the
1395 // renderer refuses to emit it, because `Field::value` is a public field and
1396 // a struct literal reaches past the builder.
1397 let params = quasi_router::Params::new().with("password", "hunter2");
1398
1399 let refused = Field::new(layout::FieldKind::Secret, "password", "Password").refilled(&params);
1400 assert_eq!(refused.value, None);
1401
1402 let mut forced = Field::new(layout::FieldKind::Secret, "password", "Password");
1403 forced.value = Some("hunter2".to_owned());
1404 let html = fragment(&Node::Form {
1405 action: Action::post("/login"),
1406 submit: "Sign in".into(),
1407 fields: vec![forced],
1408 });
1409 assert!(!html.contains("hunter2"));
1410 }
1411
1412 #[test]
1413 fn an_empty_region_says_so_instead_of_rendering_an_empty_box() {
1414 // `703f4cd2`. A region shows its content or a stand-in, never both, which
1415 // is `Readiness` being one axis with four values rather than two.
1416 let empty = render(
1417 &Screen::list_detail("Projects", false).with(
1418 Slot::new("list", RegionKind::Pane)
1419 .with(Node::section("Projects"))
1420 .with(Node::empty("No projects yet")),
1421 ),
1422 );
1423
1424 assert!(empty.contains("No projects yet"));
1425 assert!(empty.contains(r#"data-state="empty""#));
1426 // The heading survives, which is why this is a node and not a state on the
1427 // region: a column with a heading and no rows still has a heading.
1428 assert!(empty.contains(">Projects<"));
1429 // Not announced as a fault: an empty list is the normal state of a new
1430 // install.
1431 assert!(!empty.contains(r#"role="alert""#));
1432 }
1433
1434 #[test]
1435 fn a_failed_region_is_a_different_state_from_an_empty_one_and_offers_a_way_out() {
1436 let failed = render(
1437 &Screen::list_detail("Events", false).with(
1438 Slot::new("list", RegionKind::Pane).with(
1439 Node::failed("Failed to load events")
1440 .offering(Act::new("Try again", Action::get("/events"))),
1441 ),
1442 ),
1443 );
1444
1445 assert!(failed.contains(r#"data-state="failed""#));
1446 assert!(failed.contains(r#"data-tone="danger""#));
1447 assert!(failed.contains(r#"role="alert""#));
1448 // The way out is a real control, so it reaches a handler.
1449 assert!(failed.contains("hx-get=\"/events\""));
1450 }
1451
1452 #[test]
1453 fn a_destructive_act_asks_first_and_a_shortcut_reaches_it() {
1454 // `524a63fe` and `2daea915`. Both are facts about the control that no
1455 // renderer can derive, and goingson expressed the first by calling a JS
1456 // helper at 33 call sites.
1457 let html = fragment(&Node::Act(
1458 Act::new("Delete", Action::post("/tasks/1/delete"))
1459 .tone(layout::Tone::Danger)
1460 .confirm("Delete this task? This cannot be undone.")
1461 .key("d"),
1462 ));
1463
1464 assert!(html.contains(r#"hx-confirm="Delete this task? This cannot be undone.""#));
1465 assert!(html.contains(r#"accesskey="d""#));
1466 assert!(html.contains("hx-post=\"/tasks/1/delete\""));
1467 }
1468
1469 #[test]
1470 fn a_row_offers_what_it_does_not_show() {
1471 // `5e02fbce`. `actions` is what the row shows; `menu` is what it offers,
1472 // opened by right-click, long-press or a key depending on the host.
1473 let html = fragment(&Node::list([Row::new("Buy milk")
1474 .act(Act::new("Done", Action::post("/tasks/1/complete")))
1475 .offers(Act::new("Duplicate", Action::post("/tasks/1/copy")))
1476 .offers(
1477 Act::new("Delete", Action::post("/tasks/1/delete")).confirm("Delete this task?"),
1478 )]));
1479
1480 assert!(html.contains(r#"data-menu="row""#));
1481 // Hidden rather than absent: the host opens it, and a menu that is not in
1482 // the document cannot be opened.
1483 assert!(html.contains(" hidden>"));
1484 assert!(html.contains("Duplicate"));
1485 assert!(html.contains(r#"hx-confirm="Delete this task?""#));
1486 // The shown action is still shown.
1487 assert!(html.contains("Done"));
1488 }
1489
1490 #[test]
1491 fn a_list_says_how_much_more_there_is_and_how_to_ask() {
1492 // `346567f9`. A described list of the first 50 of 400 was indistinguishable
1493 // from a described list of 50.
1494 let counted = fragment(
1495 &Node::list([Row::new("One")])
1496 .and_more(Rest::more(Action::get("/tasks?page=2")).remaining(350)),
1497 );
1498 assert!(counted.contains("350 remaining"));
1499 assert!(counted.contains("hx-get=\"/tasks?page=2\""));
1500
1501 // A count is often unknown: asking for 51 to find out whether there are
1502 // more than 50 answers the question without answering how many.
1503 let uncounted =
1504 fragment(&Node::list([Row::new("One")]).and_more(Rest::more(Action::get("/more"))));
1505 assert!(uncounted.contains("Show more"));
1506 assert!(!uncounted.contains("remaining"));
1507
1508 let plain = fragment(&Node::list([Row::new("One")]));
1509 assert!(!plain.contains("rest"));
1510 }
1511
1512 #[test]
1513 fn a_sortable_column_says_which_way_and_offers_the_press() {
1514 // `ce620871`. The one finding that completed a member rather than adding
1515 // one: `Column` shipped with a width and a priority and could say nothing
1516 // about order.
1517 let html = fragment(&Node::Table {
1518 columns: vec![
1519 Column::new("Title")
1520 .reorder(Action::get("/tasks?sort=title"))
1521 .sorted(layout::Sort::Ascending),
1522 Column::new("Due").reorder(Action::get("/tasks?sort=due")),
1523 Column::new("Notes"),
1524 ],
1525 rows: vec![Cells::new(["Ship it", "Tomorrow", "None"])],
1526 });
1527
1528 assert!(html.contains(r#"aria-sort="ascending""#));
1529 assert_eq!(html.matches("data-sortable").count(), 2);
1530 // The press is its own control inside the header cell, never the cell
1531 // itself: a `columnheader` is not a control and must not announce itself as
1532 // one. Reordering is a read, so the control is a link and is addressable.
1533 assert!(html.contains(r#"<a class="table-sort""#));
1534 assert!(html.contains("href=\"/tasks?sort=due\""));
1535 assert!(html.contains("hx-get=\"/tasks?sort=due\""));
1536 }
1537
1538 #[test]
1539 fn a_notice_interrupts_only_when_its_tone_says_to() {
1540 let danger = fragment(&Node::banner(layout::Tone::Danger, "Disk full"));
1541 assert!(danger.contains("role=\"alert\""));
1542
1543 let info = fragment(&Node::toast(layout::Tone::Info, "Saved"));
1544 assert!(info.contains("role=\"status\""));
1545 assert!(info.contains("aria-live=\"polite\""));
1546 }
1547
1548 #[test]
1549 fn a_screens_notices_come_before_its_regions() {
1550 let screen = Screen::list_detail("Tasks", false)
1551 .with(Slot::new("list", RegionKind::Pane))
1552 .saying(Node::toast(layout::Tone::Success, "Saved"));
1553 let html = render(&screen);
1554
1555 let notice = html.find("Saved").expect("the notice is rendered");
1556 let region = html.find("id=\"list\"").expect("the region is rendered");
1557 assert!(notice < region);
1558 }
1559
1560 #[test]
1561 fn every_arrangement_has_a_class_and_they_differ() {
1562 let plain = render(&Screen::list_detail("A", false));
1563 let tabbed = render(&Screen::list_detail("A", true));
1564 let sidebar = render(&Screen::sidebar_content("A"));
1565
1566 // The measure rides in the same attribute, and the two vary independently:
1567 // a compound `wide-list-detail` per pairing is the enumeration `1786cb94`
1568 // settled against one level down.
1569 assert!(plain.contains("class=\"list-detail measure-wide\""));
1570 assert!(tabbed.contains("class=\"list-detail-tabbed measure-wide\""));
1571 assert!(sidebar.contains("class=\"sidebar-content measure-wide\""));
1572
1573 let reading = render(&Screen::list_detail("A", false).measured(layout::Measure::Reading));
1574 assert!(reading.contains("class=\"list-detail measure-reading\""));
1575 }
1576
1577 #[test]
1578 fn a_screen_carries_the_share_as_a_number_rather_than_a_class() {
1579 // `e0fd485e`. A share is a number the description carries, and a class can
1580 // only name a number some stylesheet already fixed -- which is the drift
1581 // the member exists to end, since a terminal has no stylesheet to read it
1582 // out of.
1583 let wide = render(&Screen::sidebar_content("A"));
1584 assert!(
1585 wide.contains("--region-share:25fr;--region-rest:75fr"),
1586 "{wide}"
1587 );
1588
1589 let narrow = render(&Screen::new(
1590 "A",
1591 layout::Arrangement::sidebar_content().with_share(layout::Share::percent(20)),
1592 ));
1593 assert!(
1594 narrow.contains("--region-share:20fr;--region-rest:80fr"),
1595 "{narrow}"
1596 );
1597 }
1598
1599 #[test]
1600 fn a_class_prefix_reaches_every_emitted_name() {
1601 let emit = crate::Emit {
1602 class_prefix: "qs-",
1603 ..crate::Emit::default()
1604 };
1605
1606 let screen = Screen::list_detail("Tasks", false).with(
1607 Slot::new("list", RegionKind::Pane)
1608 .with(Node::page("Tasks"))
1609 .with(Node::list([Row::new("One")])),
1610 );
1611 let html = Webview::new().with_emit(emit).screen(&screen);
1612
1613 assert!(html.contains("class=\"qs-list-detail qs-measure-wide\""));
1614 assert!(html.contains("qs-region qs-pane"));
1615 assert!(html.contains("qs-heading"));
1616 assert!(html.contains("qs-list"));
1617 // No unprefixed leftovers: a name that missed the prefix is a rule in the
1618 // generated stylesheet that matches nothing.
1619 assert!(!html.contains("class=\"list\""));
1620 assert!(!html.contains("class=\"heading\""));
1621 }
1622
1623 #[test]
1624 fn the_shell_serves_its_assets_from_where_the_host_says() {
1625 let html = Webview::under("/assets").screen(&Screen::list_detail("A", false));
1626 assert!(html.contains("src=\"/assets/htmx.min.js\""));
1627 assert!(html.contains("src=\"/assets/idiomorph-ext.min.js\""));
1628
1629 // The Tauri case: a custom scheme, which is the whole reason this is a
1630 // parameter and not a constant.
1631 let tauri = Webview::under("quasi://localhost/assets").screen(&Screen::list_detail("A", false));
1632 assert!(tauri.contains("src=\"quasi://localhost/assets/htmx.min.js\""));
1633 }
1634
1635 #[test]
1636 fn stylesheets_link_in_the_order_they_were_added() {
1637 let shell = Shell::default().styled("/a.css").styled("/b.css");
1638 let html = Webview::new()
1639 .with_shell(shell)
1640 .screen(&Screen::list_detail("A", false));
1641
1642 let a = html.find("/a.css").expect("a is linked");
1643 let b = html.find("/b.css").expect("b is linked");
1644 assert!(a < b);
1645 }
1646
1647 #[test]
1648 fn injected_head_markup_lands_last_so_it_can_override() {
1649 let shell = Shell::default().with_head("<link rel=\"icon\" href=\"/f.png\">");
1650 let html = Webview::new()
1651 .with_shell(shell)
1652 .screen(&Screen::list_detail("A", false));
1653
1654 let icon = html.find("/f.png").expect("the icon is linked");
1655 let htmx = html.find("htmx.min.js").expect("htmx is linked");
1656 assert!(htmx < icon);
1657 assert!(icon < html.find("</head>").expect("the head closes"));
1658 }
1659
1660 #[test]
1661 fn the_layer_statement_precedes_every_stylesheet() {
1662 // The whole point: a layer's position is fixed where its name is first
1663 // seen, so a statement after the links is not a statement.
1664 let shell = Shell::default()
1665 .layered(["base", "components", "responsive"])
1666 .styled("/geometry.css")
1667 .styled("/style.css");
1668 let html = Webview::new()
1669 .with_shell(shell)
1670 .screen(&Screen::list_detail("A", false));
1671
1672 let stmt = html
1673 .find("@layer makeover, base, components, responsive;")
1674 .expect("the order is stated");
1675 let first_sheet = html.find("/geometry.css").expect("the sheet is linked");
1676 assert!(stmt < first_sheet);
1677 }
1678
1679 #[test]
1680 fn the_layer_statement_is_emitted_even_with_no_app_layers() {
1681 // An app that names no layers of its own still needs makeover pinned to the
1682 // bottom of the cascade, and that is the case where forgetting is easiest.
1683 let html = Webview::new().screen(&Screen::list_detail("A", false));
1684 assert!(html.contains("@layer makeover;"));
1685 }
1686
1687 #[test]
1688 fn a_layer_name_cannot_escape_the_style_element() {
1689 // HTML escaping does not apply inside <style>, so a `<` here would be a way
1690 // out of the element rather than a character in a name.
1691 let shell = Shell::default().layered(["base</style><script>alert(1)</script>"]);
1692 let html = Webview::new()
1693 .with_shell(shell)
1694 .screen(&Screen::list_detail("A", false));
1695
1696 assert!(!html.contains("<script>alert(1)"));
1697 assert!(html.contains("@layer makeover, basestylescriptalert1script;"));
1698 }
1699
1700 #[test]
1701 fn head_first_markup_lands_before_the_layer_statement_and_the_sheets() {
1702 // A preload discovered after the stylesheets it races bought nothing.
1703 let shell = Shell::default()
1704 .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">")
1705 .styled("/style.css");
1706 let html = Webview::new()
1707 .with_shell(shell)
1708 .screen(&Screen::list_detail("A", false));
1709
1710 let preload = html.find("/f.woff2").expect("the font is preloaded");
1711 let stmt = html.find("@layer makeover").expect("the order is stated");
1712 let sheet = html.find("/style.css").expect("the sheet is linked");
1713 assert!(preload < stmt);
1714 assert!(stmt < sheet);
1715 }
1716
1717 #[test]
1718 fn head_first_and_head_are_different_ends_of_the_same_head() {
1719 let shell = Shell::default()
1720 .with_head_first("<meta name=\"first\">")
1721 .with_head("<meta name=\"last\">");
1722 let html = Webview::new()
1723 .with_shell(shell)
1724 .screen(&Screen::list_detail("A", false));
1725
1726 let first = html.find("name=\"first\"").expect("first is emitted");
1727 let last = html.find("name=\"last\"").expect("last is emitted");
1728 let htmx = html.find("htmx.min.js").expect("htmx is linked");
1729 assert!(first < htmx);
1730 assert!(htmx < last);
1731 }
1732
1733 #[test]
1734 fn repeated_head_first_calls_keep_their_call_order() {
1735 let shell = Shell::default()
1736 .with_head_first("<meta name=\"a\">")
1737 .with_head_first("<meta name=\"b\">");
1738 let html = Webview::new()
1739 .with_shell(shell)
1740 .screen(&Screen::list_detail("A", false));
1741
1742 assert!(html.find("name=\"a\"") < html.find("name=\"b\""));
1743 }
1744
1745 #[test]
1746 fn a_document_wraps_markup_the_renderer_did_not_write() {
1747 // The path a host on hand-written templates takes: one head, emitted here,
1748 // around a body it rendered itself.
1749 let shell = Shell::default().layered(["base"]).styled("/style.css");
1750 let html = shell.document("Console", "<main>hand-written</main>");
1751
1752 assert!(html.starts_with("<!doctype html><html lang=\"en\">"));
1753 assert!(html.contains("<title>Console</title>"));
1754 assert!(html.contains("<main>hand-written</main>"));
1755 assert!(html.ends_with("</body></html>"));
1756 }
1757
1758 #[test]
1759 fn a_document_states_the_layers_before_the_sheets_like_a_screen_does() {
1760 // The whole reason a template would take the shell. If these two paths
1761 // disagree on layer order, the described and the Askama halves of one app
1762 // cascade differently.
1763 let shell = Shell::default().layered(["base"]).styled("/style.css");
1764 let html = shell.document("Console", "<main>x</main>");
1765
1766 let stmt = html
1767 .find("@layer makeover, base;")
1768 .expect("the order is stated");
1769 let sheet = html.find("/style.css").expect("the sheet is linked");
1770 let body = html.find("<main>").expect("the body is placed");
1771 assert!(stmt < sheet);
1772 assert!(sheet < body);
1773 }
1774
1775 #[test]
1776 fn a_documents_body_tag_is_the_shells_own() {
1777 // The morph registration and the body classes are the shell's on both
1778 // paths, so a template does not have to remember either.
1779 let shell = Shell {
1780 body_class: Some("console".into()),
1781 ..Shell::default()
1782 };
1783 let html = shell.document("Console", "x");
1784
1785 assert!(html.contains("<body hx-ext=\"morph\" class=\"console\">x</body>"));
1786 }
1787
1788 #[test]
1789 fn a_host_assembling_its_own_document_gets_the_same_head_as_a_screen() {
1790 // The property the split exists for. A server converting one screen at a
1791 // time renders both ways at once, and the two heads agreeing is the whole
1792 // reason its templates take the shell at all.
1793 let shell = Shell::default()
1794 .layered(["base", "components"])
1795 .styled("/style.css")
1796 .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">");
1797 let parts = shell.parts();
1798 let screen = Webview::new()
1799 .with_shell(shell)
1800 .screen(&Screen::list_detail("Console", false));
1801
1802 // Everything the SHELL owns is the same markup, in the same order. Two
1803 // things are not the shell's and are stripped before comparing: the title,
1804 // because the host writes it, and the screen's discovery tags, because a
1805 // host on this path has no `Screen` to read them from and writes its own
1806 // head metadata -- which is exactly what the server does today in its
1807 // `block head`.
1808 let head = screen
1809 .split("</head>")
1810 .next()
1811 .expect("the screen has a head")
1812 .replace("<title>Console</title>", "");
1813 let head = strip_discovery(&head);
1814 assert_eq!(parts.head, head);
1815 assert!(screen.contains(&format!("<body{}>", parts.body_attrs)));
1816 }
1817
1818 #[test]
1819 fn the_body_attributes_compose_with_the_hosts_own() {
1820 // Space-prefixed and never a bare `class`, so a template that writes its
1821 // own class attribute after them does not produce two.
1822 let parts = Shell::default().parts();
1823 assert_eq!(parts.body_attrs, " hx-ext=\"morph\"");
1824 assert!(!parts.head.contains("</head>"));
1825 assert!(!parts.head.contains("<title>"));
1826
1827 let quiet = Shell::default().without_morph().parts();
1828 assert_eq!(quiet.body_attrs, "");
1829 }
1830
1831 #[test]
1832 fn a_nested_region_renders_inside_its_parent() {
1833 let screen =
1834 Screen::list_detail("Tasks", false).with(Slot::new("outer", RegionKind::Split).with(
1835 Node::Region(Slot::new("inner", RegionKind::Pane).with(Node::text("in"))),
1836 ));
1837 let html = render(&screen);
1838
1839 let outer = html.find("id=\"outer\"").expect("outer renders");
1840 let inner = html.find("id=\"inner\"").expect("inner renders");
1841 assert!(outer < inner);
1842 assert!(html.contains("in</p></div></div>"));
1843 }
1844
1845 #[test]
1846 fn text_from_a_description_can_never_become_markup() {
1847 // The property that has to hold across every variant, because a
1848 // description's strings come from application state. Checked over the whole
1849 // tree rather than per node, so a variant added without escaping fails
1850 // here rather than in production.
1851 let hostile = "<script>alert(1)</script>";
1852 let screen = Screen::list_detail(hostile, false)
1853 .saying(Node::banner(layout::Tone::Danger, hostile))
1854 .with(
1855 Slot::new("s", RegionKind::Pane)
1856 .with(Node::page(hostile))
1857 .with(Node::text(hostile))
1858 .with(Node::act(hostile, Action::get("/x")))
1859 .with(Node::list([Row::new(hostile)
1860 .secondary(hostile)
1861 .meta(hostile)
1862 .act(Act::new(hostile, Action::post("/y")))]))
1863 .with(Node::Token(Tag {
1864 kind: layout::Token::Chip { removable: true },
1865 label: hostile.into(),
1866 tone: layout::Tone::Neutral,
1867 latched: false,
1868 action: Some(Action::get("/z")),
1869 })),
1870 );
1871
1872 let html = render(&screen);
1873 assert!(!html.contains("<script>"));
1874 // Twelve sinks: the title, the notice, the heading, the prose, the act's
1875 // label, the row's four parts, the chip's label, and the two the title is
1876 // repeated into for a link preview -- `og:title` and `twitter:title`, which
1877 // are attribute values and escape through the same path. Counted rather
1878 // than merely checked for absence, so a variant that silently stops
1879 // rendering its text fails here too.
1880 assert_eq!(html.matches("&lt;script&gt;").count(), 12);
1881 }
1882
1883 #[test]
1884 fn a_rows_plain_prose_is_escaped_exactly_as_it_always_was() {
1885 // The default case, and the one that must not change: `.secondary("...")`
1886 // still means text, and text is never markup however it is punctuated.
1887 let row = Row::new("Atlas").secondary("**not bold** <b>not bold either</b>");
1888 let node = Node::list(vec![row]);
1889
1890 let html = Webview::new().fragment(&node);
1891
1892 assert!(html.contains("**not bold**"), "got: {html}");
1893 assert!(!html.contains("<strong>"), "got: {html}");
1894 assert!(!html.contains("<b>"), "got: {html}");
1895 assert!(html.contains("&lt;b&gt;"), "got: {html}");
1896 }
1897
1898 #[test]
1899 fn a_rows_rich_prose_is_rendered_inline() {
1900 // The row-prose decision. The description says the string is markdown and
1901 // this renderer draws it as markdown, one line's worth.
1902 let row = Row::new("Atlas").secondary(Prose::rich("**Ships Q3.** `soon`"));
1903 let node = Node::list(vec![row]);
1904
1905 let html = Webview::new().fragment(&node);
1906
1907 assert!(html.contains("<strong>Ships Q3.</strong>"), "got: {html}");
1908 assert!(html.contains("<code>soon</code>"), "got: {html}");
1909 }
1910
1911 #[test]
1912 fn a_rows_rich_prose_keeps_no_blocks() {
1913 // A row is one line tall. A heading, a list and a quote each contribute
1914 // their words and none of them claims a block.
1915 let row = Row::new("Borealis").secondary(Prose::rich("# Goal\n\n> ship it\n\n- one\n- two"));
1916 let node = Node::list(vec![row]);
1917
1918 let html = Webview::new().fragment(&node);
1919
1920 // The outer `<ul class="list">` is the list itself; what must not appear is
1921 // a second one inside the row's own span.
1922 let secondary = html
1923 .split_once(r#"<span class="row-secondary">"#)
1924 .expect("the row draws its secondary")
1925 .1
1926 .split_once("</span>")
1927 .expect("the part closes")
1928 .0;
1929 for block in ["<h1", "<blockquote", "<ul", "<li", "<p"] {
1930 assert!(!secondary.contains(block), "no {block} in a row: {html}");
1931 }
1932 for word in ["Goal", "ship it", "one", "two"] {
1933 assert!(html.contains(word), "{word} survives: {html}");
1934 }
1935 }
1936
1937 #[test]
1938 fn a_rows_rich_prose_carries_no_second_click_target() {
1939 // The row is already the target through `activate`. An anchor inside it
1940 // would be a second target inside the first.
1941 let row = Row::new("Atlas")
1942 .secondary(Prose::rich(
1943 "see [the brief](https://example.com/a/long/path)",
1944 ))
1945 .activate(Action::get("/projects/1"));
1946 let node = Node::list(vec![row]);
1947
1948 let html = Webview::new().fragment(&node);
1949
1950 assert!(html.contains("see the brief"), "the text survives: {html}");
1951 assert!(!html.contains("example.com"), "no href: {html}");
1952 // The row itself is an anchor, drawn from `activate`. That one is the
1953 // target; the assertion is that the prose did not add a second.
1954 assert_eq!(
1955 html.matches("<a ").count(),
1956 1,
1957 "the row is the only target: {html}"
1958 );
1959 }
1960
1961 #[test]
1962 fn a_rows_rich_prose_cannot_smuggle_markup() {
1963 // `Prose::Rich` carries source, not markup, so the renderer decides what is
1964 // drawable. Raw HTML in the source is not.
1965 let row = Row::new("Atlas").secondary(Prose::rich(
1966 "hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))",
1967 ));
1968 let node = Node::list(vec![row]);
1969
1970 let html = Webview::new().fragment(&node);
1971
1972 assert!(!html.contains("<script"), "got: {html}");
1973 assert!(!html.contains("onerror"), "got: {html}");
1974 assert!(!html.contains("javascript:"), "got: {html}");
1975 }
1976
1977 #[test]
1978 fn no_control_ever_says_whether_its_answer_is_a_place() {
1979 // History is derived from the answer in quasi-http, not decided by the
1980 // control at render time. The MNW server has 24 hand-written hx-push-url
1981 // uses across 13 files, which is what asking the control looks like after
1982 // a while: each one is a prediction of what a route will do, made by the
1983 // party that does not know.
1984 let html = fragment(&Node::Table {
1985 columns: vec![Column::new("Title").width(layout::Width::Fill)],
1986 rows: vec![
1987 Cells::new([Cell::new("Release notes").activate(Action::get("/blog/7"))])
1988 .activate(Action::get("/blog/7/edit")),
1989 ],
1990 });
1991 assert!(!html.contains("push-url"), "{html}");
1992 assert!(!html.contains("replace-url"), "{html}");
1993 }
1994
1995 #[test]
1996 fn an_option_naming_its_own_route_calls_that_and_not_the_strips() {
1997 // The tab strip. dashboard-user has fifteen panels and fifteen routes, and
1998 // one strip-level action with the value substituted in cannot address them.
1999 let html = fragment(&Node::Select {
2000 kind: layout::Selector::Tabs,
2001 options: vec![
2002 (
2003 Choice {
2004 value: "projects".into(),
2005 label: "Projects".into(),
2006 },
2007 // No target on the action: decision 7 says the response names
2008 // what it replaces, and a panel route answers with a Fragment
2009 // aimed at the content slot.
2010 Some(Action::get("/dashboard/tabs/projects")),
2011 ),
2012 (
2013 Choice {
2014 value: "keys".into(),
2015 label: "SSH keys".into(),
2016 },
2017 Some(Action::get("/dashboard/tabs/ssh-keys")),
2018 ),
2019 ],
2020 chosen: Some("projects".into()),
2021 action: None,
2022 });
2023
2024 // Each panel's own route, and no value substituted into anything: the
2025 // option already names where it goes.
2026 assert!(html.contains("/dashboard/tabs/projects"), "{html}");
2027 assert!(html.contains("/dashboard/tabs/ssh-keys"), "{html}");
2028 assert!(
2029 !html.contains(&format!("{}=projects", Node::SELECTED)),
2030 "{html}"
2031 );
2032
2033 // A read is a link, so the tab works with JS off and can be copied.
2034 assert!(html.contains("<a"), "{html}");
2035 assert!(html.contains("role=\"tablist\""), "{html}");
2036 assert!(html.contains("aria-selected=\"true\""), "{html}");
2037 }
2038
2039 #[test]
2040 fn an_option_with_no_route_of_its_own_is_unchanged() {
2041 // Every segmented control and toggle in the tree is this case, and the
2042 // tuple must not have moved it: the strip's action carries the picked value
2043 // under the one agreed name, exactly as before.
2044 let with_tuple = fragment(&Node::Select {
2045 kind: layout::Selector::Segmented,
2046 options: vec![
2047 (
2048 Choice {
2049 value: "0".into(),
2050 label: "Active".into(),
2051 },
2052 None,
2053 ),
2054 (
2055 Choice {
2056 value: "1".into(),
2057 label: "Archived".into(),
2058 },
2059 None,
2060 ),
2061 ],
2062 chosen: Some("0".into()),
2063 action: Some(Action::get("/notes")),
2064 });
2065
2066 assert!(
2067 with_tuple.contains(&format!("/notes?{}=0", Node::SELECTED)),
2068 "{with_tuple}"
2069 );
2070 assert!(
2071 with_tuple.contains(&format!("/notes?{}=1", Node::SELECTED)),
2072 "{with_tuple}"
2073 );
2074 // Still a button: nothing about the fallback path changed.
2075 assert!(
2076 with_tuple.contains("<button type=\"button\""),
2077 "{with_tuple}"
2078 );
2079 }
2080
2081 #[test]
2082 fn a_screen_that_says_nothing_is_still_a_findable_page() {
2083 // The default has to be right, because most screens will never mention the
2084 // subject. Indexable, a title a preview can show, and a type.
2085 let html = render(&Screen::sidebar_content("Projects"));
2086
2087 assert!(
2088 html.contains("<meta property=\"og:title\" content=\"Projects\">"),
2089 "{html}"
2090 );
2091 assert!(
2092 html.contains("<meta property=\"og:type\" content=\"website\">"),
2093 "{html}"
2094 );
2095 assert!(!html.contains("robots"), "{html}");
2096
2097 // A None emits nothing rather than an empty tag. A preview showing a blank
2098 // line reads as a broken page.
2099 assert!(!html.contains("og:description"), "{html}");
2100 assert!(!html.contains("og:image"), "{html}");
2101 assert!(!html.contains("canonical"), "{html}");
2102 }
2103
2104 #[test]
2105 fn a_purchased_content_screen_can_say_it_is_not_for_crawlers() {
2106 // Six of the server's screens. This is the assertion the whole decision
2107 // exists to buy: a conversion that drops the tag fails here rather than
2108 // exposing the URLs and being noticed in a search result.
2109 let html = render(&Screen::sidebar_content("Downloads").indexed(false));
2110 assert!(
2111 html.contains("<meta name=\"robots\" content=\"noindex\">"),
2112 "{html}"
2113 );
2114 }
2115
2116 #[test]
2117 fn a_screen_names_what_it_is_about_and_how_it_previews() {
2118 let html = render(
2119 &Screen::sidebar_content("Blue Hour")
2120 .summarised("Nine tracks recorded in one night.")
2121 .illustrated("https://makenot.work/media/cover.png")
2122 .about(quasi_router::SocialKind::Song)
2123 .canonical_at("https://makenot.work/i/7"),
2124 );
2125
2126 assert!(
2127 html.contains("content=\"Nine tracks recorded in one night.\""),
2128 "{html}"
2129 );
2130 assert!(
2131 html.contains("content=\"https://makenot.work/media/cover.png\""),
2132 "{html}"
2133 );
2134 assert!(
2135 html.contains("<meta property=\"og:type\" content=\"music.song\">"),
2136 "{html}"
2137 );
2138 assert!(
2139 html.contains("<link rel=\"canonical\" href=\"https://makenot.work/i/7\">"),
2140 "{html}"
2141 );
2142
2143 // An image means a large card. The Twitter tags are `name`, never
2144 // `property`: they were not part of RDFa, and a card written the other way
2145 // is a card the crawler skips.
2146 assert!(
2147 html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">"),
2148 "{html}"
2149 );
2150 assert!(!html.contains("property=\"twitter:"), "{html}");
2151 }
2152
2153 #[test]
2154 fn a_summary_is_escaped_because_a_person_wrote_it() {
2155 // An item description and a bio are user-authored, and they land in an
2156 // attribute value. The one place in the head where that is true.
2157 let html =
2158 render(&Screen::sidebar_content("Item").summarised("She said \"hi\" & <b>waved</b>"));
2159
2160 assert!(!html.contains("<b>waved"), "{html}");
2161 assert!(html.contains("&quot;hi&quot;"), "{html}");
2162 assert!(html.contains("&amp;"), "{html}");
2163 }
2164
2165 /// A screen exercising every [`Node`] variant and every [`RegionKind`].
2166 ///
2167 /// Written out rather than derived, for the reason `makeover-webview`'s own
2168 /// `part_class` is written out: both enums are `#[non_exhaustive]`-shaped in
2169 /// practice and there is nothing to iterate. A variant added upstream and not
2170 /// added here emits classes this file never sees, which is the one way the
2171 /// check below can be quietly weakened. Grep this function when adding a node.
2172 fn every_kind_of_screen() -> Vec<String> {
2173 let mut htmls = Vec::new();
2174
2175 for kind in [
2176 RegionKind::Band,
2177 RegionKind::Sidebar,
2178 RegionKind::Pane,
2179 RegionKind::Split,
2180 RegionKind::TabGroup,
2181 RegionKind::Modal,
2182 ] {
2183 htmls.push(render(
2184 &Screen::list_detail("Everything", false)
2185 .saying(Node::banner(layout::Tone::Warning, "heads up"))
2186 .saying(Node::toast(layout::Tone::Success, "saved"))
2187 .with(Slot::new("region", kind).with(Node::text("in a region"))),
2188 ));
2189 }
2190 htmls.push(render(&Screen::sidebar_content("Everything").with(
2191 Slot::bespoke("bespoke", "map").with(Node::text("beside a fill")),
2192 )));
2193 htmls.push(render(&Screen::list_detail("Tabbed", true)));
2194 // Every measure, so the accounting below covers all three rather than only
2195 // the default a screen gets for saying nothing.
2196 for measure in [
2197 layout::Measure::Wide,
2198 layout::Measure::Contained,
2199 layout::Measure::Reading,
2200 ] {
2201 htmls.push(render(
2202 &Screen::list_detail("Measured", false).measured(measure),
2203 ));
2204 }
2205
2206 let acts = || Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger);
2207 for node in [
2208 Node::page("A page"),
2209 Node::section("A section"),
2210 Node::text("plain"),
2211 Node::rich("**bold** and a [link](https://example.com)"),
2212 Node::act("Save", Action::post("/save")),
2213 Node::Token(Tag::badge("Paid").tone(layout::Tone::Success)),
2214 Node::Token(Tag::chip("Open", Action::get("/tasks?open=1")).latched(true)),
2215 Node::banner(layout::Tone::Danger, "it broke"),
2216 Node::toast(layout::Tone::Info, "it saved"),
2217 Node::empty("nothing here").offering(acts()),
2218 Node::failed("it broke").offering(acts()),
2219 Node::field(Field::new(layout::FieldKind::Text, "name", "Name").required()),
2220 Node::field(Field::new(layout::FieldKind::Secret, "pw", "Password").error("too short")),
2221 Node::field(
2222 Field::new(layout::FieldKind::Checkbox, "live", "Live").changes(Action::post("/live")),
2223 ),
2224 Node::field(Field::select(
2225 "size",
2226 "Size",
2227 vec![Choice::plain("small"), Choice::new("l", "large")],
2228 )),
2229 Node::field(Field::radio(
2230 "mode",
2231 "Mode",
2232 vec![Choice::plain("one"), Choice::plain("two")],
2233 )),
2234 Node::Form {
2235 action: Action::post("/new"),
2236 submit: "Create".into(),
2237 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
2238 },
2239 Node::list([Row::new("fw13")
2240 .secondary("a second line")
2241 .meta("2 days ago")
2242 .token(Tag::badge("Active"))
2243 .meter(Meter::new(3, 10))
2244 .activate(Action::get("/keys/7"))
2245 .act(acts())])
2246 .and_more(Rest::more(Action::get("/keys?page=2")).remaining(40)),
2247 Node::list([Row::new("astra").toggling(true, Action::post("/keys/8/pin"))]),
2248 Node::Table {
2249 columns: vec![
2250 Column::new("Name")
2251 .width(layout::Width::Fill)
2252 .priority(layout::Priority::Essential),
2253 Column::new("Status")
2254 .width(layout::Width::Content)
2255 .priority(layout::Priority::Optional),
2256 ],
2257 rows: vec![
2258 Cells::new([
2259 Cell::new("deploy"),
2260 Cell::tag(Tag::badge("Paid").tone(layout::Tone::Success)),
2261 ])
2262 .activate(Action::get("/runs/1")),
2263 Cells::new([Cell::new("build"), Cell::acts([acts()])]),
2264 ],
2265 },
2266 Node::Meter(Meter::new(7, 10).label("7 of 10")),
2267 Node::stats([Figure::new("$12.00", "Revenue").change("+3%")]),
2268 ] {
2269 htmls.push(fragment(&node));
2270 }
2271
2272 for kind in [
2273 layout::Selector::Tabs,
2274 layout::Selector::Segmented,
2275 layout::Selector::Toggle,
2276 ] {
2277 htmls.push(fragment(&Node::Select {
2278 kind,
2279 options: vec![
2280 (Choice::plain("open"), Some(Action::get("/tasks?open=1"))),
2281 (Choice::new("done", "Done"), None),
2282 ],
2283 chosen: Some("open".into()),
2284 action: Some(Action::get("/tasks")),
2285 }));
2286 }
2287
2288 htmls
2289 }
2290
2291 /// Every class name the markup carries, from `class="a b c"` attributes.
2292 ///
2293 /// Column identity classes are dropped. `col-Name` is `column_classes`'s own
2294 /// output and names the column rather than the vocabulary, so it is data and
2295 /// there is nothing for a stylesheet to define.
2296 fn emitted_classes(htmls: &[String]) -> std::collections::BTreeSet<String> {
2297 let mut names = std::collections::BTreeSet::new();
2298 for html in htmls {
2299 let mut rest = html.as_str();
2300 while let Some(at) = rest.find("class=\"") {
2301 rest = &rest[at + 7..];
2302 let end = rest.find('"').expect("the attribute closes");
2303 for name in rest[..end].split_whitespace() {
2304 if !name.starts_with("col-") {
2305 names.insert(name.to_string());
2306 }
2307 }
2308 rest = &rest[end..];
2309 }
2310 }
2311 names
2312 }
2313
2314 /// Classes this renderer emits that no rule in the generated stylesheet names,
2315 /// because there is nothing for makeover to say about them.
2316 ///
2317 /// Arrangements and regions are placement, and placement is spacing:
2318 /// `makeover-geometry`'s question, answered per app in `styles.css`. The rest
2319 /// are containers holding things makeover styles one by one, or elements that
2320 /// are already an element before they are a class.
2321 ///
2322 /// This list is the boundary written down, not a todo. A *component* joining it
2323 /// is the bug, and the test is what refuses one.
2324 const BY_DESIGN: &[&str] = &[
2325 // Arrangements, from `Webview::arrangement_class`.
2326 "list-detail",
2327 "list-detail-tabbed",
2328 "sidebar-content",
2329 // Measures, from `Screen::measure`. Placement for the same reason an
2330 // arrangement is: the screen says which of the three it is, and what a
2331 // measure means in pixels is the app's stylesheet answering once instead of
2332 // 69 templates answering separately. `0eccff0d` moved the choice, not the
2333 // number.
2334 "measure-wide",
2335 "measure-contained",
2336 "measure-reading",
2337 // Regions, from `region_class`.
2338 "band",
2339 "bespoke",
2340 "modal",
2341 "pane",
2342 "region",
2343 "sidebar",
2344 "split",
2345 "tabgroup",
2346 // Containers. Each holds things that carry their own styled classes.
2347 "figures",
2348 "form",
2349 "notices",
2350 "rest",
2351 "row",
2352 "selector",
2353 // Typography. makeover sets no type scale, so a heading and a paragraph
2354 // are an `h2` and a `p` before they are anything this crate named.
2355 "heading",
2356 "rich",
2357 "text",
2358 ];
2359
2360 /// Classes that name *which* of something, on an element already styled by the
2361 /// class beside them.
2362 ///
2363 /// `class="button act-submit"` takes its whole appearance from `button`. The
2364 /// second name exists so an app can reach the submit button of a form without
2365 /// reaching every button, and a rule for it upstream would be makeover deciding
2366 /// that a submit button looks different, which is the app's call.
2367 ///
2368 /// The row parts are makeover's own `part_class` output. `row_rules` writes a
2369 /// colour for `Primary`, `Secondary` and `Meta` and deliberately none for these
2370 /// three: actions carry controls, and tokens and proportions each carry their
2371 /// own tone, so a colour on the container would fight what is inside it.
2372 const MODIFIERS: &[&str] = &[
2373 "act-submit",
2374 "rest-more",
2375 "row-actions",
2376 "row-activate",
2377 "row-proportion",
2378 "row-select",
2379 "row-selected",
2380 "row-tokens",
2381 "cell-actions",
2382 "cell-tokens",
2383 "field-writes",
2384 ];
2385
2386 /// Classes that reach the markup with no rule anywhere, which is a gap rather
2387 /// than a decision.
2388 ///
2389 /// Every one is a component: something makeover-layout names, that a described
2390 /// screen produces, that arrives unstyled. This is the same failure the
2391 /// SSH-keys tab found, one layer up — the name is not invented here, it is
2392 /// correct and nothing defines it.
2393 ///
2394 /// Three separate causes, none of them fixable in this crate alone:
2395 ///
2396 /// - `form-*`, `has-error`, `visible` and `placeholder-action` are emitted by
2397 /// `makeover_webview::form` and `::placeholder` and styled by no rule that
2398 /// crate's own `stylesheet` writes. A field's anatomy is makeover's from end
2399 /// to end, so both halves are over there.
2400 /// - `cell-fill` and `cell-keeps` come off `column_classes`, and the rules that
2401 /// make them mean anything come off `list::narrowing_css`, which needs the
2402 /// columns and is therefore per-table. Nothing calls it here, so a described
2403 /// table has no column tracks and no narrowing: every column is content-width
2404 /// and none of them ever drops. goingson generates its `tables.css` from it in
2405 /// its own `build.rs`, which is the shape a described table cannot use,
2406 /// because its columns are known at render time and not at build time.
2407 /// - `banner` and `toast` are `makeover_layout::Notice`, which the description
2408 /// layer names and `component_rules` has no section for.
2409 ///
2410 /// Shrinking this list is the work. Growing it needs a reason written here.
2411 const GAPS: &[&str] = &[
2412 "form-checkbox-label",
2413 "form-error",
2414 "form-group",
2415 "form-label",
2416 "form-radio-group",
2417 "form-radio-label",
2418 "has-error",
2419 "visible",
2420 "placeholder-action",
2421 "cell-fill",
2422 "cell-keeps",
2423 "banner",
2424 "toast",
2425 ];
2426
2427 #[test]
2428 fn every_class_this_renderer_emits_is_one_makeover_defines() {
2429 // The invariant the SSH-keys tab found three counterexamples to, checked
2430 // by enumeration rather than by remembering the three. `act`, `tone-danger`
2431 // and `chip-latched` were each a name this renderer made up, and each one
2432 // rendered a described control as unstyled text beside a hand-written one
2433 // that had a rule. A fourth is a matter of time without this.
2434 let css = makeover_webview::stylesheet(&Emit::default());
2435 let emitted = emitted_classes(&every_kind_of_screen());
2436
2437 // Whole-name matching. `.row` is in the stylesheet and `.row-primary`
2438 // starts with it, so a substring search would call every misspelling styled.
2439 let styled = |name: &str| {
2440 css.match_indices(&format!(".{name}")).any(|(at, found)| {
2441 css[at + found.len()..]
2442 .chars()
2443 .next()
2444 .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
2445 })
2446 };
2447
2448 let unstyled: Vec<&str> = emitted
2449 .iter()
2450 .map(String::as_str)
2451 .filter(|name| !styled(name))
2452 .collect();
2453
2454 let mut accounted: Vec<&str> = [BY_DESIGN, MODIFIERS, GAPS].concat();
2455 accounted.sort_unstable();
2456 assert_eq!(
2457 accounted.len(),
2458 accounted
2459 .iter()
2460 .collect::<std::collections::BTreeSet<_>>()
2461 .len(),
2462 "a name is in two of the three lists, which means two answers to one \
2463 question"
2464 );
2465
2466 assert_eq!(
2467 unstyled, accounted,
2468 "a class this renderer emits has no rule and no entry above. If \
2469 makeover spells it differently, use makeover's spelling -- that is the \
2470 whole of the SSH-keys bug. Otherwise put it in BY_DESIGN, MODIFIERS or \
2471 GAPS with the reason, and note that GAPS is work rather than a \
2472 decision."
2473 );
2474 }
2475
2476 #[test]
2477 fn a_class_prefix_reaches_the_markup_the_way_it_reaches_the_stylesheet() {
2478 // The prefix is one setting shared by two emitters, and the failure is
2479 // silent in the same way: a prefixed app whose renderer forgot the prefix
2480 // on one element gets a stylesheet that matches everything except that
2481 // element.
2482 let emit = Emit {
2483 class_prefix: "mk-",
2484 ..Emit::default()
2485 };
2486 let html = Webview::new()
2487 .with_emit(emit)
2488 .fragment(&Node::list([Row::new("fw13").token(Tag::badge("Active"))]));
2489
2490 assert!(html.contains("class=\"mk-row\""), "{html}");
2491 assert!(html.contains("mk-row-primary"), "{html}");
2492 assert!(html.contains("mk-badge"), "{html}");
2493 assert!(!html.contains("\"row\""), "{html}");
2494 }
2495
2496 #[test]
2497 fn an_invalidated_slot_is_addressed_by_name_and_swapped_inside_its_region() {
2498 // `innerHTML:` rather than a bare `true`, because a bare one replaces the
2499 // element carrying the id and that element is the region `slot_html`
2500 // emitted, classes and all. The region would keep its contents and lose
2501 // its layout.
2502 let html = Webview::new().invalidated("task-count", &Node::text("4 left"));
2503
2504 assert!(
2505 html.starts_with("<div hx-swap-oob=\"innerHTML:#task-count\">"),
2506 "{html}"
2507 );
2508 assert!(html.contains("4 left"), "{html}");
2509 assert!(html.ends_with("</div>"), "{html}");
2510 }
2511
2512 #[test]
2513 fn a_slot_id_cannot_break_out_of_the_out_of_band_selector() {
2514 // A slot id reaches this from the description, and the description is the
2515 // app's. It is escaped for the same reason `slot_html` escapes it.
2516 let html = Webview::new().invalidated("a\"><script>x</script>", &Node::text("hi"));
2517 assert!(!html.contains("<script>"), "{html}");
2518 }
2519
2520 #[test]
2521 fn a_tick_carries_the_value_it_contributes_to_the_selection() {
2522 // `5f2b8753`. A checkbox with no value submits `on`, which says a box was
2523 // checked and not which one, so every app gathered them in JS instead.
2524 let html = fragment(&Node::list([
2525 Row::new("First").ticking("m-1", false),
2526 Row::new("Second").ticking("m-2", true),
2527 ]));
2528
2529 assert!(html.contains("name=\"ticked\" value=\"m-1\""), "{html}");
2530 assert!(html.contains("name=\"ticked\" value=\"m-2\""), "{html}");
2531 assert_eq!(html.matches("checked").count(), 1, "{html}");
2532 }
2533
2534 #[test]
2535 fn a_commit_control_gathers_every_tick_on_the_screen() {
2536 // Declarative, because a checkbox already submits its own name and value:
2537 // all that was missing was something saying which boxes belong together.
2538 // That is the whole of what the per-app gathering JS did.
2539 let html = fragment(&Node::Act(
2540 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
2541 ));
2542
2543 assert!(html.contains("hx-include=\".row-select\""), "{html}");
2544 assert!(html.contains("hx-post=\"/mail/archive\""), "{html}");
2545 }
2546
2547 #[test]
2548 fn a_control_over_nothing_gathers_nothing() {
2549 let html = fragment(&Node::Act(Act::new(
2550 "Delete",
2551 Action::post("/mail/1/delete"),
2552 )));
2553 assert!(!html.contains("hx-include"), "{html}");
2554 }
2555
2556 #[test]
2557 fn the_gathering_selector_follows_a_hosts_class_prefix() {
2558 // A host setting `Emit::class_prefix` moves the class the ticks carry, and
2559 // the selector has to move with it or the commit control gathers nothing.
2560 let emit = Emit {
2561 class_prefix: "q-",
2562 ..Emit::default()
2563 };
2564 let render = Webview::new().with_emit(emit);
2565
2566 let boxes = render.fragment(&Node::list([Row::new("First").ticking("m-1", false)]));
2567 let button = render.fragment(&Node::Act(
2568 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
2569 ));
2570
2571 assert!(boxes.contains("q-row-select"), "{boxes}");
2572 assert!(button.contains("hx-include=\".q-row-select\""), "{button}");
2573 }
2574
2575 #[test]
2576 fn an_overlay_is_the_inside_of_a_container_and_not_a_document() {
2577 // The whole difference from a screen: what is under it keeps its document.
2578 let screen = Screen::sidebar_content("Palette")
2579 .with(Slot::new("results", RegionKind::Pane).with(Node::text("Open task")));
2580 let html = Webview::new().overlay(&screen);
2581 assert!(!html.contains("<html"), "{html}");
2582 assert!(!html.contains("<body"), "{html}");
2583 assert!(!html.contains("<title"), "{html}");
2584 assert!(html.contains("Open task"), "{html}");
2585 }
2586
2587 #[test]
2588 fn an_overlay_names_the_container_it_lands_in() {
2589 // What `quasi-http` turns into the retarget header. A renderer answering
2590 // `None` here is one with no overlay container, and this one has one.
2591 assert_eq!(
2592 Webview::new().overlay_target(),
2593 Some(crate::chrome::OVERLAY_ID)
2594 );
2595 }
2596
2597 #[test]
2598 fn a_document_carries_the_apps_bindings_and_the_container_they_open_into() {
2599 use quasi_router::Chrome;
2600
2601 let shell = Shell::default().with_chrome(Chrome::new().bind(
2602 "ctrl+k",
2603 "Search",
2604 Action::get("/palette"),
2605 ));
2606 let html = Webview::new()
2607 .with_shell(shell)
2608 .screen(&Screen::list_detail("Tasks", false));
2609 assert!(html.contains("hx-get=\"/palette\""), "{html}");
2610 assert!(html.contains("from:body"), "{html}");
2611 assert!(html.contains("id=\"quasi-overlay\""), "{html}");
2612 // After the content and before the body closes: chrome is the app's, so it
2613 // sits outside what a screen's markup is.
2614 let overlay_at = html.find("id=\"quasi-overlay\"").expect("emitted");
2615 assert!(
2616 overlay_at > html.find("</main>").expect("main closes"),
2617 "{html}"
2618 );
2619 assert!(html.ends_with("</body></html>"), "{html}");
2620 }
2621
2622 #[test]
2623 fn an_app_declaring_no_chrome_gets_the_document_it_always_got() {
2624 // What makes this additive: nothing is emitted, so nothing moved.
2625 let html = render(&Screen::list_detail("Tasks", false));
2626 assert!(!html.contains("quasi-overlay"), "{html}");
2627 assert!(!html.contains("data-chrome"), "{html}");
2628 }
2629
2630 /// A carousel: three frames, the second up, and no label anywhere.
2631 fn gallery() -> Slot {
2632 Slot::widget("shots", "carousel")
2633 .extend((0..3).map(|n| {
2634 Node::Image(quasi_router::screen::Picture::new(
2635 format!("/shot-{n}.png"),
2636 format!("shot {n}"),
2637 ))
2638 }))
2639 .showing_one(1)
2640 }
2641
2642 #[test]
2643 fn a_region_showing_everything_emits_exactly_what_it_always_did() {
2644 // The whole additive claim. `Showing::All` is the default, so every
2645 // description written before the member existed has to come out unchanged:
2646 // no wrapper, no hook, no row.
2647 let screen = Screen::list_detail("Tasks", false)
2648 .with(Slot::new("main", RegionKind::Pane).with(Node::text("plain")));
2649 let html = render(&screen);
2650
2651 assert!(!html.contains("data-showing"));
2652 assert!(!html.contains("showing-frame"));
2653 assert!(!html.contains("showing-position"));
2654 }
2655
2656 #[test]
2657 fn a_carousel_gets_a_row_without_this_renderer_knowing_what_a_carousel_is() {
2658 // The point of the whole design. Nothing below reads the widget's name, and
2659 // a second assembly showing one child at a time gets the same row for free.
2660 let screen = Screen::list_detail("Product", false).with(gallery());
2661 let html = render(&screen);
2662
2663 assert!(html.contains("data-showing=\"one\""), "{html}");
2664 assert!(html.contains("data-shows=\"previous\""), "{html}");
2665 assert!(html.contains("data-shows=\"next\""), "{html}");
2666 // Position counts from one for a reader, and off `current()` rather than
2667 // off `shown`, so a clamped index reports where the frame actually is.
2668 assert!(html.contains(">2 / 3</span>"), "{html}");
2669 // The name is still there and is still nobody's business here.
2670 assert!(html.contains("data-widget=\"carousel\""));
2671 }
2672
2673 #[test]
2674 fn the_row_sits_under_the_frames_and_overlays_nothing() {
2675 // Max, 2026-08-14: the shipped arrows were absolutely positioned over the
2676 // picture, which a terminal cannot honestly do and which read as clutter
2677 // here. In flow, after the content, on every host.
2678 let html = render(&Screen::list_detail("Product", false).with(gallery()));
2679
2680 let last_frame = html.rfind("showing-frame").expect("frames are wrapped");
2681 let row = html.rfind("showing-position").expect("a row is derived");
2682 assert!(row > last_frame, "{html}");
2683 }
2684
2685 #[test]
2686 fn only_the_current_frame_is_marked_and_the_rest_are_still_in_the_document() {
2687 // Degradation runs toward more content. Every frame ships; the rule that
2688 // collapses them waits for whatever binds the region, so a reader with no
2689 // script gets the whole gallery instead of one frame and two dead buttons.
2690 let html = render(&Screen::list_detail("Product", false).with(gallery()));
2691
2692 assert_eq!(html.matches("showing-frame").count(), 3, "{html}");
2693 assert_eq!(html.matches("showing-frame current").count(), 1, "{html}");
2694 assert!(html.contains("/shot-0.png") && html.contains("/shot-2.png"));
2695 }
2696
2697 #[test]
2698 fn labelled_children_get_a_strip_and_it_is_makeovers_tab_markup() {
2699 // A tab strip is already a described thing. Deriving a second spelling of
2700 // one is how `tabs` and `segmented` came to render flat.
2701 let screen = Screen::list_detail("Project", false).with(
2702 Slot::new("detail", RegionKind::TabGroup)
2703 .with(Node::Region(
2704 Slot::new("overview", RegionKind::Pane).label("Overview"),
2705 ))
2706 .with(Node::Region(
2707 Slot::new("files", RegionKind::Pane).label("Files"),
2708 ))
2709 .showing_one(1),
2710 );
2711 let html = render(&screen);
2712
2713 assert!(html.contains("data-selector=\"tab\""), "{html}");
2714 assert!(html.contains("role=\"tablist\""), "{html}");
2715 assert!(html.contains(">Overview</button>"), "{html}");
2716 assert!(html.contains("chosen\" data-shows=\"1\""), "{html}");
2717 assert!(
2718 html.contains("aria-selected=\"true\">Files</button>"),
2719 "{html}"
2720 );
2721 assert!(
2722 html.contains("aria-selected=\"false\">Overview</button>"),
2723 "{html}"
2724 );
2725 // A strip, not a counter row: the labels are what the reader steers by.
2726 assert!(!html.contains("showing-position"), "{html}");
2727 }
2728
2729 #[test]
2730 fn a_strip_sits_above_the_panes_it_opens() {
2731 // The folder semantic. A tab after its pane would not read as the tab of
2732 // it, and this is the only placement decision the derivation makes.
2733 let screen = Screen::list_detail("Project", false).with(
2734 Slot::new("detail", RegionKind::TabGroup)
2735 .with(Node::Region(
2736 Slot::new("overview", RegionKind::Pane).label("Overview"),
2737 ))
2738 .with(Node::Region(
2739 Slot::new("files", RegionKind::Pane).label("Files"),
2740 ))
2741 .showing_one(0),
2742 );
2743 let html = render(&screen);
2744
2745 let strip = html
2746 .find("data-selector=\"tab\"")
2747 .expect("a strip is derived");
2748 let first_frame = html.find("showing-frame").expect("frames are wrapped");
2749 assert!(strip < first_frame, "{html}");
2750 }
2751
2752 #[test]
2753 fn a_named_child_that_can_close_is_a_summary_line() {
2754 // Disclosure, which is `871e7f21` and is the third findings this member
2755 // collapses. A strip of one tab is not what a summary line is, so the
2756 // dismissible case is checked before the labelled one.
2757 let screen = Screen::list_detail("Item", false).with(
2758 Slot::widget("more", "disclosure")
2759 .with(Node::Region(
2760 Slot::new("body", RegionKind::Pane)
2761 .label("Technical details")
2762 .with(Node::text("the rest")),
2763 ))
2764 .showing_at_most_one(None),
2765 );
2766 let html = render(&screen);
2767
2768 assert!(html.contains("data-showing=\"at-most-one\""), "{html}");
2769 assert!(html.contains("aria-expanded=\"false\""), "{html}");
2770 assert!(html.contains(">Technical details</button>"), "{html}");
2771 assert!(!html.contains("data-shows=\"next\""), "{html}");
2772 }
2773
2774 #[test]
2775 fn a_derived_control_names_no_route_and_the_transport_stays_in_one_function() {
2776 // The controls move between children already in the document, so there is
2777 // nothing to fetch. Emitting htmx here would put the transport in a second
2778 // place, which is the claim `action_attrs` exists to keep true.
2779 let html = render(&Screen::list_detail("Product", false).with(gallery()));
2780 let row = &html[html.find("showing-position").expect("a row is derived") - 200..];
2781
2782 assert!(!row.contains("hx-get"), "{row}");
2783 assert!(!row.contains("hx-post"), "{row}");
2784 }
2785
2786 // ---------------------------------------------------------------- timeline
2787
2788 /// A row with one word in it, for placing on an axis.
2789 fn placed(at: u16, minutes: u16, text: &str) -> quasi_router::screen::Placed {
2790 quasi_router::screen::Placed::new(at, minutes, Row::new(text))
2791 }
2792
2793 #[test]
2794 fn a_timeline_places_each_entry_as_a_percentage_of_its_span() {
2795 // 09:00 for an hour, on a midnight-to-midnight axis: 37.5% down, 4.1667%
2796 // tall. The arithmetic is Track::fraction's, asserted here because this is
2797 // where a renderer would be tempted to do its own.
2798 let node = Node::Timeline {
2799 track: layout::Track::DAY,
2800 entries: vec![placed(540, 60, "Standup")],
2801 focus: None,
2802 };
2803 let html = fragment(&node);
2804
2805 assert!(html.contains("--track-at:37.5000%"), "{html}");
2806 assert!(html.contains("--track-for:4.1667%"), "{html}");
2807 // Full width when nothing collides, from the defaults rather than from a
2808 // special case in the emitter.
2809 assert!(html.contains("--track-lane:0"), "{html}");
2810 assert!(html.contains("--track-lanes:1"), "{html}");
2811 }
2812
2813 #[test]
2814 fn overlapping_entries_take_lanes_and_the_rest_stay_wide() {
2815 // Two at once and one after. The two collide, so the track is two lanes
2816 // wide; the third does not collide with either but shares the width,
2817 // because the lane count is per-track rather than per-cluster. That is a
2818 // layout decision this renderer owns and the test records it as one.
2819 let node = Node::Timeline {
2820 track: layout::Track::DAY,
2821 entries: vec![
2822 placed(540, 60, "Standup"), // 09:00-10:00
2823 placed(570, 60, "Interview"), // 09:30-10:30
2824 placed(720, 30, "Lunch"), // 12:00-12:30
2825 ],
2826 focus: None,
2827 };
2828 let html = fragment(&node);
2829
2830 assert!(html.contains("--track-lane:0"), "{html}");
2831 assert!(html.contains("--track-lane:1"), "{html}");
2832 assert!(
2833 !html.contains("--track-lane:2"),
2834 "two collide, not three: {html}"
2835 );
2836 assert_eq!(html.matches("--track-lanes:2").count(), 3, "{html}");
2837 }
2838
2839 #[test]
2840 fn things_that_only_touch_do_not_collide() {
2841 // 09:00-10:00 and 10:00-11:00 are back to back. `to` is exclusive, so they
2842 // share no minute and both keep the full width. Off-by-one here is the
2843 // classic day-view bug: every hour on the hour reads as a conflict.
2844 let node = Node::Timeline {
2845 track: layout::Track::DAY,
2846 entries: vec![placed(540, 60, "First"), placed(600, 60, "Second")],
2847 focus: None,
2848 };
2849 let html = fragment(&node);
2850
2851 assert!(html.contains("--track-lanes:1"), "{html}");
2852 assert!(!html.contains("--track-lane:1"), "{html}");
2853 }
2854
2855 #[test]
2856 fn a_timeline_labels_its_ruler_in_wall_clock_and_wraps_past_midnight() {
2857 // An overnight span counts past 1440 so it needs no date, and the ruler
2858 // wraps that back to a clock a person reads. 22:00 to 02:00, hourly.
2859 let node = Node::Timeline {
2860 track: layout::Track::over(layout::Span::new(1320, 1560)),
2861 entries: vec![],
2862 focus: None,
2863 };
2864 let html = fragment(&node);
2865
2866 assert!(html.contains(">22:00<"), "{html}");
2867 assert!(html.contains(">00:00<"), "{html}");
2868 assert!(html.contains(">01:00<"), "{html}");
2869 assert!(!html.contains(">25:00<"), "the clock wrapped: {html}");
2870 }
2871
2872 #[test]
2873 fn a_timeline_carries_its_focus_as_a_moment_and_never_as_an_offset() {
2874 // "Show me 09:00", not a pixel. The host decides how to get there, which is
2875 // the arrangement that replaces goingson's hardcoded targetHour.
2876 let node = Node::Timeline {
2877 track: layout::Track::DAY,
2878 entries: vec![],
2879 focus: Some(540),
2880 };
2881 let html = fragment(&node);
2882
2883 assert!(html.contains("data-focus=\"540\""), "{html}");
2884 assert!(!html.contains("scrollTop"), "{html}");
2885 assert!(!html.contains("px"), "{html}");
2886 }
2887
2888 #[test]
2889 fn a_placed_rows_text_cannot_become_markup() {
2890 // The property the whole port exists for. An entry's row goes through the
2891 // same escaping every other row does; being placed is not a way around it.
2892 let node = Node::Timeline {
2893 track: layout::Track::DAY,
2894 entries: vec![placed(540, 60, "<script>alert('x')</script>")],
2895 focus: None,
2896 };
2897 let html = fragment(&node);
2898
2899 assert!(!html.contains("<script>"), "{html}");
2900 assert!(html.contains("&lt;script&gt;"), "{html}");
2901 }
2902
2903 #[test]
2904 fn a_timeline_emits_no_size_of_its_own() {
2905 // Every number this renderer writes is a percentage of the span or a lane
2906 // index. A pixel here would be quasi deciding how tall an hour is, which is
2907 // makeover-geometry's question and the line makeover-webview's track_rules
2908 // holds from the other side.
2909 let node = Node::Timeline {
2910 track: layout::Track::DAY,
2911 entries: vec![placed(540, 60, "Standup")],
2912 focus: None,
2913 };
2914 let html = fragment(&node);
2915
2916 for unit in ["px", "rem", "em;", "vh"] {
2917 assert!(!html.contains(unit), "emitted a {unit}: {html}");
2918 }
2919 }
2920
2921 // ---------------------------------------------------------------- columns
2922
2923 #[test]
2924 fn peer_columns_name_themselves_and_carry_no_width() {
2925 // A board: three columns, no master and no detail. The class is all this
2926 // renderer says; how wide each column sits is the consumer's stylesheet,
2927 // the same as band, sidebar and pane.
2928 let column = |id: &str, label: &str| {
2929 Slot::new(id, RegionKind::Pane).with(Node::Heading {
2930 level: layout::Heading::Section,
2931 text: label.into(),
2932 })
2933 };
2934 let screen = Screen::list_detail("Tasks", false).with(
2935 Slot::new("board", RegionKind::Columns)
2936 .with(Node::Region(column("pending", "Pending")))
2937 .with(Node::Region(column("started", "Started")))
2938 .with(Node::Region(column("done", "Completed"))),
2939 );
2940
2941 let html = render(&screen);
2942
2943 assert!(html.contains("columns"), "{html}");
2944 // No share, no count, no order index. Every one of those would be this
2945 // renderer deciding something peers settle by being peers.
2946 assert!(!html.contains("data-share"), "{html}");
2947 assert!(!html.contains("data-columns"), "{html}");
2948 for label in ["Pending", "Started", "Completed"] {
2949 assert!(html.contains(label), "{html}");
2950 }
2951 }
2952
2953 #[test]
2954 fn a_board_is_not_a_split() {
2955 // Both are regions side by side and they mean different things: a split's
2956 // left pane chooses what its right shows. If these ever emit the same
2957 // class, that distinction has been lost in the one place it is visible.
2958 let board = render(&Screen::list_detail("B", false).with(Slot::new("r", RegionKind::Columns)));
2959 let split = render(&Screen::list_detail("S", false).with(Slot::new("r", RegionKind::Split)));
2960
2961 assert!(board.contains("columns"), "{board}");
2962 assert!(!board.contains("split"), "{board}");
2963 assert!(split.contains("split"), "{split}");
2964 }
2965