Skip to main content

max / quasi

316.0 KB · 7931 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 Accepted, Act, Candidate, Canvas, Cell, CellKey, Choice, Column, Consult, Document, Field,
14 Figure, Jump, Meter, Prose, Repeat, Repeating, Rest, Row, Tag, ThemeChoice,
15 };
16 use quasi_router::{Action, Frame, Node, RegionKind, Reveal, Richness, Run, Screen, Slot, Trust};
17
18 use crate::{Emit, Shell, Webview};
19
20 /// A head with the screen's discovery tags removed.
21 ///
22 /// They are the one part of the head that comes off the `Screen` rather than
23 /// off the `Shell`, so a comparison against the shell's own parts has to drop
24 /// them or it is comparing two different questions.
25 fn strip_discovery(head: &str) -> String {
26 let mut out = String::with_capacity(head.len());
27 let mut rest = head;
28 while let Some(start) = rest.find("<meta property=\"og:").or_else(|| {
29 rest.find("<meta name=\"twitter:")
30 .or_else(|| rest.find("<meta name=\"robots\""))
31 .or_else(|| rest.find("<link rel=\"canonical\""))
32 }) {
33 out.push_str(&rest[..start]);
34 let end = rest[start..].find('>').expect("a tag closes") + start + 1;
35 rest = &rest[end..];
36 }
37 out.push_str(rest);
38 out
39 }
40
41 /// A band with every member in it, for the corpus the class checks read.
42 ///
43 /// Every class this crate emits has to be reachable from that corpus or the
44 /// second of the two checks reads the name as dead and fails; a band with a
45 /// brand, a search box and a disclosure is what reaches all six of the band's.
46 fn banded() -> quasi_router::Band {
47 quasi_router::Band::new()
48 .branded(quasi_router::Brand::new("Makenot.work", Action::get("/")).marking("."))
49 .searching(Field::new(layout::FieldKind::Text, "q", "Search"))
50 .disclosing(quasi_router::Disclose::Narrow)
51 }
52
53 fn render(screen: &Screen) -> String {
54 Webview::new().screen(screen)
55 }
56
57 fn fragment(node: &Node) -> String {
58 Webview::new().fragment(node)
59 }
60
61 /// One of every `Node` member, in declaration order.
62 ///
63 /// Kept as a function so more than one test can walk it, and it has to stay
64 /// complete: `Node` is `#[non_exhaustive]`, so a member added upstream lands on
65 /// a catch-all arm and compiles. This list plus the count below is what says
66 /// `node_html` has learned the member rather than merely accepting it.
67 fn one_of_everything() -> Vec<Node> {
68 vec![
69 Node::page("Tasks"),
70 Node::text("plain"),
71 Node::rich("**bold** and `code`"),
72 Node::Act(Act::new("Save", Action::post("/save"))),
73 Node::Link {
74 text: "Docs".to_owned(),
75 action: Action::get("/docs"),
76 },
77 Node::Figure(Figure::new("17", "Streak")),
78 Node::since(std::time::SystemTime::UNIX_EPOCH),
79 Node::until(std::time::SystemTime::UNIX_EPOCH),
80 Node::age(std::time::SystemTime::UNIX_EPOCH),
81 Node::Image(quasi_router::Image::new("/cover.png", "The library view")),
82 Node::Token(Tag::badge("beta")),
83 Node::banner(layout::Tone::Info, "Saved"),
84 Node::empty("Nothing here yet"),
85 Node::Field(Box::new(Field::new(
86 layout::FieldKind::Text,
87 "title",
88 "Title",
89 ))),
90 Node::Form {
91 marks: ::quasi_router::stage::Marks::none(),
92 action: Action::post("/save"),
93 submit: "Save".to_owned(),
94 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
95 },
96 Node::list([Row::new("One")]),
97 Node::Table {
98 marks: ::quasi_router::stage::Marks::none(),
99 columns: vec![Column::new("Name")],
100 rows: vec![Row::cells([Cell::new("One")])],
101 more: None,
102 },
103 Node::Timeline {
104 marks: ::quasi_router::stage::Marks::none(),
105 track: layout::Track::DAY,
106 entries: vec![quasi_router::Placed::new(540, 45, Row::new("Standup"))],
107 focus: Some(540),
108 },
109 Node::Meter(Meter::new(3, 6)),
110 Node::stats([Figure::new("17", "Streak")]),
111 Node::Region(Slot::new("nested", RegionKind::Pane)),
112 ]
113 }
114
115 #[test]
116 fn every_described_node_emits_markup() {
117 // One of everything, through `node_html`. A member the walk does not know
118 // reaches a catch-all rather than a compile error, so an empty fragment is
119 // what silence looks like here and this is what objects to it.
120 for node in one_of_everything() {
121 let html = fragment(&node);
122 assert!(!html.is_empty(), "nothing came out for {node:?}");
123 }
124 }
125
126 #[test]
127 fn the_exhaustiveness_list_holds_one_of_every_member() {
128 // A count rather than a comment. `Node` cannot be iterated, so nothing but
129 // this stops the list above going stale while the walk keeps compiling.
130 assert_eq!(
131 one_of_everything().len(),
132 21,
133 "one of every `Node` member, in declaration order"
134 );
135 }
136
137 #[test]
138 fn a_screen_is_a_whole_document() {
139 let html = render(&Screen::list_detail("Tasks", false));
140 assert!(html.starts_with("<!doctype html><html lang=\"en\">"));
141 assert!(html.contains("<title>Tasks</title>"));
142 assert!(html.ends_with("</body></html>"));
143 }
144
145 #[test]
146 fn a_fragment_is_not() {
147 let html = fragment(&Node::text("hello"));
148 assert!(!html.contains("<html"));
149 assert!(!html.contains("<body"));
150 assert_eq!(html, "<p class=\"text\">hello</p>");
151 }
152
153 #[test]
154 fn the_document_configures_nothing_about_how_a_status_swaps() {
155 // Decision 9's gap, closed by the transport rather than by the document.
156 // htmx 4 swaps every status but 204 and 304, so the classified 403 and 404
157 // reach the screen with no `htmx-config` meta tag emitted for them -- the
158 // tag 2.x needed, and the reason `responseHandling` was a required piece of
159 // this adapter until 2026-08-18.
160 let html = render(&Screen::list_detail("Tasks", false));
161 assert!(!html.contains("htmx-config"), "{html}");
162 assert!(!html.contains("responseHandling"), "{html}");
163 }
164
165 #[test]
166 fn the_title_is_escaped_into_the_head() {
167 let html = render(&Screen::list_detail("</title><script>x()</script>", false));
168 assert!(!html.contains("<script>x()"));
169 assert!(html.contains("&lt;/title&gt;"));
170 }
171
172 #[test]
173 fn a_control_asks_for_the_morph_swap_htmx_ships() {
174 // Decision 7's slack, and it no longer depends on anything being loaded:
175 // `outerMorph` is a swap style in htmx 4, where 2.x needed idiomorph and
176 // fell back to a destructive `innerHTML` in silence when it was missing.
177 // So the extension, the `hx-ext` registration and the shell switch that
178 // guarded them are all gone, and the swap is unconditional.
179 let screen = Screen::list_detail("Tasks", false)
180 .with(Slot::new("main", RegionKind::Pane).with(Node::act("Go", Action::get("/go"))));
181 let html = render(&screen);
182 assert!(html.contains("hx-swap=\"outerMorph\""), "{html}");
183 assert!(!html.contains("hx-ext"), "{html}");
184 assert!(!html.contains("idiomorph"), "{html}");
185 }
186
187 #[test]
188 fn an_action_becomes_the_verb_it_names() {
189 let get = fragment(&Node::act("Open", Action::get("/tasks/1")));
190 assert!(get.contains("hx-get=\"/tasks/1\""));
191 assert!(!get.contains("hx-post"));
192
193 let post = fragment(&Node::act("Delete", Action::post("/tasks/1/delete")));
194 assert!(post.contains("hx-post=\"/tasks/1/delete\""));
195 assert!(!post.contains("hx-get"));
196 }
197
198 #[test]
199 fn a_delete_and_a_put_reach_the_route_the_server_actually_answers() {
200 // `61e1b069`. Every described write was a POST, so a REST-shaped route was
201 // unaddressable, and the first tab described worked around it by posting to
202 // a `/delete` path that was never registered: the button rendered and
203 // answered 404. 53 sites across 34 of MNW's templates use one of these.
204 let removed = fragment(&Node::act("Delete", Action::delete("/api/blog/7")));
205 assert!(removed.contains("hx-delete=\"/api/blog/7\""), "{removed}");
206
207 let replaced = fragment(&Node::act("Move", Action::put("/api/items/7/move")));
208 assert!(
209 replaced.contains("hx-put=\"/api/items/7/move\""),
210 "{replaced}"
211 );
212
213 // Both mutate, so both are buttons. An anchor is something a browser may
214 // prefetch and a crawler will follow, and neither is allowed to delete.
215 for html in [&removed, &replaced] {
216 assert!(html.contains("<button"), "{html}");
217 assert!(!html.contains("href"), "{html}");
218 }
219 }
220
221 #[test]
222 fn a_read_of_a_route_is_a_link_and_a_write_is_not() {
223 // A GET to a route this app answers is a link in every host, and it was a
224 // `<button hx-get>` until 2026-08-10: a control that only worked once
225 // JavaScript had run, with no middle-click, no copy-link and nothing for a
226 // crawler. The anchor costs the webview host nothing, because htmx still
227 // has its own attributes and prevents the default.
228 let read = fragment(&Node::act("Open", Action::get("/tasks/1")));
229 assert!(read.contains("<a "));
230 assert!(read.contains("href=\"/tasks/1\""));
231 assert!(read.contains("hx-get=\"/tasks/1\""));
232 assert!(!read.contains("<button"));
233
234 // The other half, and the reason this is not simply "GET is an anchor
235 // everywhere": a write is never a link however it is spelled. An anchor is
236 // something a browser may prefetch and a crawler will follow, and neither
237 // is allowed to delete a task.
238 let write = fragment(&Node::act("Delete", Action::post("/tasks/1/delete")));
239 assert!(write.contains("<button"));
240 assert!(!write.contains("href"));
241
242 // An external address is still a plain link that leaves, with no transport
243 // on it at all.
244 let away = fragment(&Node::act("Docs", Action::external("https://example.com")));
245 assert!(away.contains("href=\"https://example.com\""));
246 assert!(away.contains("rel=\"noopener noreferrer\""));
247 assert!(!away.contains("hx-get"));
248 }
249
250 #[test]
251 fn a_reads_values_are_its_address_and_nothing_is_sent_beside_it() {
252 let action = Action::get("/tasks")
253 .with("filter", "open")
254 .with("sort", "due");
255 let html = fragment(&Node::act("Filter", action));
256
257 // A read sends nothing: its values say where it is going, so they belong on
258 // the address rather than in a payload htmx would fold in for it.
259 assert!(html.contains("hx-get=\"/tasks?filter=open&amp;sort=due\""));
260 assert!(!html.contains("hx-vals"));
261
262 // The href is the same address for the reader who is not htmx, and it
263 // keeps the parameters: a link to a filtered list that drops the filter is
264 // a different place.
265 assert!(html.contains("href=\"/tasks?filter=open&amp;sort=due\""));
266 }
267
268 #[test]
269 fn a_write_from_a_filtered_list_keeps_the_two_status_values_apart() {
270 // The collision this split exists for. goingson's problems inbox filters on
271 // `status` and writes a `status`; with one bag the two met in an `hx-vals`
272 // object under one key, and dismissing from the Open list answered with the
273 // Dismissed list. The view goes on the address, the payload stays in
274 // `hx-vals`, and neither can reach the other.
275 let action = Action::post("/problems/7/status")
276 .with("status", "Dismissed")
277 .carrying("status", "Open");
278 let html = fragment(&Node::act("Dismiss", action));
279
280 assert!(html.contains("hx-post=\"/problems/7/status?status=Open\""));
281 assert!(html.contains("hx-vals=\"{&quot;status&quot;:&quot;Dismissed&quot;}\""));
282
283 // A write is not a link, so it gets no href to middle-click into.
284 assert!(!html.contains("href="));
285 }
286
287 #[test]
288 fn a_repeated_name_survives_the_wire_rather_than_collapsing() {
289 // `Params::get_all` promises repeats, and an `hx-vals` object literal could
290 // not keep that promise: two entries under one name emitted a duplicate JSON
291 // key and every parser kept the last. A query string repeats a name happily,
292 // which is the other half of why the view travels on the address.
293 let action = Action::get("/tags")
294 .with("tag", "rust")
295 .with("tag", "router");
296 let html = fragment(&Node::act("Both", action));
297
298 assert!(html.contains("hx-get=\"/tags?tag=rust&amp;tag=router\""));
299 }
300
301 #[test]
302 fn a_param_value_cannot_escape_the_attribute_or_the_json() {
303 let action = Action::post("/search").with("q", "\" onload=\"steal()");
304 let html = fragment(&Node::act("Search", action));
305
306 assert!(!html.contains("onload=\"steal()"));
307 assert!(html.contains("\\&quot;"));
308
309 let action = Action::post("/search").with("q", "a\\b\nc");
310 let html = fragment(&Node::act("Search", action));
311 assert!(html.contains("a\\\\b\\nc"));
312 }
313
314 #[test]
315 fn no_control_ever_names_its_own_target() {
316 // Decision 7: the response says what it replaces, through HX-Retarget,
317 // because the router is the only party that knows what it just changed.
318 let screen = Screen::list_detail("Tasks", false)
319 .with(
320 Slot::new("list", RegionKind::Pane)
321 .with(Node::list([
322 Row::new("One").activate(Action::get("/tasks/1"))
323 ]))
324 .with(Node::act("New", Action::post("/tasks"))),
325 )
326 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")));
327
328 let html = render(&screen);
329 assert!(!html.contains("hx-target"));
330 assert!(!html.contains("hx-retarget"));
331 }
332
333 /// Code lines naming htmx, ignoring doc comments and ordinary comments.
334 ///
335 /// The comments are where the transport is *explained*, and there are more of
336 /// those than there are emissions. Counting them would make this test pass by
337 /// being talked at.
338 fn htmx_lines(source: &str) -> usize {
339 source
340 .lines()
341 .filter(|line| !line.trim_start().starts_with("//"))
342 .filter(|line| line.contains("hx-"))
343 .count()
344 }
345
346 /// One function's body: from its signature to the next item's doc comment.
347 fn body_of<'a>(source: &'a str, signature: &str) -> &'a str {
348 source
349 .split(signature)
350 .nth(1)
351 .unwrap_or_else(|| panic!("{signature} exists"))
352 .split("\n/// ")
353 .next()
354 .expect("the function has a body")
355 }
356
357 #[test]
358 fn htmx_enters_in_exactly_three_functions() {
359 // The check on decision 13's claim that the transport is replaceable. If
360 // this fails, `hx-` has leaked out of the transport seam and swapping htmx
361 // for fixi stopped being a bounded change.
362 //
363 // Two functions rather than one since 2026-08-12, when a response gained
364 // the slots it invalidates: `action_attrs` says where a control sends and
365 // where its answer lands, `oob_html` says where a piece of the answer
366 // lands that no control asked for. Both are the same fact and both move
367 // together, so the claim is unchanged and only the count is.
368 //
369 // Three since `a135f898`, when a field gained the ability to say its value
370 // is the reader's: `preserve_attr` says what the answer may not touch on
371 // the way in. A third reading of one fact rather than a fourth thing, and
372 // all three move together when the transport does.
373 //
374 // It also stopped matching on `" hx-`. That pattern wanted a quote and a
375 // space before the attribute, which is what an attribute appended to an
376 // open tag looks like — and `oob_html` opens the tag itself, so the leak
377 // this test exists to catch would have gone straight past it.
378 let source = include_str!("node.rs");
379 let seam = [
380 "pub(crate) fn action_attrs",
381 "pub(crate) fn oob_html",
382 "fn preserve_attr",
383 ]
384 .iter()
385 .map(|signature| htmx_lines(body_of(source, signature)))
386 .sum::<usize>();
387
388 assert_eq!(
389 htmx_lines(source),
390 seam,
391 "every emitted hx- attribute should come from action_attrs, oob_html or \
392 preserve_attr"
393 );
394 assert!(seam >= 4, "verb, vals, swap and the out-of-band address");
395 }
396
397 #[test]
398 fn a_disabled_control_carries_no_address() {
399 let act = Act::new("Delete", Action::post("/tasks/1/delete")).disabled();
400 let html = fragment(&Node::Act(act));
401
402 assert!(html.contains("disabled"));
403 assert!(!html.contains("hx-post"));
404 }
405
406 #[test]
407 fn a_row_is_a_control_only_when_selecting_it_does_something() {
408 // Opening a row is a read of a route, so it is a link and has an address.
409 let live = fragment(&Node::list([Row::new("One").activate(Action::get("/1"))]));
410 assert!(live.contains("<a class=\"row-activate\""));
411 assert!(live.contains("href=\"/1\""));
412
413 let inert = fragment(&Node::list([Row::new("One")]));
414 assert!(!inert.contains("<button"));
415 assert!(!inert.contains("<a "));
416 assert!(inert.contains("<span class=\"row-primary\">One</span>"));
417 }
418
419 #[test]
420 fn the_current_row_says_so_to_a_screen_reader() {
421 // This field was called `selected` until 2026-08-08 and this test asserted
422 // both meanings at once, because there was only one field to assert. It is
423 // the app's own pointer: what the detail pane is showing.
424 let html = fragment(&Node::list([Row {
425 current: true,
426 ..Row::new("One")
427 }]));
428 assert!(html.contains("aria-current=\"true\""));
429 assert!(html.contains("row-current"));
430 // Not a tick. Nothing here is selectable, so no checkbox.
431 assert!(!html.contains("type=\"checkbox\""));
432 }
433
434 #[test]
435 fn a_flat_row_says_nothing_about_an_outline() {
436 // `ccaa7e4b`. Every list described before these members existed emits the
437 // markup it emitted, byte for byte: no indent property, no chevron, no
438 // hidden rows.
439 let html = fragment(&Node::list([Row::new("One"), Row::new("Two")]));
440 assert!(!html.contains("--row-depth"), "{html}");
441 assert!(!html.contains("data-disclose"), "{html}");
442 assert!(!html.contains(" hidden"), "{html}");
443 assert!(!html.contains("row-nested"), "{html}");
444 }
445
446 #[test]
447 fn a_nested_row_carries_its_depth_as_a_property_the_stylesheet_reads() {
448 // The indent is a number and the step is a size, which this crate does not
449 // name. `--track-at`'s arrangement: the rule stays static and the data
450 // moves.
451 let html = fragment(&Node::list([
452 Row::new("drums"),
453 Row::new("drums.kick").depth(quasi_router::layout::Nesting::at(1)),
454 ]));
455 assert!(html.contains("data-depth=\"1\""), "{html}");
456 assert!(html.contains("style=\"--row-depth:1\""), "{html}");
457 assert!(html.contains("aria-level=\"2\""), "{html}");
458 assert!(html.contains("row-nested"), "{html}");
459 }
460
461 #[test]
462 fn a_branch_draws_a_chevron_beside_the_label_rather_than_on_it() {
463 // `Row::open`'s instruction and the shipped egui sidebar's own behaviour:
464 // pressing a tag filters by it, pressing its chevron does not. So the
465 // chevron is its own control and carries no route at all.
466 let html = fragment(&Node::list([Row::new("drums")
467 .disclosing(true)
468 .activate(Action::get("/tags/drums"))]));
469 assert!(html.contains("data-disclose"), "{html}");
470 assert!(html.contains("aria-expanded=\"true\""), "{html}");
471 assert!(html.contains("aria-label=\"Collapse\""), "{html}");
472 assert!(html.contains("row-branch"), "{html}");
473 // The route is the row's and the chevron carries none: it appears in the
474 // anchor's `href` and its `hx-get` and nowhere else.
475 assert_eq!(html.matches("/tags/drums").count(), 2, "{html}");
476 let chevron = html.find("data-disclose").expect("the chevron");
477 let ends = html[chevron..]
478 .find("</button>")
479 .expect("the chevron closes");
480 assert!(!html[chevron..chevron + ends].contains("hx-"), "{html}");
481 }
482
483 #[test]
484 fn a_shut_branch_hides_what_is_under_it_and_keeps_it_in_the_document() {
485 // Which is what lets `outline.js` open it again without asking the app for
486 // rows the page is already holding.
487 let html = fragment(&Node::list([
488 Row::new("drums").disclosing(false),
489 Row::new("drums.kick").depth(quasi_router::layout::Nesting::at(1)),
490 Row::new("genre"),
491 ]));
492 assert!(html.contains("drums.kick"), "{html}");
493 assert!(html.contains("aria-expanded=\"false\""), "{html}");
494 assert!(html.contains("aria-label=\"Expand\""), "{html}");
495 assert_eq!(html.matches(" hidden").count(), 1, "{html}");
496 // The row after the branch is not under it and is drawn.
497 let genre = html.find("genre").expect("the third row");
498 assert!(!html[..genre].ends_with(" hidden>"), "{html}");
499 }
500
501 #[test]
502 fn a_table_says_the_outline_the_way_a_list_does() {
503 let html = fragment(&Node::Table {
504 marks: ::quasi_router::stage::Marks::none(),
505 columns: vec![Column::new("Name")],
506 rows: vec![
507 Row::cells(["kit"]).disclosing(false),
508 Row::cells(["kit/ride.wav"]).depth(quasi_router::layout::Nesting::at(1)),
509 ],
510 more: None,
511 });
512 assert!(html.contains("table-disclose"), "{html}");
513 assert!(html.contains("table-disclose-head"), "{html}");
514 assert!(html.contains("style=\"--row-depth:1\""), "{html}");
515 assert_eq!(html.matches(" hidden").count(), 1, "{html}");
516 }
517
518 #[test]
519 fn a_table_with_no_branch_grows_no_gutter_for_one() {
520 let html = fragment(&Node::Table {
521 marks: ::quasi_router::stage::Marks::none(),
522 columns: vec![Column::new("Name")],
523 rows: vec![Row::cells(["kick.wav"])],
524 more: None,
525 });
526 assert!(!html.contains("table-disclose"), "{html}");
527 }
528
529 #[test]
530 fn a_selectable_row_gets_a_real_checkbox_and_an_unselectable_one_gets_nothing() {
531 // The other half of the split. `selected` is now the user's tick, and
532 // `Option` is what tells "not ticked" from "not tickable" -- the ambiguity
533 // that made goingson's bulk-selection checkbox undescribable.
534 let untickable = fragment(&Node::list([Row::new("One")]));
535 assert!(!untickable.contains("type=\"checkbox\""));
536
537 let unticked = fragment(&Node::list([Row::new("One").selectable(false)]));
538 assert!(unticked.contains("type=\"checkbox\""));
539 assert!(!unticked.contains(" checked"));
540 assert!(!unticked.contains("row-selected"));
541
542 let ticked = fragment(&Node::list([Row::new("One").selectable(true)]));
543 assert!(ticked.contains("type=\"checkbox\""));
544 assert!(ticked.contains(" checked"));
545 assert!(ticked.contains("row-selected"));
546 // A tick is not the app's pointer, so it claims no `aria-current`.
547 assert!(!ticked.contains("aria-current"));
548 }
549
550 #[test]
551 fn a_row_carries_its_tokens_as_tokens_rather_than_as_joined_text() {
552 // What makeover-layout 0.9.0's `RowPart::Tokens` was added for. Both
553 // goingson ports had to join two trailing facts into `meta` and lost what
554 // the second one was; here the tone survives to the markup.
555 let html = fragment(&Node::list([Row::new("Mine")
556 .meta("3 files")
557 .token(Tag::badge("Side Project"))
558 .token(Tag::badge("On Hold").tone(layout::Tone::Warning))]));
559
560 assert!(html.contains("class=\"row-tokens\""));
561 assert!(html.contains("On Hold"));
562 // The thing the join could not keep.
563 assert!(html.contains("warning"));
564 // And the plain fact stays a plain fact rather than becoming a badge.
565 assert!(html.contains("class=\"row-meta\">3 files</span>"));
566 }
567
568 #[test]
569 fn an_external_destination_is_an_anchor_and_never_a_route() {
570 // The contacts screen's social handles. A button that navigates away lies
571 // to middle-click and to a screen reader, so the element changes with the
572 // destination and not just the attributes.
573 let html = fragment(&Node::act(
574 "Profile",
575 Action::external("https://example.com/@ada"),
576 ));
577
578 assert!(html.contains("<a "));
579 assert!(html.contains("href=\"https://example.com/@ada\""));
580 assert!(html.contains("rel=\"noopener noreferrer\""));
581 // Nothing here is htmx's business: no route is called and nothing swaps.
582 assert!(!html.contains("hx-get"));
583 assert!(!html.contains("hx-post"));
584 assert!(!html.contains("<button"));
585 }
586
587 #[test]
588 fn a_local_destination_asks_nothing_and_goes_nowhere() {
589 // The hybrid renderer's half of `210574ca`. Every other branch of
590 // `action_attrs` builds an address; this one must not, because there is
591 // none and `hx-get=""` is htmx for "ask the page you are on".
592 let html = fragment(&Node::act("Dismiss", Action::local().with("id", "7")));
593
594 assert!(html.contains("data-local"));
595 // A button, not an anchor: nowhere to go means nothing to middle-click.
596 assert!(html.contains("<button"));
597 assert!(!html.contains("<a "));
598 assert!(!html.contains("href"));
599 // The whole of what must never appear. An empty verb is the failure this
600 // branch exists for, so it is asserted as a substring rather than by
601 // looking for a well-formed one.
602 assert!(!html.contains("hx-get"));
603 assert!(!html.contains("hx-post"));
604 // The payload the behaviour acts on rides along; the description said it
605 // and the markup has to carry it.
606 assert!(html.contains("data-vals"));
607 assert!(html.contains("id"));
608 }
609
610 #[test]
611 fn an_external_url_cannot_break_out_of_its_own_attribute() {
612 let html = fragment(&Node::act(
613 "Profile",
614 Action::external("\"><script>alert(1)</script>"),
615 ));
616 assert!(!html.contains("<script>"));
617 }
618
619 #[test]
620 fn a_meter_renders_through_makeover_and_not_through_a_second_emitter() {
621 // The rule this crate is held to: the markup and the CSS that has to match
622 // it are both makeover-webview's. If this ever diverges, the trough and its
623 // fill stop agreeing about which classes exist.
624 let html = fragment(&Node::Meter(
625 Meter::new(3, 7)
626 .tone(layout::Tone::Success)
627 .label("subtasks"),
628 ));
629 assert_eq!(
630 html,
631 makeover_webview::meter::meter_html(
632 &layout::Meter::new(3, 7)
633 .tone(layout::Tone::Success)
634 .label("subtasks"),
635 &Emit::default(),
636 )
637 );
638 assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
639 }
640
641 #[test]
642 fn a_link_and_a_figure_in_a_row_cost_no_release() {
643 // The two gaps the enumeration left open on the row side. Each was a
644 // `RowPart` variant, a member on `Row`, a renderer arm and a
645 // makeover-layout release away, which is what put the third of the seven
646 // matrix pairings out of reach. Under the run they are leaves, and a leaf
647 // in a run is already sayable.
648 let html = fragment(&Node::list([Row::new("Invoice 41")
649 .part(
650 layout::RowPart::Meta,
651 Node::Link {
652 text: "makenot.work".into(),
653 action: Action::get("/sites/1"),
654 },
655 )
656 .part(
657 layout::RowPart::Tokens,
658 Node::Figure(Figure::new("41", "days")),
659 )]));
660
661 assert!(html.contains("makenot.work"), "{html}");
662 assert!(html.contains("/sites/1"), "{html}");
663 assert!(html.contains("41"), "{html}");
664 // Each takes the class of the role it was given, and the leaves inside keep
665 // makeover's own classes for what they are.
666 assert!(html.contains("class=\"row-meta\""), "{html}");
667 assert!(html.contains("class=\"row-tokens\""), "{html}");
668 }
669
670 #[test]
671 #[should_panic(expected = "a row is an inline run and holds leaves")]
672 fn a_row_refuses_a_block() {
673 // The bound, at the call site. A row is drawable on one wrapped line, and a
674 // list inside one is the door the 2026-08-08 rider was trying to shut with
675 // a doc comment. This is the same rule, checked.
676 let _ = Row::new("Ship it").part(
677 layout::RowPart::Meta,
678 Node::Table {
679 marks: ::quasi_router::stage::Marks::none(),
680 columns: Vec::new(),
681 rows: Vec::new(),
682 more: None,
683 },
684 );
685 }
686
687 #[test]
688 fn a_rows_parts_draw_in_the_order_the_description_says_them() {
689 // The members were drawn in a fixed sequence whatever order they were built
690 // in, so a row wanting a tag between two facts got the tag hoisted to the
691 // end. The run says the order itself.
692 let html = fragment(&Node::list([Row::new("Ship it")
693 .token(Tag::badge("beta"))
694 .meta("2 files")]));
695
696 let tokens = html.find("row-tokens").expect("a tokens strip");
697 let meta = html.find("row-meta").expect("a meta part");
698 assert!(tokens < meta, "{html}");
699 }
700
701 #[test]
702 fn consecutive_parts_of_one_role_share_a_strip() {
703 // Two badges are a strip and not two unrelated spans: the part class
704 // carries the gap between siblings, which is the same reason a cell's
705 // tokens group. Two badges with a fact between them are two strips, and
706 // that is the description saying so.
707 let html = fragment(&Node::list([Row::new("Ship it")
708 .token(Tag::badge("beta"))
709 .token(Tag::badge("draft"))]));
710
711 assert_eq!(html.matches("row-tokens").count(), 1, "{html}");
712 assert!(html.contains("beta"), "{html}");
713 assert!(html.contains("draft"), "{html}");
714 }
715
716 #[test]
717 fn a_relaxed_part_carries_the_clamp_and_a_tight_one_carries_nothing() {
718 // The class is makeover's, not this renderer's: the rule lives in the
719 // generated sheet and `flow_class` names it, which is what keeps a
720 // description's two lines and a stylesheet's two lines the same two lines.
721 let relaxed = fragment(&Node::list([
722 Row::new("A headline long enough to wrap").relaxed()
723 ]));
724 assert!(relaxed.contains("row-relaxed"), "{relaxed}");
725
726 let tight = fragment(&Node::list([Row::new("A headline long enough to wrap")]));
727 assert!(!tight.contains("row-relaxed"), "{tight}");
728 }
729
730 #[test]
731 fn relaxing_applies_to_the_part_that_was_just_added() {
732 // BB's case, which is the reason flow is per part: the title clamps and the
733 // excerpt under it does not. A row-wide setting would have been wrong at
734 // the only site that asked for one.
735 let html = fragment(&Node::list([Row::new("Title")
736 .relaxed()
737 .secondary("Excerpt")]));
738
739 let primary = html.find("row-primary").expect("a primary");
740 let secondary = html.find("row-secondary").expect("a secondary");
741 let clamp = html.find("row-relaxed").expect("a clamp");
742 assert!(primary < clamp && clamp < secondary, "{html}");
743 }
744
745 #[test]
746 fn parts_sharing_a_role_but_not_a_flow_do_not_share_a_span() {
747 // The span carries the clamp, so grouping on role alone would put a tight
748 // part inside a clamped box and count its lines against a budget it never
749 // asked for.
750 let html = fragment(&Node::list([Row::new("Title")
751 .part(layout::RowPart::Secondary, Node::text("Clamped"))
752 .relaxed()
753 .part(layout::RowPart::Secondary, Node::text("Loose"))]));
754 assert_eq!(html.matches("row-secondary").count(), 2, "{html}");
755 assert_eq!(html.matches("row-relaxed").count(), 1, "{html}");
756 }
757
758 #[test]
759 fn a_proportion_in_a_row_is_a_bar_and_not_flattened_text() {
760 // `da5666ae`. `Meter` closed two of its seven sites at 0.10.0 and could not
761 // reach the five that sit in rows, because a row held no nodes. The row
762 // carried the description of a bar instead; under the run it carries the
763 // meter as the leaf it always was, and `Row::meter` still builds it.
764 let html = fragment(&Node::list([
765 Row::new("Ship it").meter(Meter::new(3, 7).label("subtasks"))
766 ]));
767
768 assert!(html.contains("class=\"row-proportion\""));
769 assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
770 // The markup is makeover-webview's, so the trough matches the CSS emitted
771 // for it. A second emitter here is what this crate does not do.
772 assert!(html.contains(&makeover_webview::meter::meter_html(
773 &layout::Meter::new(3, 7).label("subtasks"),
774 &Emit::default(),
775 )));
776 }
777
778 #[test]
779 fn a_strip_of_figures_is_one_node_because_a_renderer_cannot_infer_a_set() {
780 // `93c6a174`. The port had been making each of these out of a `Row` with
781 // the caption as `primary` and the figure as `meta`, which reads backwards.
782 let html = fragment(&Node::stats([
783 Figure::new("17", "Current Streak"),
784 Figure::new("84%", "Completion Rate").tone(layout::Tone::Success),
785 ]));
786
787 assert!(html.starts_with("<div class=\"figures\">"));
788 assert!(html.contains(r#"aria-label="Current Streak: 17""#));
789 assert!(html.contains(r#"data-tone="success""#));
790 // Nothing answers a click here, so nothing is a control.
791 assert!(!html.contains("<button"));
792 }
793
794 #[test]
795 fn a_figure_whose_value_is_a_control_becomes_one_without_a_second_emitter() {
796 // The consumer no candidate shape accounted for: sync's "Not Applied: 3"
797 // opens the list. makeover-layout cannot name an action, so the address
798 // rides beside the figure here rather than inside it.
799 let html = fragment(&Node::Stats {
800 marks: ::quasi_router::stage::Marks::none(),
801 figures: vec![(
802 Figure::new("3", "Not Applied").tone(layout::Tone::Warning),
803 Some(Action::get("/settings/held")),
804 )],
805 });
806
807 assert!(html.contains("<a class=\"figure-act\""));
808 assert!(html.contains("href=\"/settings/held\""));
809 assert!(html.contains("hx-get=\"/settings/held\""));
810 // And the figure inside it is the same markup an inert one would be.
811 assert!(html.contains(&makeover_webview::figure::figure_html(
812 &layout::Figure::new("3", "Not Applied").tone(layout::Tone::Warning),
813 &Emit::default(),
814 )));
815 }
816
817 #[test]
818 fn a_meter_that_ran_over_says_so_after_the_width_is_clamped() {
819 // The finding this member closed. "45m tracked / 30m est" was heading text
820 // because nothing named a bar; a bar alone would report it as exactly full.
821 let html = fragment(&Node::Meter(
822 Meter::new(45, 30)
823 .tone(layout::Tone::Danger)
824 .label("minutes"),
825 ));
826 assert!(html.contains("width: 100%"));
827 assert!(html.contains(r#"data-over="true""#));
828 assert!(html.contains(r#"aria-label="45 of 30 minutes""#));
829 }
830
831 #[test]
832 fn a_badge_is_not_a_button_and_a_chip_is() {
833 let badge = fragment(&Node::Token(Tag {
834 tone: layout::Tone::Info,
835 action: Some(Action::get("/x")),
836 ..Tag::badge("3")
837 }));
838 // A badge answers no click however it is dressed, so it emits no transport
839 // even when a description hands it an action.
840 assert!(!badge.contains("<button"));
841 assert!(!badge.contains("<a "));
842 assert!(!badge.contains("hx-get"));
843 assert!(!badge.contains("href"));
844
845 let chip = fragment(&Node::Token(Tag::chip("open", Action::get("/x")).latched()));
846 // A read of a route, so the chip is a link and says its state the way a
847 // link says it. aria-pressed is a button's word and means nothing here.
848 assert!(chip.contains("<a "));
849 assert!(chip.contains("aria-current=\"true\""));
850 assert!(!chip.contains("aria-pressed"));
851 assert!(chip.contains("href=\"/x\""));
852 assert!(chip.contains("hx-get=\"/x\""));
853
854 // A chip whose activation writes is still a button, and still says so.
855 let toggle = fragment(&Node::Token(
856 Tag::chip("open", Action::post("/x/toggle")).latched(),
857 ));
858 assert!(toggle.contains("<button"));
859 assert!(toggle.contains("aria-pressed=\"true\""));
860 assert!(!toggle.contains("href"));
861 }
862
863 /// A badge's label is short because a badge is small, and the shipped goingson
864 /// board carried the long form in `title`. This renderer draws it there.
865 #[test]
866 fn a_badge_carries_the_detail_behind_its_label_as_a_title() {
867 let html = fragment(&Node::Token(Tag::badge("Blocked").hinted("3 steps away")));
868
869 assert!(html.contains("title=\"3 steps away\""), "{html}");
870 // Still a badge: the hint is standing help, not an affordance.
871 assert!(!html.contains("<button"), "{html}");
872 assert!(!html.contains("data-act"), "{html}");
873 }
874
875 /// A hint is user-authored text going into an attribute value, so it takes the
876 /// same escaping every other attribute here takes.
877 #[test]
878 fn a_hint_is_escaped_like_every_other_attribute() {
879 let html = fragment(&Node::Token(
880 Tag::badge("Cycle").hinted("remove an edge: a -> b -> \"a\""),
881 ));
882
883 assert!(!html.contains("-> \"a\">"), "{html}");
884 assert!(html.contains("&quot;a&quot;"), "{html}");
885 }
886
887 /// No hint, no attribute. An empty `title` is a tooltip that opens on nothing.
888 #[test]
889 fn a_tag_with_nothing_behind_its_label_emits_no_title() {
890 let html = fragment(&Node::Token(Tag::badge("Blocked")));
891 assert!(!html.contains("title="), "{html}");
892 }
893
894 #[test]
895 fn a_notice_carrying_a_way_back_emits_it_inside_the_notice() {
896 // `bde35298`. This renderer never dropped an undo the way the retained-
897 // screen hosts did -- it draws a message itself and can put a control
898 // beside the text -- but the node grew an act, and a member a renderer
899 // does not emit is a description that draws less than it says.
900 let html = fragment(
901 &Node::toast(layout::Tone::Success, "Deleted")
902 .about(Act::new("Undo", Action::post("/tasks/7/restore"))),
903 );
904
905 assert!(html.contains("Deleted"), "{html}");
906 assert!(html.contains(r#"hx-post="/tasks/7/restore""#), "{html}");
907 // Inside the notice and not after it: a control that scrolled away from
908 // the sentence explaining it is a button with no subject.
909 let opened = html.find("Deleted").expect("the text");
910 let control = html.find("hx-post").expect("the control");
911 let closed = html.rfind("</div>").expect("the notice closes");
912 assert!(opened < control && control < closed, "{html}");
913 }
914
915 #[test]
916 fn a_notice_with_nothing_to_do_about_it_emits_no_control() {
917 let html = fragment(&Node::toast(layout::Tone::Success, "Saved"));
918 assert!(!html.contains("<button"), "{html}");
919 assert!(!html.contains("hx-post"), "{html}");
920 }
921
922 #[test]
923 fn a_control_that_goes_back_carries_the_behaviour_and_no_address() {
924 // `33c27e81`. A browser is the host holding the history here, so this is
925 // the one destination this renderer can perform on its own. Above all: no
926 // address -- `hx-get=""` is what htmx reads as "ask the page you are on",
927 // which is the failure the local branch beside this one exists to prevent.
928 let html = fragment(&Node::Act(Act::new("Close", Action::back())));
929
930 assert!(html.contains("call history.back()"), "{html}");
931 assert!(!html.contains("hx-get"), "{html}");
932 assert!(!html.contains("href"), "{html}");
933 // Not the local mark either: `Local` says no request is made at all, and
934 // going back makes one.
935 assert!(!html.contains("data-local"), "{html}");
936 }
937
938 /// A rule editor's conditions: two slots of one repeating question.
939 ///
940 /// audiofiles' shape, cut down. Each condition is a region of several fields,
941 /// which is what `Repeat` could not say: an `Instance` is one value.
942 fn conditions(standing: usize, least: usize) -> Slot {
943 let mut group = Slot::new("conditions", RegionKind::Group).repeating(
944 Repeating::new(
945 "Condition",
946 Act::new("Add condition", Action::post("/rules/conditions/add")),
947 )
948 .least(least),
949 );
950 for at in 0..standing {
951 group = group.with(Node::Region(
952 Slot::new(format!("condition-{at}"), RegionKind::Group)
953 .with(Node::Field(Box::new(Field::new(
954 layout::FieldKind::Text,
955 format!("value-{at}"),
956 "Value",
957 ))))
958 .removes(Act::new(
959 "Remove condition",
960 Action::post(format!("/rules/conditions/{at}/remove")),
961 )),
962 ));
963 }
964 group
965 }
966
967 #[test]
968 fn the_slots_of_a_repeating_question_are_numbered_for_a_reader() {
969 // `f7abbc08`. One-based, because it is read by a person, and the renderer's
970 // rather than the description's: numbers written into a description go
971 // stale the moment a slot leaves the middle.
972 let html = fragment(&Node::Region(conditions(2, 1)));
973
974 assert!(html.contains("Condition 1"), "{html}");
975 assert!(html.contains("Condition 2"), "{html}");
976 assert!(!html.contains("Condition 0"), "{html}");
977 // Derived as a heading rather than as markup this crate invented, so a
978 // slot's number reads as the same thing a described heading does.
979 let numbered = html.find("Condition 1").expect("the caption");
980 assert!(html[..numbered].ends_with('>'), "{html}");
981 }
982
983 #[test]
984 fn the_floor_disables_the_last_remove_rather_than_the_app_doing_it() {
985 // The done condition of the whole member: "at least one condition" is the
986 // description's now. Drawn dead rather than hidden -- a control that
987 // vanishes at a boundary is one the reader has to discover twice.
988 let alone = fragment(&Node::Region(conditions(1, 1)));
989 assert!(alone.contains("Remove condition"), "{alone}");
990 assert!(alone.contains("disabled"), "{alone}");
991
992 let pair = fragment(&Node::Region(conditions(2, 1)));
993 assert!(!pair.contains("disabled"), "{pair}");
994 }
995
996 #[test]
997 fn the_ceiling_disables_the_add_when_the_group_is_full() {
998 let mut group = conditions(2, 0);
999 group.repeating = Some(Box::new(
1000 Repeating::new(
1001 "Condition",
1002 Act::new("Add condition", Action::post("/rules/conditions/add")),
1003 )
1004 .most(2),
1005 ));
1006 let html = fragment(&Node::Region(group));
1007
1008 assert!(html.contains("Add condition"), "{html}");
1009 assert!(html.contains("disabled"), "{html}");
1010 }
1011
1012 #[test]
1013 fn a_region_that_repeats_nothing_emits_what_it_always_did() {
1014 // The additive claim. Every region written before this member existed says
1015 // nothing about repetition and draws exactly as it did.
1016 let plain = Slot::new("body", RegionKind::Group).with(Node::text("inside"));
1017 let html = fragment(&Node::Region(plain));
1018
1019 assert!(html.contains("inside"), "{html}");
1020 assert!(!html.contains("Add"), "{html}");
1021 }
1022
1023 #[test]
1024 fn a_field_whose_value_is_the_readers_survives_a_redraw() {
1025 // `a135f898`. The measured regression: MNW's tag typeahead sits in a
1026 // sidebar swapped out of band on every filter change, so typing three
1027 // letters and ticking an unrelated facet emptied the box. The server never
1028 // sent that value and cannot send it again.
1029 let html = fragment(&Node::Field(Box::new(
1030 Field::new(layout::FieldKind::Text, "tag", "Tag").keeping_value(),
1031 )));
1032
1033 assert!(html.contains(r#"hx-preserve="true""#), "{html}");
1034 // It works off an id, which makeover writes from the field's name. No id,
1035 // no preservation, and htmx says nothing about it.
1036 assert!(html.contains(r#"id="tag""#), "{html}");
1037 }
1038
1039 #[test]
1040 fn a_field_that_says_nothing_is_replaced_as_it_always_was() {
1041 // The additive claim. Preserving by default would keep a facet control the
1042 // answer legitimately reset, which is why this is said and not inferred.
1043 let html = fragment(&Node::Field(Box::new(Field::new(
1044 layout::FieldKind::Text,
1045 "tag",
1046 "Tag",
1047 ))));
1048 assert!(!html.contains("hx-preserve"), "{html}");
1049 }
1050
1051 #[test]
1052 fn a_box_that_owns_a_list_can_also_keep_what_was_typed_into_it() {
1053 // Two facts about one element, and one attribute slot for both: the
1054 // combobox wiring took it first and this arrived afterwards. MNW's box is
1055 // exactly this case, so the pair has to compose rather than one winning.
1056 let html = fragment(&Node::Field(Box::new(
1057 Field::new(layout::FieldKind::Text, "tag", "Tag")
1058 .suggesting(Consult::new(Action::post("/discover/tags")))
1059 .keeping_value(),
1060 )));
1061
1062 assert!(html.contains(r#"hx-preserve="true""#), "{html}");
1063 assert!(html.contains(r#"role="combobox""#), "{html}");
1064 }
1065
1066 #[test]
1067 fn a_pending_region_says_it_is_waiting() {
1068 let screen =
1069 Screen::list_detail("Tasks", false).with(Slot::new("detail", RegionKind::Pane).pending());
1070 assert!(render(&screen).contains("aria-busy=\"true\""));
1071 }
1072
1073 #[test]
1074 fn a_group_is_the_one_region_that_comes_out_as_a_section() {
1075 // The browser has an element for "these things belong together" and this is
1076 // the renderer reaching for it. Nothing in the description asked for an
1077 // element name; a terminal answers the same intent with a rule.
1078 let screen = Screen::list_detail("Settings", false).with(
1079 Slot::new("body", RegionKind::Pane).with(Node::Region(
1080 Slot::group("appearance")
1081 .with(Node::section("Appearance"))
1082 .with(Node::text("Theme")),
1083 )),
1084 );
1085 let html = render(&screen);
1086
1087 assert!(html.contains("<section id=\"appearance\""), "{html}");
1088 assert!(html.contains("</section>"), "{html}");
1089 assert!(html.contains("region group"), "{html}");
1090
1091 // Only the group. A document of nested sections says less than one that
1092 // names the single thing it means, so the pane around it is still a div.
1093 assert_eq!(html.matches("<section").count(), 1, "{html}");
1094 assert!(html.contains("<div id=\"body\""), "{html}");
1095 }
1096
1097 #[test]
1098 fn a_bespoke_region_is_a_place_and_nothing_else() {
1099 // Decision 4: the renderer hands the space over under the name the app
1100 // chose and never interprets it.
1101 let screen = Screen::list_detail("Tasks", false).with(Slot::handover("player", "media-player"));
1102 let html = render(&screen);
1103
1104 assert!(html.contains("id=\"player\""));
1105 assert!(html.contains("data-bespoke=\"media-player\""));
1106 // Empty. Whatever fills it is the app's, per host.
1107 assert!(html.contains("data-bespoke=\"media-player\"></div>"));
1108 }
1109
1110 #[test]
1111 fn a_bespoke_region_the_host_filled_carries_its_markup() {
1112 // The server case: there is no client moment, so the fill has to be in the
1113 // bytes the browser gets or it is absent from first paint, absent with JS
1114 // off and absent to a crawler.
1115 let screen = Screen::list_detail("Files", false).with(Slot::handover("file", "git-file"));
1116 let html = Webview::new()
1117 .with_fill("file", "<pre class=\"hl\">fn main() {}</pre>")
1118 .screen(&screen);
1119
1120 assert!(
1121 html.contains("<pre class=\"hl\">fn main() {}</pre></div>"),
1122 "{html}"
1123 );
1124 }
1125
1126 #[test]
1127 fn a_bespoke_region_with_no_fill_is_still_empty() {
1128 // What every client host relies on: the div is a place, and a host that
1129 // fills one region has not changed what the others are.
1130 let screen = Screen::list_detail("Files", false)
1131 .with(Slot::handover("file", "git-file"))
1132 .with(Slot::handover("player", "media-player"));
1133 let html = Webview::new()
1134 .with_fill("file", "<pre></pre>")
1135 .screen(&screen);
1136
1137 assert!(
1138 html.contains("data-bespoke=\"media-player\"></div>"),
1139 "{html}"
1140 );
1141 }
1142
1143 #[test]
1144 fn two_regions_sharing_a_name_are_filled_by_id() {
1145 // The re-entrancy case, and the reason fills are keyed by slot id: a page
1146 // of N rows each carrying one shares a single bespoke name.
1147 let screen = Screen::list_detail("Files", false)
1148 .with(Slot::handover("row-1", "diff"))
1149 .with(Slot::handover("row-2", "diff"));
1150 let html = Webview::new()
1151 .with_fill("row-1", "<i>one</i>")
1152 .with_fill("row-2", "<i>two</i>")
1153 .screen(&screen);
1154
1155 assert!(html.contains("id=\"row-1\""), "{html}");
1156 let one = html.find("<i>one</i>").expect("the first row is filled");
1157 let two = html.find("<i>two</i>").expect("the second row is filled");
1158 assert!(one < two);
1159 assert!(html.find("id=\"row-2\"").expect("the second row exists") < two);
1160 }
1161
1162 #[test]
1163 fn a_fill_is_markup_and_is_not_escaped() {
1164 // The one string in this renderer that is not escaped, and the reason it
1165 // is safe: it comes from host code, never from a description. A
1166 // description still cannot produce markup, which is what `Node::Html` was
1167 // refused to protect.
1168 let screen = Screen::list_detail("Files", false).with(Slot::handover("file", "git-file"));
1169 let html = Webview::new()
1170 .with_fill("file", "<span data-x=\"1\">&amp;</span>")
1171 .screen(&screen);
1172
1173 assert!(html.contains("<span data-x=\"1\">&amp;</span>"), "{html}");
1174 assert!(!html.contains("&lt;span"), "{html}");
1175 }
1176
1177 #[test]
1178 fn a_fill_for_a_region_the_screen_lacks_goes_nowhere() {
1179 // Ignored rather than appended somewhere. A renderer built for one screen
1180 // and handed another emits that other screen unchanged.
1181 let screen = Screen::list_detail("Files", false).with(Slot::handover("file", "git-file"));
1182 let plain = render(&screen);
1183 let html = Webview::new()
1184 .with_fill("absent", "<b>stray</b>")
1185 .screen(&screen);
1186
1187 assert!(!html.contains("stray"), "{html}");
1188 assert_eq!(html, plain);
1189 }
1190
1191 #[test]
1192 fn a_fill_is_only_for_a_bespoke_region() {
1193 // A pane's contents are the description's. A host reaching into one is
1194 // reaching past the vocabulary rather than into the space it was given.
1195 let screen = Screen::list_detail("Files", false).with(Slot::new("detail", RegionKind::Pane));
1196 let html = Webview::new()
1197 .with_fill("detail", "<b>stray</b>")
1198 .screen(&screen);
1199
1200 assert!(!html.contains("stray"), "{html}");
1201 }
1202
1203 #[test]
1204 fn a_slot_id_survives_intact_because_a_fragment_is_aimed_at_it() {
1205 let screen = Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar));
1206 assert!(render(&screen).contains("id=\"feed-list\""));
1207 }
1208
1209 #[test]
1210 fn a_modal_says_it_takes_input_until_dismissed() {
1211 let screen = Screen::list_detail("Tasks", false).with(Slot::new("confirm", RegionKind::Modal));
1212 let html = render(&screen);
1213 assert!(html.contains("role=\"dialog\""));
1214 assert!(html.contains("aria-modal=\"true\""));
1215 }
1216
1217 #[test]
1218 fn a_table_addresses_its_cells_by_column_never_by_position() {
1219 let columns = vec![
1220 Column::new("Name").width(layout::Width::Fill),
1221 Column::new("Size").width(layout::Width::Content),
1222 ];
1223 let html = fragment(&Node::Table {
1224 marks: ::quasi_router::stage::Marks::none(),
1225 columns,
1226 rows: vec![Row::cells(["kick.wav", "2.1 MB"])],
1227 more: None,
1228 });
1229
1230 assert!(html.contains("role=\"table\""));
1231 assert!(html.contains("role=\"columnheader\""));
1232 assert!(html.contains("kick.wav"));
1233
1234 // The tracks are the stylesheet's, emitted by `narrowing_css` alongside the
1235 // hiding rules. Inline tracks here would be the widest layout outranking
1236 // the narrow ones from inside the markup.
1237 assert!(!html.contains("grid-template-columns"));
1238 assert!(!html.contains("style="));
1239 }
1240
1241 #[test]
1242 fn a_table_row_says_current_the_same_way_a_list_row_does() {
1243 // The 2026-08-08 rename applied where it was missed. A table row kept
1244 // `selected` while meaning the app's pointer, so the class said one thing
1245 // and the `aria-current` beside it said the other. A table row and a list
1246 // row are the same fact in two arrangements and a stylesheet should not
1247 // have to know which it is reading.
1248 let html = fragment(&Node::Table {
1249 marks: ::quasi_router::stage::Marks::none(),
1250 columns: vec![Column::new("Name").width(layout::Width::Fill)],
1251 rows: vec![Row::cells(["kick.wav"]).current(true)],
1252 more: None,
1253 });
1254 assert!(html.contains("aria-current=\"true\""));
1255 assert!(html.contains("table-row-current"));
1256 assert!(!html.contains("table-row-selected"));
1257
1258 // And no tick, because a table has none to describe.
1259 assert!(!html.contains("type=\"checkbox\""));
1260 }
1261
1262 #[test]
1263 fn a_cell_value_is_text_and_cannot_become_markup() {
1264 let html = fragment(&Node::Table {
1265 marks: ::quasi_router::stage::Marks::none(),
1266 columns: vec![Column::new("Name").width(layout::Width::Fill)],
1267 rows: vec![Row::cells(["<img src=x onerror=alert(1)>"])],
1268 more: None,
1269 });
1270 assert!(!html.contains("<img"));
1271 assert!(html.contains("&lt;img"));
1272 }
1273
1274 #[test]
1275 fn a_table_row_carries_its_controls_in_the_cell_they_belong_to() {
1276 // `022f0c59`. The SSH-keys table: three values and a Remove, which had to be
1277 // described as a list until a cell could hold the button, losing the column
1278 // headers that were the reason it was a table.
1279 let html = fragment(&Node::Table {
1280 marks: ::quasi_router::stage::Marks::none(),
1281 columns: vec![
1282 Column::new("Fingerprint").width(layout::Width::Fill),
1283 Column::new("Label").width(layout::Width::Content),
1284 Column::new("").width(layout::Width::Content),
1285 ],
1286 rows: vec![Row::cells([
1287 Cell::new("SHA256:abc"),
1288 Cell::new("fw13"),
1289 Cell::acts([
1290 Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger)
1291 ]),
1292 ])],
1293 more: None,
1294 });
1295
1296 assert!(html.contains("role=\"table\""));
1297 assert!(html.contains("SHA256:abc"));
1298 assert!(html.contains("hx-post=\"/keys/7/delete\""));
1299 assert!(html.contains("cell-actions"));
1300
1301 // Not `row-actions`, which is a list row's class. A table's actions column
1302 // exists to show the button, and `cell-actions` is the class the generated
1303 // stylesheet gives no colour, so the button is not painted as text.
1304 assert!(!html.contains("row-actions"));
1305
1306 // A cell that is only its value says so on the container, where a wrapper
1307 // span would add an element and no information.
1308 assert!(html.contains("cell-value"), "{html}");
1309 assert!(!html.contains("<span class=\"cell-value\""), "{html}");
1310 }
1311
1312 #[test]
1313 fn a_cell_that_mixes_parts_names_them_inside_rather_than_on_itself() {
1314 // makeover-layout 0.14.0's CellPart, and the reason it is four members
1315 // rather than a flag. A cell holding text AND tokens AND a control is three
1316 // parts in one container: a content colour on the container would reach the
1317 // badge and the button, which is the drift the vocabulary ends.
1318 let html = fragment(&Node::Table {
1319 marks: ::quasi_router::stage::Marks::none(),
1320 columns: vec![Column::new("Item").width(layout::Width::Fill)],
1321 rows: vec![Row::cells([Cell::new("Release notes")
1322 .token(Tag::badge("Published"))
1323 .act(Act::new("Remove", Action::post("/blog/7/delete")))])],
1324 more: None,
1325 });
1326
1327 // Each part named where it is, inside the cell.
1328 assert!(html.contains("<span class=\"cell-value\">"), "{html}");
1329 assert!(html.contains("cell-tokens"), "{html}");
1330 assert!(html.contains("cell-actions"), "{html}");
1331
1332 // And the container claims none of them, or the colour would cascade.
1333 assert!(!html.contains("cell-keeps cell-value"), "{html}");
1334 }
1335
1336 #[test]
1337 fn a_figure_carries_its_delta_through_to_the_strip() {
1338 // makeover-layout 0.13.0. Four of MNW's screens put a label, a value and a
1339 // delta in one stat card, and the delta is the toned part. A description
1340 // that dropped it left the tone with nothing to colour.
1341 let html = fragment(&Node::Stats {
1342 marks: ::quasi_router::stage::Marks::none(),
1343 figures: vec![(
1344 Figure::new("1,204", "Views")
1345 .change("+12.5%")
1346 .tone(layout::Tone::Success),
1347 None,
1348 )],
1349 });
1350
1351 assert!(html.contains("+12.5%"), "{html}");
1352 assert!(html.contains("figure-change"), "{html}");
1353 assert!(html.contains("data-tone=\"success\""), "{html}");
1354 // The accessible name carries it too, since every span in a figure is
1355 // `aria-hidden` and a delta outside the name would reach a reader not at all.
1356 assert!(html.contains("Views: 1,204, +12.5%"), "{html}");
1357
1358 // A figure with nothing to compare against emits no empty delta.
1359 let plain = fragment(&Node::Stats {
1360 marks: ::quasi_router::stage::Marks::none(),
1361 figures: vec![(Figure::new("3.1%", "Conversion"), None)],
1362 });
1363 assert!(!plain.contains("figure-change"), "{plain}");
1364 }
1365
1366 #[test]
1367 fn a_control_calling_an_undescribed_route_can_say_where_the_answer_goes() {
1368 // Decision 7 holds for a described responder and cannot for any other. A
1369 // plain API route answers with a status or a hand-rendered fragment and
1370 // names no region, so without this htmx swaps that answer into the button
1371 // that was pressed. Which is what the MNW server's described Remove button
1372 // did: the delete endpoint answers with the whole re-rendered list, and it
1373 // landed inside the control.
1374 let html = fragment(&Node::list([Row::new("fw13").act(Act::new(
1375 "Remove",
1376 Action::delete("/api/users/me/ssh-keys/7").replacing("ssh-keys-list"),
1377 ))]));
1378
1379 assert!(html.contains("hx-target=\"#ssh-keys-list\""), "{html}");
1380 assert!(
1381 html.contains("hx-delete=\"/api/users/me/ssh-keys/7\""),
1382 "{html}"
1383 );
1384 }
1385
1386 #[test]
1387 fn nothing_else_ever_emits_a_target() {
1388 // The rule the carve-out is carved out of. A control calling a described
1389 // route says nothing about where its answer lands, because the answer says
1390 // so itself.
1391 let html = fragment(&Node::list([Row::new("fw13")
1392 .act(Act::new("Open", Action::get("/keys/7")))
1393 .activate(Action::get("/keys/7"))]));
1394 assert!(!html.contains("hx-target"), "{html}");
1395
1396 let form = fragment(&Node::Form {
1397 marks: ::quasi_router::stage::Marks::none(),
1398 action: Action::post("/keys"),
1399 submit: "Add".into(),
1400 fields: vec![Field::new(layout::FieldKind::Text, "label", "Label")],
1401 });
1402 assert!(!form.contains("hx-target"), "{form}");
1403 }
1404
1405 #[test]
1406 fn a_region_id_cannot_break_out_of_the_target_attribute() {
1407 let html = fragment(&Node::act(
1408 "Remove",
1409 Action::delete("/api/x").replacing("a\" onload=\"x()"),
1410 ));
1411 assert!(!html.contains("\" onload="), "{html}");
1412 }
1413
1414 #[test]
1415 fn a_control_whose_answer_is_a_file_says_so() {
1416 // Nine sites in MNW: five CSV exports across four templates, a sixth in the
1417 // item-sales script, three anchors carrying `download`. Six are writes,
1418 // which is why this is a property of the action and not a kind of link.
1419 //
1420 // A read is the whole job: the browser saves it and the control still works
1421 // with JS off.
1422 let read = fragment(&Node::act(
1423 "Download LICENSE.txt",
1424 Action::get("/api/items/7/license.txt").saving("LICENSE.txt"),
1425 ));
1426 assert!(read.contains("download=\"LICENSE.txt\""), "{read}");
1427 assert!(
1428 read.contains("<a "),
1429 "a read that saves is still a link: {read}"
1430 );
1431
1432 // A write cannot be a link, so the intent is a named hook the host acts on.
1433 let write = fragment(&Node::act(
1434 "Export CSV",
1435 Action::post("/api/export/contacts").saving("contacts.csv"),
1436 ));
1437 assert!(write.contains("data-saves=\"contacts.csv\""), "{write}");
1438 assert!(
1439 write.contains("hx-post=\"/api/export/contacts\""),
1440 "{write}"
1441 );
1442 assert!(
1443 !write.contains("download="),
1444 "a button is not a link: {write}"
1445 );
1446 }
1447
1448 #[test]
1449 fn a_control_that_saves_nothing_says_nothing() {
1450 let plain = fragment(&Node::act("Export", Action::post("/api/export/contacts")));
1451 assert!(!plain.contains("data-saves"), "{plain}");
1452 assert!(!plain.contains("download="), "{plain}");
1453 }
1454
1455 #[test]
1456 fn a_filename_cannot_break_out_of_its_attribute() {
1457 // A filename is chosen by the screen today, but it is a string in an
1458 // attribute and the escaping is not optional for that reason.
1459 let html = fragment(&Node::act(
1460 "Export",
1461 Action::post("/api/export").saving("\" onload=\"x()"),
1462 ));
1463 assert!(!html.contains("\" onload="), "{html}");
1464 }
1465
1466 #[test]
1467 fn a_cell_value_that_is_a_link_is_the_link() {
1468 // 35 cells across 18 of MNW's templates are a title that goes somewhere.
1469 // The value carries the address itself rather than growing an `Edit` button
1470 // beside it, which is what a title column looks like.
1471 let html = fragment(&Node::Table {
1472 marks: ::quasi_router::stage::Marks::none(),
1473 columns: vec![
1474 Column::new("Title").width(layout::Width::Fill),
1475 Column::new("Status").width(layout::Width::Content),
1476 ],
1477 rows: vec![Row::cells([
1478 Cell::new("Release notes").activate(Action::get("/blog/7")),
1479 Cell::tag(Tag::badge("Published")),
1480 ])],
1481 more: None,
1482 });
1483
1484 // A read is an anchor with a real href, so middle-click and copy-link work
1485 // and the page is still navigable with JS off.
1486 assert!(html.contains("href=\"/blog/7\""), "{html}");
1487 // `cell-link`, which is what makeover-layout 0.14.0's `CellPart::Link` is
1488 // spelled as in the generated stylesheet. It was `cell-activate` while this
1489 // crate was inventing the name itself.
1490 assert!(html.contains("cell-link"), "{html}");
1491 assert!(html.contains(">Release notes</a>"), "{html}");
1492
1493 // The value is still text, whatever the value happens to say.
1494 let hostile = fragment(&Node::Table {
1495 marks: ::quasi_router::stage::Marks::none(),
1496 columns: vec![Column::new("Title").width(layout::Width::Fill)],
1497 rows: vec![Row::cells([
1498 Cell::new("<img src=x onerror=alert(1)>").activate(Action::get("/blog/7"))
1499 ])],
1500 more: None,
1501 });
1502 assert!(!hostile.contains("<img"), "{hostile}");
1503 }
1504
1505 #[test]
1506 fn a_linked_value_does_not_also_open_the_row() {
1507 // The same double-fire `Cell::acts` has, through a different member: a click
1508 // on the title would follow the link and swap the row's destination in
1509 // underneath it. The filter keys on `data-act`, so the link carries it.
1510 let html = fragment(&Node::Table {
1511 marks: ::quasi_router::stage::Marks::none(),
1512 columns: vec![Column::new("Title").width(layout::Width::Fill)],
1513 rows: vec![
1514 Row::cells([Cell::new("Release notes").activate(Action::get("/blog/7"))])
1515 .activate(Action::get("/blog/7/edit")),
1516 ],
1517 more: None,
1518 });
1519
1520 assert!(
1521 html.contains("closest("),
1522 "the row filters the link out: {html}"
1523 );
1524 assert!(html.contains("hx-get=\"/blog/7/edit\""), "{html}");
1525 assert!(html.contains("hx-get=\"/blog/7\""), "{html}");
1526 }
1527
1528 #[test]
1529 fn a_control_beside_a_value_does_not_also_open_the_row() {
1530 // The five of MNW's thirty action-bearing rows that put a control next to a
1531 // value rather than alone in the last cell: a position with reorder arrows,
1532 // a slug with "Set slug", a use count that is itself the button. The row is
1533 // openable too, and a click on the button bubbles to it.
1534 let html = fragment(&Node::Table {
1535 marks: ::quasi_router::stage::Marks::none(),
1536 columns: vec![Column::new("Slug").width(layout::Width::Fill)],
1537 rows: vec![
1538 Row::cells([
1539 Cell::new("my-app").act(Act::new("Set slug", Action::post("/apps/3/slug")))
1540 ])
1541 .activate(Action::get("/apps/3")),
1542 ],
1543 more: None,
1544 });
1545
1546 assert!(html.contains("hx-get=\"/apps/3\""));
1547 assert!(html.contains("hx-post=\"/apps/3/slug\""));
1548 assert!(html.contains("data-act"));
1549 assert!(html.contains("closest("));
1550
1551 // A row with no controls in it keeps htmx's bare default, so the filter is
1552 // paid for only where it is needed.
1553 let plain = fragment(&Node::Table {
1554 marks: ::quasi_router::stage::Marks::none(),
1555 columns: vec![Column::new("Slug").width(layout::Width::Fill)],
1556 rows: vec![Row::cells(["my-app"]).activate(Action::get("/apps/3"))],
1557 more: None,
1558 });
1559 assert!(!plain.contains("hx-trigger"));
1560 }
1561
1562 #[test]
1563 fn the_ssh_keys_tab_gaps_stay_shut() {
1564 // The third gap the SSH-keys tab found. A described screen was emitting
1565 // `act`, `tone-danger` and `chip-latched`, none of which makeover has ever
1566 // defined, so a described control rendered as unstyled text next to a
1567 // hand-written one that did not.
1568 let html = fragment(&Node::list([Row::new("fw13").act(
1569 Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger),
1570 )]));
1571
1572 assert!(html.contains("class=\"button\""), "{html}");
1573 assert!(html.contains("data-tone=\"danger\""), "{html}");
1574 assert!(!html.contains("\"act\""), "{html}");
1575 assert!(!html.contains("tone-danger"), "{html}");
1576
1577 let chip = fragment(&Node::Token(
1578 Tag::chip("Open", Action::get("/tasks?open=1")).latched(),
1579 ));
1580 assert!(chip.contains("latched"), "{chip}");
1581 assert!(!chip.contains("chip-latched"), "{chip}");
1582 }
1583
1584 #[test]
1585 fn a_table_heading_drops_with_the_cells_below_it() {
1586 // The header is emitted here rather than by `cells_html`, so it is the one
1587 // place the two class lists could disagree. If they do, a narrow viewport
1588 // drops a column's cells and keeps its heading, and every heading past the
1589 // cut sits over the wrong values.
1590 let html = fragment(&Node::Table {
1591 marks: ::quasi_router::stage::Marks::none(),
1592 columns: vec![
1593 Column::new("Name")
1594 .width(layout::Width::Fill)
1595 .priority(layout::Priority::Essential),
1596 Column::new("Used")
1597 .width(layout::Width::Content)
1598 .priority(layout::Priority::Optional),
1599 ],
1600 rows: vec![Row::cells(["deploy", "Aug 9"])],
1601 more: None,
1602 });
1603
1604 for classes in [
1605 "col-Name cell-fill cell-keeps",
1606 "col-Used cell-content cell-drops-first",
1607 ] {
1608 assert_eq!(
1609 html.matches(classes).count(),
1610 2,
1611 "the heading and its cell must carry the same classes: {html}"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn a_status_column_keeps_its_tone_instead_of_flattening_to_text() {
1618 // 33 table cells across 22 of MNW's templates carry a badge or a chip, more
1619 // sites than the acts earned. A status column is the common one, and it is
1620 // why this cannot be folded into the cell's text: `Refunded` and `Paid` are
1621 // the same string to a renderer and different tones to a reader.
1622 let html = fragment(&Node::Table {
1623 marks: ::quasi_router::stage::Marks::none(),
1624 columns: vec![
1625 Column::new("Amount").width(layout::Width::Content),
1626 Column::new("Status").width(layout::Width::Content),
1627 ],
1628 rows: vec![Row::cells([
1629 Cell::new("$12.00"),
1630 Cell::tag(Tag::badge("Refunded").tone(layout::Tone::Warning)),
1631 ])],
1632 more: None,
1633 });
1634
1635 assert!(html.contains("cell-tokens"), "{html}");
1636 assert!(
1637 html.contains("class=\"badge\" data-tone=\"warning\""),
1638 "{html}"
1639 );
1640 assert!(html.contains("Refunded"), "{html}");
1641 }
1642
1643 #[test]
1644 fn a_meter_and_a_figure_in_a_cell_were_the_two_gaps_the_enumeration_left_open() {
1645 // The whole argument for the containment model in one test. Under the
1646 // per-pairing enumeration these were the two cells of the matrix nobody had
1647 // filled, and each would have cost a member on `Cell`, three renderer arms
1648 // and a release. Under the run they arrive by already being leaves, and the
1649 // renderer draws them through the emitters it already had.
1650 let html = fragment(&Node::Table {
1651 marks: ::quasi_router::stage::Marks::none(),
1652 columns: vec![
1653 Column::new("Task").width(layout::Width::Fill),
1654 Column::new("Done").width(layout::Width::Content),
1655 Column::new("Revenue").width(layout::Width::Content),
1656 ],
1657 rows: vec![Row::cells([
1658 Cell::new("Write the renderer"),
1659 Cell::new("").part(Node::Meter(Meter::new(3, 7))),
1660 Cell::new("").part(Node::Figure(Figure::new("$12.00", "this month"))),
1661 ])],
1662 more: None,
1663 });
1664
1665 assert!(html.contains("progress"), "the meter is drawn: {html}");
1666 assert!(html.contains("figure"), "the figure is drawn: {html}");
1667 assert!(html.contains("$12.00"), "{html}");
1668 }
1669
1670 #[test]
1671 fn a_run_says_its_own_order_rather_than_hoisting_the_tags_to_the_end() {
1672 // What a part per cell could not express. The old renderer emitted the
1673 // value, then every token, then every act, whatever order the description
1674 // put them in, because the cell had one member per kind and members have no
1675 // order between them.
1676 let html = fragment(&Node::Table {
1677 marks: ::quasi_router::stage::Marks::none(),
1678 columns: vec![Column::new("Note").width(layout::Width::Fill)],
1679 rows: vec![Row::cells([Cell::new("shipped")
1680 .token(Tag::badge("beta"))
1681 .part(Node::text("to staging"))])],
1682 more: None,
1683 });
1684
1685 let badge = html.find("beta").expect("the tag is drawn");
1686 let before = html.find("shipped").expect("the first text");
1687 let after = html.find("to staging").expect("the second text");
1688 assert!(before < badge && badge < after, "{html}");
1689 }
1690
1691 #[test]
1692 #[should_panic(expected = "a cell is an inline run and holds leaves")]
1693 fn a_cell_refuses_a_block() {
1694 // The bound, at a call site. A list in a cell is the thing the 2026-08-08
1695 // rider was defending against and never actually checked; here it is a
1696 // panic with the offending node in the message.
1697 let _ = Cell::new("x").part(Node::Table {
1698 marks: ::quasi_router::stage::Marks::none(),
1699 columns: Vec::new(),
1700 rows: Vec::new(),
1701 more: None,
1702 });
1703 }
1704
1705 #[test]
1706 fn a_chip_in_a_cell_is_a_control_and_a_badge_is_not() {
1707 // The bubbling guard has to cover both kinds of control a cell can hold,
1708 // or a chip that answers a click opens the row as well as answering it.
1709 let chip = fragment(&Node::Table {
1710 marks: ::quasi_router::stage::Marks::none(),
1711 columns: vec![Column::new("Tag").width(layout::Width::Content)],
1712 rows: vec![
1713 Row::cells([Cell::tag(Tag::chip("Open", Action::get("/tasks?open=1")))])
1714 .activate(Action::get("/tasks/1")),
1715 ],
1716 more: None,
1717 });
1718 assert!(chip.contains("data-act"), "{chip}");
1719 assert!(chip.contains("closest("), "{chip}");
1720
1721 // A badge answers nothing, so it is not a control and the row keeps htmx's
1722 // bare default.
1723 let badge = fragment(&Node::Table {
1724 marks: ::quasi_router::stage::Marks::none(),
1725 columns: vec![Column::new("Status").width(layout::Width::Content)],
1726 rows: vec![Row::cells([Cell::tag(Tag::badge("Paid"))]).activate(Action::get("/sales/1"))],
1727 more: None,
1728 });
1729 assert!(!badge.contains("data-act"), "{badge}");
1730 assert!(!badge.contains("hx-trigger"), "{badge}");
1731 }
1732
1733 #[test]
1734 fn a_cell_of_plain_text_is_still_a_string() {
1735 // The `From<&str>` that keeps every value-only table unchanged. Without it
1736 // the member would have cost every existing caller a rewrite for a feature
1737 // it does not use.
1738 let cells = Row::cells(["kick.wav", "2.1 MB"]);
1739 // The content is what `From<&str>` produces; the keys are the row's, since
1740 // `Row::cells` answers a column list positionally. Comparing whole cells
1741 // would compare `Cell::new`'s default key against those positions, which
1742 // is the one thing this test is not about.
1743 assert_eq!(
1744 cells
1745 .cells
1746 .iter()
1747 .map(|cell| cell.content.clone())
1748 .collect::<Vec<_>>(),
1749 vec![Cell::new("kick.wav").content, Cell::new("2.1 MB").content]
1750 );
1751 assert_eq!(
1752 cells
1753 .cells
1754 .iter()
1755 .map(|cell| cell.key.clone())
1756 .collect::<Vec<_>>(),
1757 vec![CellKey::Column(0), CellKey::Column(1)]
1758 );
1759 // Each is one piece of text and nothing else, which is what the container
1760 // reads to decide the cell needs no wrapper span.
1761 assert!(
1762 cells
1763 .cells
1764 .iter()
1765 .all(|cell| matches!(cell.content.as_slice(), [Node::Text { .. }]))
1766 );
1767 }
1768
1769 #[test]
1770 fn a_form_borrows_its_fields_rather_than_emitting_them_twice() {
1771 let html = fragment(&Node::Form {
1772 marks: ::quasi_router::stage::Marks::none(),
1773 action: Action::post("/tasks"),
1774 submit: "Save".into(),
1775 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title").required()],
1776 });
1777
1778 assert!(html.contains("hx-post=\"/tasks\""));
1779 assert!(html.contains("name=\"title\""));
1780 assert!(html.contains("required"));
1781 assert!(html.contains("<button type=\"submit\""));
1782 // The anatomy is makeover-webview's, so the label association it emits is
1783 // the one every app already gets.
1784 assert!(html.contains("<label"));
1785 }
1786
1787 #[test]
1788 fn a_units_symbol_reaches_the_renderer_and_the_label_keeps_none() {
1789 // The end of the cascade: `Field::unit` set on the router's own struct,
1790 // borrowed through `with_layout`, drawn by makeover-webview. What this
1791 // covers that the renderer's own tests do not is the mirroring -- the member
1792 // is declared twice, here and in makeover-layout, and a member that stopped
1793 // at the router would be silently absent everywhere.
1794 let html = fragment(&Node::field(
1795 Field::new(layout::FieldKind::Number, "fade", "Fade").unit("ms"),
1796 ));
1797
1798 assert!(html.contains(">ms</span>"), "{html}");
1799 assert!(html.contains("fade-unit"), "{html}");
1800 assert!(html.contains(">Fade</label>"), "{html}");
1801 }
1802
1803 #[test]
1804 fn a_theme_picker_reaches_the_renderer_grouped_and_marked() {
1805 // The end of the cascade for the theme picker: `themes` and `follows` are
1806 // declared twice, here and in makeover-layout, and members that stopped at
1807 // the router would be silently absent everywhere. Same guarantee
1808 // `Field::unit` and the interval pair get above.
1809 let html = fragment(&Node::field(
1810 Field::theme(
1811 "theme",
1812 "Theme",
1813 vec![
1814 ThemeChoice::new(
1815 "goingson",
1816 "GoingsOn",
1817 layout::ThemeVariant::Light,
1818 layout::Contrast::High,
1819 ),
1820 ThemeChoice::new(
1821 "carbonfox",
1822 "Carbonfox",
1823 layout::ThemeVariant::Dark,
1824 layout::Contrast::Standard,
1825 ),
1826 ],
1827 )
1828 .following(Choice::new("system", "Follow System"))
1829 .value("carbonfox"),
1830 ));
1831
1832 assert!(html.contains(r#"<optgroup label="Light""#), "{html}");
1833 assert!(html.contains(r#"<optgroup label="Dark""#), "{html}");
1834 assert!(html.contains("GoingsOn (AA)"), "{html}");
1835 assert!(html.contains("Carbonfox (OK)"), "{html}");
1836 assert!(html.contains("Follow System"), "{html}");
1837 assert!(
1838 html.contains(r#"value="carbonfox" data-contrast="standard" selected"#),
1839 "{html}"
1840 );
1841 }
1842
1843 #[test]
1844 fn an_interval_reaches_the_renderer_as_one_group_with_two_boxes() {
1845 // The end of the cascade for the pair: both names and both values are
1846 // declared twice, here and in makeover-layout, and a member that stopped at
1847 // the router would be silently absent everywhere. Same guarantee
1848 // `Field::unit` gets above.
1849 let html = fragment(&Node::field(
1850 Field {
1851 min: Some("0".into()),
1852 max: Some("300".into()),
1853 ..Field::interval("bpm_min", "bpm_max", "BPM range")
1854 }
1855 .value("90")
1856 .upper_value("130"),
1857 ));
1858
1859 assert!(html.contains("role=\"group\""), "{html}");
1860 assert!(html.contains("name=\"bpm_min\""), "{html}");
1861 assert!(html.contains("name=\"bpm_max\""), "{html}");
1862 assert!(html.contains("value=\"90\""), "{html}");
1863 assert!(html.contains("value=\"130\""), "{html}");
1864 // The extent describes the axis, so both boxes carry it.
1865 assert_eq!(html.matches("max=\"300\"").count(), 2, "{html}");
1866 }
1867
1868 #[test]
1869 fn a_refused_interval_comes_back_with_both_ends_in_it() {
1870 // The half a `pairs_with` member would not have paid for: without a second
1871 // value a refusal hands back the low end and drops the high one, so the
1872 // user retypes half of an answer they already gave.
1873 let params = quasi_router::Params::new()
1874 .with("bpm_min", "90")
1875 .with("bpm_max", "130");
1876 let html = fragment(&Node::Form {
1877 marks: ::quasi_router::stage::Marks::none(),
1878 action: Action::post("/filters"),
1879 submit: "Apply".into(),
1880 fields: vec![Field::interval("bpm_min", "bpm_max", "BPM range").refilled(&params)],
1881 });
1882
1883 assert!(
1884 html.contains("name=\"bpm_min\" aria-label=\"lower\""),
1885 "{html}"
1886 );
1887 assert!(html.contains("value=\"90\""), "{html}");
1888 assert!(html.contains("value=\"130\""), "{html}");
1889 }
1890
1891 #[test]
1892 fn a_control_that_writes_on_change_says_so_without_a_form_around_it() {
1893 // `14612ed8`. goingson reached 13 of these through `dispatch.js`, which
1894 // exists because the description could not say this.
1895 let html = fragment(&Node::field(
1896 Field::select(
1897 "theme",
1898 "Theme",
1899 vec![Choice::new("dark", "Dark"), Choice::new("light", "Light")],
1900 )
1901 .writes(Action::post("/settings/theme")),
1902 ));
1903
1904 assert!(html.contains("hx-post=\"/settings/theme\""));
1905 assert!(html.contains("hx-trigger=\"change\""));
1906 // The value is found back rather than assumed, because the control could
1907 // have been any of three elements.
1908 assert!(html.contains("hx-include="));
1909 assert!(html.contains("name=\"theme\""));
1910 // No form and no submit: that is the whole difference from `Node::Form`.
1911 assert!(!html.contains("<form"));
1912 assert!(!html.contains("type=\"submit\""));
1913 }
1914
1915 /// The default is what it always was, field for field. Every call site written
1916 /// before the axes existed keeps exactly the treatment it had.
1917 #[test]
1918 fn rich_markdown_is_still_strict_when_nothing_says_otherwise() {
1919 let html = fragment(&Node::rich("[docs](/docs) and <b>raw</b>"));
1920
1921 assert!(
1922 html.contains(r#"rel="noopener noreferrer nofollow""#),
1923 "{html}"
1924 );
1925 assert!(!html.contains("<b>raw</b>"), "raw markup survived: {html}");
1926 }
1927
1928 /// The cell the fix exists for: a page's own sentence, whose links are its own
1929 /// and should be followed.
1930 #[test]
1931 fn the_apps_own_prose_does_not_nofollow_its_own_links() {
1932 let html = fragment(&Node::rich("[docs](/docs)").trust(Trust::Trusted));
1933
1934 assert!(html.contains(r#"href="/docs""#), "{html}");
1935 assert!(
1936 !html.contains("nofollow"),
1937 "the app nofollowed itself: {html}"
1938 );
1939 }
1940
1941 /// The two axes are independent, which is the whole point: trusting a source
1942 /// does not hand it tables, and giving a source tables does not stop it being
1943 /// hardened.
1944 #[test]
1945 fn richness_and_trust_move_separately() {
1946 let table = "| a | b |\n| - | - |\n| 1 | 2 |";
1947
1948 // Trusted, but still prose: no table.
1949 let trusted_prose = fragment(&Node::rich(table).trust(Trust::Trusted));
1950 assert!(!trusted_prose.contains("<table"), "{trusted_prose}");
1951
1952 // A document, and still untrusted: the table arrives, and so does the
1953 // hardening. No preset in docengine spelled this cell.
1954 let untrusted_document = fragment(
1955 &Node::rich(format!("{table}\n\n[x](/y) <b>raw</b>")).richness(Richness::Document),
1956 );
1957 assert!(
1958 untrusted_document.contains("<table"),
1959 "{untrusted_document}"
1960 );
1961 assert!(
1962 untrusted_document.contains("nofollow"),
1963 "{untrusted_document}"
1964 );
1965 assert!(
1966 !untrusted_document.contains("<b>raw</b>"),
1967 "a document is still not trusted: {untrusted_document}"
1968 );
1969 }
1970
1971 /// The fence stays on whatever the trust says, because a trusted source is the
1972 /// one that can actually carry an attribute through.
1973 #[test]
1974 fn the_scripting_fence_holds_for_trusted_prose_too() {
1975 let html = fragment(&Node::rich("hello").trust(Trust::Trusted));
1976
1977 assert!(html.contains("data-disable-scripting"), "{html}");
1978 }
1979
1980 #[test]
1981 fn a_markdown_field_reaches_the_markup_saying_what_its_value_is() {
1982 // Task f8ad0b32's webview half. Node::Rich has carried markdown for display
1983 // since 25822137; this is the editing counterpart reaching a renderer, and
1984 // the mark is what MNW's four hand-written section editors convert onto.
1985 let html = fragment(&Node::field(
1986 Field::new(layout::FieldKind::Rich, "body", "Body").value("# Heading"),
1987 ));
1988 assert!(html.contains("<textarea"), "{html}");
1989 assert!(html.contains(r#"data-format="markdown""#), "{html}");
1990 assert!(html.contains("name=\"body\""), "{html}");
1991 // Not the single-line input the kind would have degraded to before the
1992 // renderer knew the member.
1993 assert!(!html.contains("<input"), "{html}");
1994 }
1995
1996 #[test]
1997 fn a_call_the_host_makes_gets_the_address_and_no_transport() {
1998 // `a81384d4`. MNW's upload is presign, then a PUT to storage from the
1999 // browser, then confirm: one destination to the reader and three requests
2000 // to the host. A renderer posting the field to the first of those is wrong
2001 // about the response shape and about where the bytes go, so it emits the
2002 // address and performs nothing.
2003 let html = fragment(&Node::field(
2004 Field::upload("audio", "Audio", [Accepted::family(layout::Family::Audio)])
2005 .writes(Action::post("/api/upload/presign").awaiting().by_host()),
2006 ));
2007
2008 assert!(
2009 html.contains(r#"data-sends="/api/upload/presign""#),
2010 "{html}"
2011 );
2012 // No transport. Every one of these would be the renderer making a call it
2013 // was told it does not make.
2014 assert!(!html.contains("hx-post"), "{html}");
2015 assert!(!html.contains("hx-trigger"), "{html}");
2016 assert!(!html.contains("href="), "{html}");
2017 // The field itself is unchanged: what it takes is still described.
2018 assert!(html.contains(r#"type="file""#), "{html}");
2019 assert!(html.contains(r#"accept="audio/*""#), "{html}");
2020 }
2021
2022 #[test]
2023 fn a_host_made_call_still_says_it_waits_and_is_locked_by_nobody() {
2024 // The wait is a fact about the call, so it survives; `hx-disable` does
2025 // not, because locking the control is htmx's doing on a request htmx is
2026 // making and this is not one. The host draws the bar and does its own
2027 // locking, which is the whole of what it took this on for.
2028 let html = fragment(&Node::field(
2029 Field::upload("audio", "Audio", []).writes(
2030 Action::post("/api/upload/presign")
2031 .awaiting_amount(41_943_040)
2032 .by_host(),
2033 ),
2034 ));
2035
2036 assert!(html.contains(r#"data-awaiting="determinate""#), "{html}");
2037 assert!(
2038 html.contains(r#"data-awaiting-amount="41943040""#),
2039 "{html}"
2040 );
2041 assert!(!html.contains("hx-disable"), "{html}");
2042 }
2043
2044 #[test]
2045 fn a_host_made_call_carries_its_payload_rather_than_dropping_it() {
2046 // The one thing that would be silent if it were wrong. `hx-vals` is htmx's
2047 // and htmx is not involved, so the params need somewhere else to go or the
2048 // description says something the markup does not carry.
2049 let html = fragment(&Node::field(
2050 Field::upload("audio", "Audio", []).writes(
2051 Action::post("/api/upload/presign")
2052 .with("file_type", "audio")
2053 .by_host(),
2054 ),
2055 ));
2056
2057 assert!(html.contains("data-vals="), "{html}");
2058 assert!(html.contains("file_type"), "{html}");
2059 assert!(!html.contains("hx-vals"), "{html}");
2060 }
2061
2062 #[test]
2063 fn a_navigating_act_emits_the_anchor_and_no_swap() {
2064 // `00ee7af5`. The browser replaces the document, so htmx has nothing to do
2065 // here: a verb beside the anchor would fetch a whole screen and morph it
2066 // into the page it was meant to leave.
2067 let html = fragment(&Node::Act(Act::new(
2068 "Slow Reader",
2069 Action::get("/p/slow-reader").navigating(),
2070 )));
2071
2072 assert!(html.contains(r#"href="/p/slow-reader""#), "{html}");
2073 assert!(!html.contains("hx-get"), "{html}");
2074 assert!(!html.contains("hx-swap"), "{html}");
2075 assert!(!html.contains("hx-trigger"), "{html}");
2076 // It is still an anchor, which is what everything reading the page needs it
2077 // to be: middle-click, copy-link, a crawler.
2078 assert!(html.contains("<a "), "{html}");
2079 }
2080
2081 #[test]
2082 fn a_navigating_act_carries_the_view_it_was_offered_under() {
2083 // The address is built the way every other address here is, so the place
2084 // the browser lands on is the place the control named.
2085 let html = fragment(&Node::Act(Act::new(
2086 "Slow Reader",
2087 Action::get("/p/slow-reader")
2088 .navigating()
2089 .carrying("from", "discover"),
2090 )));
2091
2092 assert!(html.contains("from=discover"), "{html}");
2093 assert!(!html.contains("hx-get"), "{html}");
2094 }
2095
2096 #[test]
2097 fn a_navigating_write_is_still_performed() {
2098 // An anchor would ask where the description said to tell. The mark is
2099 // ignored rather than obeyed into a read.
2100 let html = fragment(&Node::Act(Act::new(
2101 "Publish",
2102 Action::post("/p/slow-reader/publish").navigating(),
2103 )));
2104
2105 assert!(
2106 html.contains(r#"hx-post="/p/slow-reader/publish""#),
2107 "{html}"
2108 );
2109 }
2110
2111 #[test]
2112 fn a_navigating_act_that_asks_first_keeps_its_question() {
2113 // `hx-confirm` is htmx gating the request, so leaving htmx out would drop a
2114 // question the description asked before acting. The transport stays.
2115 let html = fragment(&Node::Act(
2116 Act::new("Slow Reader", Action::get("/p/slow-reader").navigating())
2117 .confirm("Leave this page?"),
2118 ));
2119
2120 assert!(html.contains("hx-confirm="), "{html}");
2121 assert!(html.contains("hx-get="), "{html}");
2122 }
2123
2124 #[test]
2125 fn an_ordinary_read_keeps_its_swap() {
2126 // The narrowing is the mark's and nothing else's. A link written before
2127 // this member existed is the link it was.
2128 let html = fragment(&Node::Act(Act::new(
2129 "Slow Reader",
2130 Action::get("/p/slow-reader"),
2131 )));
2132
2133 assert!(html.contains(r#"href="/p/slow-reader""#), "{html}");
2134 assert!(html.contains(r#"hx-get="/p/slow-reader""#), "{html}");
2135 assert!(html.contains(r#"hx-swap="outerMorph""#), "{html}");
2136 }
2137
2138 #[test]
2139 fn a_call_that_goes_elsewhere_gets_the_address_and_no_transport() {
2140 // goingson `3fb2526a`. A mount here is a second document, and a webview
2141 // cannot put one up: opening a window is the host's. So this is the same
2142 // shape `by_host` has -- the address, and nothing that would make the call
2143 // in the mount the control is already in.
2144 let html = fragment(&Node::Act(Act::new(
2145 "Open in a window",
2146 Action::get("/compose/7").elsewhere(),
2147 )));
2148
2149 assert!(html.contains(r#"data-mount="/compose/7""#), "{html}");
2150 // Not `data-sends`. The two say different things to the host, and a host
2151 // that conflated them would post where it should open.
2152 assert!(!html.contains("data-sends"), "{html}");
2153 // A read would ordinarily be a link with a verb on it. Both would put the
2154 // answer in this mount, which is what the mark says not to do.
2155 assert!(!html.contains("href="), "{html}");
2156 assert!(!html.contains("hx-get"), "{html}");
2157 }
2158
2159 #[test]
2160 fn a_call_that_goes_elsewhere_carries_the_view_it_was_offered_under() {
2161 // The address is built with `carried` the way every other address here is,
2162 // so the mount comes up on the place the control was offered under rather
2163 // than on a default. Silent if wrong: the window opens, on the wrong list.
2164 let html = fragment(&Node::Act(Act::new(
2165 "Open in a window",
2166 Action::get("/compose/7")
2167 .elsewhere()
2168 .carrying("folder", "drafts"),
2169 )));
2170
2171 assert!(html.contains("folder=drafts"), "{html}");
2172 }
2173
2174 #[test]
2175 fn a_call_that_goes_elsewhere_draws_no_wait() {
2176 // Putting up a mount is not a call that resolves. A spinner on the control
2177 // would be waiting for something that never lands, which is worse than no
2178 // feedback because it reads as a hang.
2179 let html = fragment(&Node::Act(Act::new(
2180 "Open in a window",
2181 Action::get("/compose/7").awaiting().elsewhere(),
2182 )));
2183
2184 assert!(!html.contains("data-awaiting"), "{html}");
2185 }
2186
2187 #[test]
2188 fn an_ordinary_call_is_untouched_by_the_member_existing() {
2189 // `by_host` is off by default and the transport is exactly what it was, so
2190 // no description written before this member says anything new.
2191 let html = fragment(&Node::field(
2192 Field::new(layout::FieldKind::Text, "name", "Name")
2193 .writes(Action::post("/rename").awaiting()),
2194 ));
2195
2196 assert!(html.contains("hx-post"), "{html}");
2197 assert!(html.contains("hx-disable"), "{html}");
2198 assert!(!html.contains("data-sends"), "{html}");
2199 }
2200
2201 #[test]
2202 fn an_upload_reaches_the_markup_with_what_it_takes_and_how_many() {
2203 // Task f7261a5a's webview half. The accept list and the multiplicity are
2204 // makeover-layout 0.31.0's, carried through quasi-router's owned mirror and
2205 // emitted by makeover-webview; nothing about the file input is written
2206 // twice. The other two axes an upload has are already here: where the bytes
2207 // go is the field's action, and how far along it is rides on that action's
2208 // `Awaiting`.
2209 let html = fragment(&Node::field(
2210 Field::upload(
2211 "media",
2212 "Media",
2213 [
2214 Accepted::family(layout::Family::Image),
2215 Accepted::family(layout::Family::Video),
2216 Accepted::suffix(".tar.gz"),
2217 ],
2218 )
2219 .many()
2220 .writes(Action::post("/media").awaiting_amount(41_943_040)),
2221 ));
2222 assert!(html.contains(r#"type="file""#), "{html}");
2223 assert!(
2224 html.contains(r#"accept="image/*,video/*,.tar.gz""#),
2225 "{html}"
2226 );
2227 assert!(html.contains(" multiple"), "{html}");
2228 assert!(html.contains("/media"), "{html}");
2229 }
2230
2231 #[test]
2232 fn a_field_with_no_route_is_the_plain_group_it_always_was() {
2233 let html = fragment(&Node::field(Field::new(
2234 layout::FieldKind::Text,
2235 "title",
2236 "Title",
2237 )));
2238 assert!(html.contains("name=\"title\""));
2239 assert!(!html.contains("hx-"));
2240 }
2241
2242 #[test]
2243 fn a_tick_that_is_the_write_calls_a_route_and_a_tick_that_is_not_does_not() {
2244 // The two cases `Row::selected` could not tell apart. A bulk checkbox is
2245 // client state feeding a later action; a checklist item is the write.
2246 let checklist = fragment(&Node::list(vec![
2247 Row::new("Buy milk").toggling(false, Action::post("/subtasks/1/toggle")),
2248 ]));
2249 assert!(checklist.contains("type=\"checkbox\""));
2250 assert!(checklist.contains("hx-post=\"/subtasks/1/toggle\""));
2251 assert!(checklist.contains("hx-trigger=\"change\""));
2252
2253 let mut bulk = Row::new("Ada Lovelace");
2254 bulk.selected = Some(false);
2255 let bulk = fragment(&Node::list(vec![bulk]));
2256 assert!(bulk.contains("type=\"checkbox\""));
2257 assert!(!bulk.contains("hx-"));
2258 }
2259
2260 #[test]
2261 fn rich_text_is_rendered_from_source_and_still_cannot_smuggle_markup() {
2262 // `25822137`. The member is only defensible because what it carries is
2263 // source: the renderer decides what becomes markup, so a script tag in a
2264 // description is no more dangerous here than in `Node::Text`.
2265 let html = fragment(&Node::rich("A **bold** claim\n\n<script>alert(1)</script>"));
2266
2267 assert!(html.contains("<strong>bold</strong>"));
2268 assert!(!html.contains("<script"));
2269 // The node names itself, so a stylesheet has something to hang typography
2270 // on without the app wrapping it in a region of its own.
2271 assert!(html.contains("class=\"rich\""));
2272 }
2273
2274 #[test]
2275 fn a_refused_form_comes_back_with_what_was_typed_in_it() {
2276 // `1c4a66a4`. The value the user lost is the whole point of the finding, so
2277 // the assertion is that it is in the markup rather than that a field exists.
2278 let params = quasi_router::Params::new()
2279 .with("title", "a name with <angles> in it")
2280 .with("done", "on");
2281 let html = fragment(&Node::Form {
2282 marks: ::quasi_router::stage::Marks::none(),
2283 action: Action::post("/tasks"),
2284 submit: "Save".into(),
2285 fields: vec![
2286 Field::new(layout::FieldKind::Text, "title", "Title").refilled(&params),
2287 Field::new(layout::FieldKind::Checkbox, "done", "Done").refilled(&params),
2288 // Nothing was submitted under this name, so it stays empty rather
2289 // than coming back as the empty string.
2290 Field::new(layout::FieldKind::Text, "notes", "Notes").refilled(&params),
2291 ],
2292 });
2293
2294 assert!(html.contains("value=\"a name with &lt;angles&gt; in it\""));
2295 assert!(html.contains("checked"));
2296 // The escaping guarantee is not weakened by carrying a value.
2297 assert!(!html.contains("<angles>"));
2298 // `notes` had nothing submitted under it, so it comes back empty rather
2299 // than carrying a neighbour's value.
2300 assert!(html.contains("name=\"notes\" value=\"\""));
2301 }
2302
2303 #[test]
2304 fn a_secret_is_never_offered_back_however_it_was_set() {
2305 // Two halves of one guarantee. The builder refuses to store it, and the
2306 // renderer refuses to emit it, because `Field::value` is a public field and
2307 // a struct literal reaches past the builder.
2308 let params = quasi_router::Params::new().with("password", "hunter2");
2309
2310 let refused = Field::new(layout::FieldKind::Secret, "password", "Password").refilled(&params);
2311 assert_eq!(refused.value, None);
2312
2313 let mut forced = Field::new(layout::FieldKind::Secret, "password", "Password");
2314 forced.value = Some("hunter2".to_owned());
2315 let html = fragment(&Node::Form {
2316 marks: ::quasi_router::stage::Marks::none(),
2317 action: Action::post("/login"),
2318 submit: "Sign in".into(),
2319 fields: vec![forced],
2320 });
2321 assert!(!html.contains("hunter2"));
2322 }
2323
2324 #[test]
2325 fn an_empty_region_says_so_instead_of_rendering_an_empty_box() {
2326 // `703f4cd2`. A region shows its content or a stand-in, never both, which
2327 // is `Readiness` being one axis with four values rather than two.
2328 let empty = render(
2329 &Screen::list_detail("Projects", false).with(
2330 Slot::new("list", RegionKind::Pane)
2331 .with(Node::section("Projects"))
2332 .with(Node::empty("No projects yet")),
2333 ),
2334 );
2335
2336 assert!(empty.contains("No projects yet"));
2337 assert!(empty.contains(r#"data-state="empty""#));
2338 // The heading survives, which is why this is a node and not a state on the
2339 // region: a column with a heading and no rows still has a heading.
2340 assert!(empty.contains(">Projects<"));
2341 // Not announced as a fault: an empty list is the normal state of a new
2342 // install.
2343 assert!(!empty.contains(r#"role="alert""#));
2344 }
2345
2346 #[test]
2347 fn a_failed_region_is_a_different_state_from_an_empty_one_and_offers_a_way_out() {
2348 let failed = render(
2349 &Screen::list_detail("Events", false).with(
2350 Slot::new("list", RegionKind::Pane).with(
2351 Node::failed("Failed to load events")
2352 .offering(Act::new("Try again", Action::get("/events"))),
2353 ),
2354 ),
2355 );
2356
2357 assert!(failed.contains(r#"data-state="failed""#));
2358 assert!(failed.contains(r#"data-tone="danger""#));
2359 assert!(failed.contains(r#"role="alert""#));
2360 // The way out is a real control, so it reaches a handler.
2361 assert!(failed.contains("hx-get=\"/events\""));
2362 }
2363
2364 #[test]
2365 fn a_destructive_act_asks_first_and_a_shortcut_reaches_it() {
2366 // `524a63fe` and `2daea915`. Both are facts about the control that no
2367 // renderer can derive, and goingson expressed the first by calling a JS
2368 // helper at 33 call sites.
2369 let html = fragment(&Node::Act(
2370 Act::new("Delete", Action::post("/tasks/1/delete"))
2371 .tone(layout::Tone::Danger)
2372 .confirm("Delete this task? This cannot be undone.")
2373 .key("d"),
2374 ));
2375
2376 assert!(html.contains(r#"hx-confirm="Delete this task? This cannot be undone.""#));
2377 assert!(html.contains(r#"accesskey="d""#));
2378 assert!(html.contains("hx-post=\"/tasks/1/delete\""));
2379 }
2380
2381 #[test]
2382 fn a_row_offers_what_it_does_not_show() {
2383 // `5e02fbce`. `actions` is what the row shows; `menu` is what it offers,
2384 // opened by right-click, long-press or a key depending on the host.
2385 let html = fragment(&Node::list([Row::new("Buy milk")
2386 .act(Act::new("Done", Action::post("/tasks/1/complete")))
2387 .offers(Act::new("Duplicate", Action::post("/tasks/1/copy")))
2388 .offers(
2389 Act::new("Delete", Action::post("/tasks/1/delete")).confirm("Delete this task?"),
2390 )]));
2391
2392 assert!(html.contains(r#"data-menu="row""#));
2393 // Hidden rather than absent: the host opens it, and a menu that is not in
2394 // the document cannot be opened.
2395 assert!(html.contains(" hidden>"));
2396 assert!(html.contains("Duplicate"));
2397 assert!(html.contains(r#"hx-confirm="Delete this task?""#));
2398 // The shown action is still shown.
2399 assert!(html.contains("Done"));
2400 }
2401
2402 #[test]
2403 fn a_list_says_how_much_more_there_is_and_how_to_ask() {
2404 // `346567f9`. A described list of the first 50 of 400 was indistinguishable
2405 // from a described list of 50.
2406 let counted = fragment(
2407 &Node::list([Row::new("One")])
2408 .and_more(Rest::more(50, Action::get("/tasks?page=2")).of(400)),
2409 );
2410 assert!(counted.contains("50 of 400"));
2411 assert!(counted.contains("hx-get=\"/tasks?page=2\""));
2412
2413 // A count is often unknown: asking for 51 to find out whether there are
2414 // more than 50 answers the question without answering how many.
2415 let uncounted =
2416 fragment(&Node::list([Row::new("One")]).and_more(Rest::more(50, Action::get("/more"))));
2417 assert!(uncounted.contains("Show more"));
2418 assert!(!uncounted.contains(" of "));
2419
2420 let plain = fragment(&Node::list([Row::new("One")]));
2421 assert!(!plain.contains("rest"));
2422 }
2423
2424 #[test]
2425 fn a_table_can_say_there_is_more_and_the_pager_is_not_a_row() {
2426 // goingson's task list, the first described table anywhere, had to hang its
2427 // paging off a separate `Node::Act` under the table because `Node::Table`
2428 // carried no `Rest`. What that lost was the renderer knowing the control
2429 // belonged to the table above it, and this is that being closed.
2430 let html = fragment(&Node::Table {
2431 marks: ::quasi_router::stage::Marks::none(),
2432 columns: vec![Column::new("Name")],
2433 rows: vec![Row::cells([Cell::new("One")])],
2434 more: Some(
2435 Rest::page(100, 50)
2436 .of(400)
2437 .back(Action::get("/tasks?page=2"))
2438 .forward(Action::get("/tasks?page=4")),
2439 ),
2440 });
2441 assert!(html.contains("3 / 8"));
2442 assert!(html.contains("hx-get=\"/tasks?page=4\""));
2443
2444 // Outside the table element. A `role="table"` whose children are not rows is
2445 // a table saying something untrue about its own shape, so the pager sits
2446 // after it the way a list's sits outside the `<ul>`.
2447 let table_end = html.rfind("</div>").expect("the table closes");
2448 let pager = html.find("rest-position").expect("the pager is drawn");
2449 assert!(
2450 pager < table_end,
2451 "the pager should follow the table's rows, not sit inside its last cell"
2452 );
2453
2454 let plain = fragment(&Node::Table {
2455 marks: ::quasi_router::stage::Marks::none(),
2456 columns: vec![Column::new("Name")],
2457 rows: vec![Row::cells([Cell::new("One")])],
2458 more: None,
2459 });
2460 assert!(!plain.contains("rest"));
2461 }
2462
2463 #[test]
2464 fn a_paged_list_prints_its_page_and_keeps_both_ends() {
2465 // Numbered pages read the same here as a carousel's position does, because
2466 // both are a `layout::Window` and the spelling is deliberately shared.
2467 let middle = fragment(
2468 &Node::list([Row::new("One")]).and_more(
2469 Rest::page(100, 50)
2470 .of(400)
2471 .back(Action::get("/tasks?page=2"))
2472 .forward(Action::get("/tasks?page=4")),
2473 ),
2474 );
2475 assert!(middle.contains("3 / 8"));
2476 assert!(middle.contains("hx-get=\"/tasks?page=2\""));
2477 assert!(middle.contains("hx-get=\"/tasks?page=4\""));
2478 assert!(!middle.contains("disabled"));
2479
2480 // The first page has nowhere back and draws nothing there. Ruled by Max
2481 // 2026-09-08, against the "first paint is final paint" reading that stood
2482 // before: a Prev that cannot go back is the control `control_tag` exists to
2483 // prevent, and a control that is only ever drawn when it acts is also the
2484 // one shape a residual can hold. See `rest_html`.
2485 let first = fragment(
2486 &Node::list([Row::new("One")]).and_more(
2487 Rest::page(0, 50)
2488 .of(400)
2489 .forward(Action::get("/tasks?page=2")),
2490 ),
2491 );
2492 assert!(first.contains("1 / 8"));
2493 assert!(!first.contains("rest-previous"), "{first}");
2494 assert!(first.contains("rest-next"), "{first}");
2495 assert!(!first.contains("disabled"), "{first}");
2496
2497 // And the last page the other way round, so neither direction is the only
2498 // one this is checked on.
2499 let last = fragment(
2500 &Node::list([Row::new("One")]).and_more(
2501 Rest::page(350, 50)
2502 .of(400)
2503 .back(Action::get("/tasks?page=7")),
2504 ),
2505 );
2506 assert!(last.contains("8 / 8"));
2507 assert!(last.contains("rest-previous"), "{last}");
2508 assert!(!last.contains("rest-next"), "{last}");
2509 }
2510
2511 #[test]
2512 fn a_pager_that_offers_pages_draws_a_strip_and_marks_the_one_being_read() {
2513 // `0ce21f4b`. The description names each page's address, because a renderer
2514 // building page 5's out of prev and next would have to know the address
2515 // grammar -- the private vocabulary a conversion exists to retire.
2516 let html = fragment(
2517 &Node::list([Row::new("One")]).and_more(
2518 Rest::page(100, 50)
2519 .of(400)
2520 .back(Action::get("/feed?page=2"))
2521 .forward(Action::get("/feed?page=4"))
2522 .jumping(Jump::new(2, Action::get("/feed?page=2")))
2523 .jumping(Jump::new(3, Action::get("/feed?page=3")).here())
2524 .jumping(Jump::new(4, Action::get("/feed?page=4"))),
2525 ),
2526 );
2527
2528 assert!(html.contains("rest-pages"), "{html}");
2529 assert!(!html.contains("hx-get=\"/feed?page=3\""), "{html}");
2530 assert!(html.contains("hx-get=\"/feed?page=2\""), "{html}");
2531 assert!(html.contains("hx-get=\"/feed?page=4\""), "{html}");
2532
2533 // The page being read is text, not a control: one that reloads the page it
2534 // is on is the lying control `control_tag` exists to prevent.
2535 assert!(html.contains("aria-current=\"page\""), "{html}");
2536 assert!(html.contains("rest-page-here"), "{html}");
2537
2538 // And the readout is gone, because the strip says both numbers already.
2539 assert!(!html.contains("3 / 8"), "{html}");
2540 assert!(!html.contains("rest-position"), "{html}");
2541
2542 // The ends are still there. A strip is a way to jump, not a replacement for
2543 // stepping.
2544 assert!(html.contains("rest-previous"), "{html}");
2545 assert!(html.contains("rest-next"), "{html}");
2546 }
2547
2548 #[test]
2549 fn a_pager_that_offers_no_pages_prints_the_position_it_always_did() {
2550 // Empty jumps is every site that existed before this member, and it has to
2551 // emit the same bytes.
2552 let html = fragment(
2553 &Node::list([Row::new("One")]).and_more(
2554 Rest::page(100, 50)
2555 .of(400)
2556 .back(Action::get("/feed?page=2"))
2557 .forward(Action::get("/feed?page=4")),
2558 ),
2559 );
2560 assert!(html.contains("rest-position"), "{html}");
2561 assert!(!html.contains("rest-pages"), "{html}");
2562 }
2563
2564 #[test]
2565 fn a_sortable_column_says_which_way_and_offers_the_press() {
2566 // `ce620871`. The one finding that completed a member rather than adding
2567 // one: `Column` shipped with a width and a priority and could say nothing
2568 // about order.
2569 let html = fragment(&Node::Table {
2570 marks: ::quasi_router::stage::Marks::none(),
2571 columns: vec![
2572 Column::new("Title")
2573 .reorder(Action::get("/tasks?sort=title"))
2574 .sorted(layout::Sort::Ascending),
2575 Column::new("Due").reorder(Action::get("/tasks?sort=due")),
2576 Column::new("Notes"),
2577 ],
2578 rows: vec![Row::cells(["Ship it", "Tomorrow", "None"])],
2579 more: None,
2580 });
2581
2582 assert!(html.contains(r#"aria-sort="ascending""#));
2583 assert_eq!(html.matches("data-sortable").count(), 2);
2584 // The press is its own control inside the header cell, never the cell
2585 // itself: a `columnheader` is not a control and must not announce itself as
2586 // one. Reordering is a read, so the control is a link and is addressable.
2587 assert!(html.contains(r#"<a class="table-sort""#));
2588 assert!(html.contains("href=\"/tasks?sort=due\""));
2589 assert!(html.contains("hx-get=\"/tasks?sort=due\""));
2590 }
2591
2592 #[test]
2593 fn a_notice_interrupts_only_when_its_tone_says_to() {
2594 let danger = fragment(&Node::banner(layout::Tone::Danger, "Disk full"));
2595 assert!(danger.contains("role=\"alert\""));
2596
2597 let info = fragment(&Node::toast(layout::Tone::Info, "Saved"));
2598 assert!(info.contains("role=\"status\""));
2599 assert!(info.contains("aria-live=\"polite\""));
2600 }
2601
2602 #[test]
2603 fn a_screens_notices_come_before_its_regions() {
2604 let screen = Screen::list_detail("Tasks", false)
2605 .with(Slot::new("list", RegionKind::Pane))
2606 .saying(Node::toast(layout::Tone::Success, "Saved"));
2607 let html = render(&screen);
2608
2609 let notice = html.find("Saved").expect("the notice is rendered");
2610 let region = html.find("id=\"list\"").expect("the region is rendered");
2611 assert!(notice < region);
2612 }
2613
2614 #[test]
2615 fn every_arrangement_has_a_class_and_they_differ() {
2616 let plain = render(&Screen::list_detail("A", false));
2617 let tabbed = render(&Screen::list_detail("A", true));
2618 let sidebar = render(&Screen::sidebar_content("A"));
2619
2620 // The measure rides in the same attribute, and the two vary independently:
2621 // a compound `wide-list-detail` per pairing is the enumeration `1786cb94`
2622 // settled against one level down.
2623 assert!(plain.contains("class=\"list-detail measure-wide\""));
2624 assert!(tabbed.contains("class=\"list-detail-tabbed measure-wide\""));
2625 assert!(sidebar.contains("class=\"sidebar-content measure-wide\""));
2626
2627 let reading = render(&Screen::list_detail("A", false).measured(layout::Measure::Reading));
2628 assert!(reading.contains("class=\"list-detail measure-reading\""));
2629 }
2630
2631 #[test]
2632 fn a_screen_carries_the_share_as_a_number_rather_than_a_class() {
2633 // `e0fd485e`. A share is a number the description carries, and a class can
2634 // only name a number some stylesheet already fixed -- which is the drift
2635 // the member exists to end, since a terminal has no stylesheet to read it
2636 // out of.
2637 let wide = render(&Screen::sidebar_content("A"));
2638 assert!(
2639 wide.contains("--region-share:25fr;--region-rest:75fr"),
2640 "{wide}"
2641 );
2642
2643 let narrow = render(&Screen::new(
2644 "A",
2645 layout::Arrangement::sidebar_content().with_share(layout::Share::percent(20)),
2646 ));
2647 assert!(
2648 narrow.contains("--region-share:20fr;--region-rest:80fr"),
2649 "{narrow}"
2650 );
2651 }
2652
2653 #[test]
2654 fn a_class_prefix_reaches_every_emitted_name() {
2655 let emit = crate::Emit {
2656 class_prefix: "qs-",
2657 ..crate::Emit::default()
2658 };
2659
2660 let screen = Screen::list_detail("Tasks", false).with(
2661 Slot::new("list", RegionKind::Pane)
2662 .with(Node::page("Tasks"))
2663 .with(Node::list([Row::new("One")])),
2664 );
2665 let html = Webview::new().with_emit(emit).screen(&screen);
2666
2667 assert!(html.contains("class=\"qs-list-detail qs-measure-wide\""));
2668 assert!(html.contains("qs-region qs-pane"));
2669 assert!(html.contains("qs-heading"));
2670 assert!(html.contains("qs-list"));
2671 // No unprefixed leftovers: a name that missed the prefix is a rule in the
2672 // generated stylesheet that matches nothing.
2673 assert!(!html.contains("class=\"list\""));
2674 assert!(!html.contains("class=\"heading\""));
2675 }
2676
2677 #[test]
2678 fn the_shell_serves_its_assets_from_where_the_host_says() {
2679 let html = Webview::under("/assets").screen(&Screen::list_detail("A", false));
2680 assert!(html.contains("src=\"/assets/htmx.min.js\""));
2681
2682 // The Tauri case: a custom scheme, which is the whole reason this is a
2683 // parameter and not a constant.
2684 let tauri = Webview::under("quasi://localhost/assets").screen(&Screen::list_detail("A", false));
2685 assert!(tauri.contains("src=\"quasi://localhost/assets/htmx.min.js\""));
2686 }
2687
2688 #[test]
2689 fn stylesheets_link_in_the_order_they_were_added() {
2690 let shell = Shell::default().styled("/a.css").styled("/b.css");
2691 let html = Webview::new()
2692 .with_shell(shell)
2693 .screen(&Screen::list_detail("A", false));
2694
2695 let a = html.find("/a.css").expect("a is linked");
2696 let b = html.find("/b.css").expect("b is linked");
2697 assert!(a < b);
2698 }
2699
2700 #[test]
2701 fn injected_head_markup_lands_last_so_it_can_override() {
2702 let shell = Shell::default().with_head("<link rel=\"icon\" href=\"/f.png\">");
2703 let html = Webview::new()
2704 .with_shell(shell)
2705 .screen(&Screen::list_detail("A", false));
2706
2707 let icon = html.find("/f.png").expect("the icon is linked");
2708 let htmx = html.find("htmx.min.js").expect("htmx is linked");
2709 assert!(htmx < icon);
2710 assert!(icon < html.find("</head>").expect("the head closes"));
2711 }
2712
2713 #[test]
2714 fn injected_opening_body_markup_precedes_everything_the_screen_draws() {
2715 // A skip link is the case, and the top is the only position it works from.
2716 let shell = Shell::default().with_body_first("<a href=\"#pane\">Skip</a>");
2717 let html = Webview::new()
2718 .with_shell(shell)
2719 .screen(&Screen::list_detail("A", false));
2720
2721 let skip = html.find("Skip").expect("the link is emitted");
2722 assert!(html.find("<body").expect("the body opens") < skip);
2723 assert!(skip < html.find("<main").expect("the content opens"));
2724 }
2725
2726 #[test]
2727 fn injected_body_markup_lands_after_the_content_and_before_the_close() {
2728 // `with_head`'s mirror. A script appended before the markup it binds to is
2729 // a script that finds nothing, so the only placement worth asserting is
2730 // last.
2731 let shell = Shell::default().with_body_last("<script src=\"/tail.js\"></script>");
2732 let html = Webview::new()
2733 .with_shell(shell)
2734 .screen(&Screen::list_detail("A", false));
2735
2736 let tail = html.find("/tail.js").expect("the script is emitted");
2737 assert!(html.find("</main>").expect("the content closes") < tail);
2738 assert!(tail < html.find("</body>").expect("the body closes"));
2739 }
2740
2741 #[test]
2742 fn a_document_with_nothing_to_append_is_the_one_it_was_before() {
2743 // Additive, checked rather than assumed: a shell that says nothing here
2744 // emits what it emitted before the member existed.
2745 let html = Webview::new().screen(&Screen::list_detail("A", false));
2746 assert!(html.ends_with("</body></html>"));
2747 }
2748
2749 #[test]
2750 fn the_layer_statement_precedes_every_stylesheet() {
2751 // The whole point: a layer's position is fixed where its name is first
2752 // seen, so a statement after the links is not a statement.
2753 let shell = Shell::default()
2754 .layered(["base", "components", "responsive"])
2755 .styled("/geometry.css")
2756 .styled("/style.css");
2757 let html = Webview::new()
2758 .with_shell(shell)
2759 .screen(&Screen::list_detail("A", false));
2760
2761 let stmt = html
2762 .find("@layer makeover, base, components, responsive;")
2763 .expect("the order is stated");
2764 let first_sheet = html.find("/geometry.css").expect("the sheet is linked");
2765 assert!(stmt < first_sheet);
2766 }
2767
2768 #[test]
2769 fn the_layer_statement_is_emitted_even_with_no_app_layers() {
2770 // An app that names no layers of its own still needs makeover pinned to the
2771 // bottom of the cascade, and that is the case where forgetting is easiest.
2772 let html = Webview::new().screen(&Screen::list_detail("A", false));
2773 assert!(html.contains("@layer makeover;"));
2774 }
2775
2776 #[test]
2777 fn a_layer_name_cannot_escape_the_style_element() {
2778 // HTML escaping does not apply inside <style>, so a `<` here would be a way
2779 // out of the element rather than a character in a name.
2780 let shell = Shell::default().layered(["base</style><script>alert(1)</script>"]);
2781 let html = Webview::new()
2782 .with_shell(shell)
2783 .screen(&Screen::list_detail("A", false));
2784
2785 assert!(!html.contains("<script>alert(1)"));
2786 assert!(html.contains("@layer makeover, basestylescriptalert1script;"));
2787 }
2788
2789 #[test]
2790 fn head_first_markup_lands_before_the_layer_statement_and_the_sheets() {
2791 // A preload discovered after the stylesheets it races bought nothing.
2792 let shell = Shell::default()
2793 .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">")
2794 .styled("/style.css");
2795 let html = Webview::new()
2796 .with_shell(shell)
2797 .screen(&Screen::list_detail("A", false));
2798
2799 let preload = html.find("/f.woff2").expect("the font is preloaded");
2800 let stmt = html.find("@layer makeover").expect("the order is stated");
2801 let sheet = html.find("/style.css").expect("the sheet is linked");
2802 assert!(preload < stmt);
2803 assert!(stmt < sheet);
2804 }
2805
2806 #[test]
2807 fn head_first_and_head_are_different_ends_of_the_same_head() {
2808 let shell = Shell::default()
2809 .with_head_first("<meta name=\"first\">")
2810 .with_head("<meta name=\"last\">");
2811 let html = Webview::new()
2812 .with_shell(shell)
2813 .screen(&Screen::list_detail("A", false));
2814
2815 let first = html.find("name=\"first\"").expect("first is emitted");
2816 let last = html.find("name=\"last\"").expect("last is emitted");
2817 let htmx = html.find("htmx.min.js").expect("htmx is linked");
2818 assert!(first < htmx);
2819 assert!(htmx < last);
2820 }
2821
2822 #[test]
2823 fn repeated_head_first_calls_keep_their_call_order() {
2824 let shell = Shell::default()
2825 .with_head_first("<meta name=\"a\">")
2826 .with_head_first("<meta name=\"b\">");
2827 let html = Webview::new()
2828 .with_shell(shell)
2829 .screen(&Screen::list_detail("A", false));
2830
2831 assert!(html.find("name=\"a\"") < html.find("name=\"b\""));
2832 }
2833
2834 #[test]
2835 fn a_document_wraps_markup_the_renderer_did_not_write() {
2836 // The path a host on hand-written templates takes: one head, emitted here,
2837 // around a body it rendered itself.
2838 let shell = Shell::default().layered(["base"]).styled("/style.css");
2839 let html = shell.document("Console", "<main>hand-written</main>");
2840
2841 assert!(html.starts_with("<!doctype html><html lang=\"en\">"));
2842 assert!(html.contains("<title>Console</title>"));
2843 assert!(html.contains("<main>hand-written</main>"));
2844 assert!(html.ends_with("</body></html>"));
2845 }
2846
2847 #[test]
2848 fn a_document_states_the_layers_before_the_sheets_like_a_screen_does() {
2849 // The whole reason a template would take the shell. If these two paths
2850 // disagree on layer order, the described and the Askama halves of one app
2851 // cascade differently.
2852 let shell = Shell::default().layered(["base"]).styled("/style.css");
2853 let html = shell.document("Console", "<main>x</main>");
2854
2855 let stmt = html
2856 .find("@layer makeover, base;")
2857 .expect("the order is stated");
2858 let sheet = html.find("/style.css").expect("the sheet is linked");
2859 let body = html.find("<main>").expect("the body is placed");
2860 assert!(stmt < sheet);
2861 assert!(sheet < body);
2862 }
2863
2864 #[test]
2865 fn a_documents_body_tag_is_the_shells_own() {
2866 // The body classes are the shell's on both paths, so a template does not
2867 // have to remember them.
2868 let shell = Shell {
2869 body_class: Some("console".into()),
2870 ..Shell::default()
2871 };
2872 let html = shell.document("Console", "x");
2873
2874 assert!(html.contains("<body class=\"console\">x</body>"));
2875 }
2876
2877 #[test]
2878 fn a_screens_body_class_joins_the_shells_rather_than_replacing_it() {
2879 // `1d4f288e`. The shell carries what is true of every page and the screen
2880 // what is true of this one, in one attribute because two `class` attributes
2881 // on a tag is markup a parser drops half of.
2882 let shell = Shell {
2883 body_class: Some("mnw".into()),
2884 ..Shell::default()
2885 };
2886 let screen =
2887 Screen::list_detail("Admin", false).documented(Document::default().classed("admin-page"));
2888 let html = Webview::new().with_shell(shell).screen(&screen);
2889
2890 assert!(html.contains("<body class=\"mnw admin-page\""), "{html}");
2891 }
2892
2893 #[test]
2894 fn a_screen_that_says_nothing_about_the_document_emits_what_it_always_did() {
2895 // The default has to be the byte the member replaced, or every screen in
2896 // the tree moved when this landed.
2897 let plain = Webview::new().screen(&Screen::list_detail("A", false));
2898 assert!(plain.contains("<html lang=\"en\">"), "{plain}");
2899 assert!(plain.contains("<body>"), "{plain}");
2900 }
2901
2902 #[test]
2903 fn a_screen_puts_its_own_attributes_on_the_root() {
2904 // goingson's pinned theme: once every theme ships in one sheet keyed by a
2905 // root attribute, answering a preference change is setting this rather
2906 // than reloading the app.
2907 let screen = Screen::list_detail("Settings", false)
2908 .documented(Document::default().rooted("data-theme", "slate"));
2909 let html = Webview::new().screen(&screen);
2910
2911 // After `lang`, which is the shell's and cannot be displaced.
2912 assert!(
2913 html.contains("<html lang=\"en\" data-theme=\"slate\">"),
2914 "{html}"
2915 );
2916 }
2917
2918 #[test]
2919 fn a_root_attribute_this_renderer_will_not_write_is_dropped_and_the_screen_still_draws() {
2920 // The name reaches markup as a name rather than as a value, so escaping is
2921 // not what protects it -- a gate is. And a refused name is a description
2922 // bug worth being able to see, not a reason to refuse the screen.
2923 let screen = Screen::list_detail("A", false).documented(
2924 Document::default()
2925 .rooted("x\" onload=alert(1) y", "1")
2926 .rooted("data-theme", "slate"),
2927 );
2928 let html = Webview::new().screen(&screen);
2929
2930 assert!(!html.contains("onload"), "{html}");
2931 assert!(html.contains("data-theme=\"slate\""), "{html}");
2932 assert!(html.contains("<main"), "{html}");
2933 }
2934
2935 #[test]
2936 fn a_reference_opens_aside_and_a_handoff_replaces_the_page() {
2937 // `48a6e9e5`. Both leave the app and neither is htmx's business; what
2938 // separates them is where the reader ends up, which is the description's.
2939 // A satellite document's link home said as External would open a tab,
2940 // which is refusing to let go of a reader who asked to leave.
2941 let aside = fragment(&Node::Link {
2942 text: "Docs".into(),
2943 action: Action::external("https://example.com"),
2944 });
2945 let onward = fragment(&Node::Link {
2946 text: "View on makenot.work".into(),
2947 action: Action::leaving("https://makenot.work/p/x"),
2948 });
2949
2950 assert!(aside.contains("target=\"_blank\""), "{aside}");
2951 assert!(!onward.contains("target="), "{onward}");
2952 // `noreferrer` earns its place on both: a same-tab navigation gives the
2953 // other page no live handle, and still sends `Referer`.
2954 assert!(aside.contains("rel=\"noopener noreferrer\""), "{aside}");
2955 assert!(onward.contains("rel=\"noopener noreferrer\""), "{onward}");
2956 // Neither carries a verb: nothing swaps and no route is called.
2957 assert!(!onward.contains("hx-get"), "{onward}");
2958 }
2959
2960 #[test]
2961 fn a_scriptless_shell_writes_no_script_at_all() {
2962 // `48a6e9e5`. Under `default-src 'none'` a script element is a console
2963 // error rather than a feature, and the list is here rather than at the call
2964 // site so a script this crate gains joins it in one place.
2965 let html = Webview::new()
2966 .with_shell(Shell::under("/static").without_scripts())
2967 .screen(&Screen::list_detail("A", false));
2968
2969 assert!(!html.contains("<script"), "{html}");
2970 }
2971
2972 #[test]
2973 fn a_canvas_writes_its_markup_out_and_scopes_it_the_way_the_app_said() {
2974 // `48a6e9e5`. MNW's sanitiser rewrites every creator selector to sit under
2975 // `.user-canvas#uc-{owner}`, so the element has to carry that class and
2976 // that id verbatim or the sheet it was scoped for matches nothing.
2977 let html = fragment(&Node::Canvas(Box::new(
2978 Canvas::new("<h1>Hello</h1>")
2979 .classed("user-canvas")
2980 .identified("uc-11111111"),
2981 )));
2982
2983 assert!(
2984 html.contains("<div class=\"user-canvas\" id=\"uc-11111111\"><h1>Hello</h1></div>"),
2985 "{html}"
2986 );
2987 }
2988
2989 #[test]
2990 fn a_canvas_draws_the_apps_own_nodes_inside_the_scope_after_the_markup() {
2991 // Inside rather than beside, which is what lets a creator's sheet reach the
2992 // platform's buy block. Outside would be a different page, quietly.
2993 let html = fragment(&Node::Canvas(Box::new(
2994 Canvas::new("<p>mine</p>")
2995 .classed("user-canvas")
2996 .with(Node::text("Free")),
2997 )));
2998
2999 let markup = html.find("<p>mine</p>").expect("the markup is written");
3000 let node = html.find("Free").expect("the node is written");
3001 let close = html.rfind("</div>").expect("the scope closes");
3002 assert!(markup < node, "{html}");
3003 assert!(node < close, "{html}");
3004 }
3005
3006 #[test]
3007 fn a_canvas_with_no_scope_is_markup_with_nothing_aimed_at_it() {
3008 // A real shape for an app whose stylesheet is its own, so the attributes
3009 // are absent rather than emitted empty.
3010 let html = fragment(&Node::Canvas(Box::new(Canvas::new("<p>x</p>"))));
3011
3012 assert!(html.contains("<div><p>x</p></div>"), "{html}");
3013 }
3014
3015 #[test]
3016 fn a_canvas_scope_cannot_leave_the_attribute_it_is_written_in() {
3017 // The class and id are app strings in attribute values. The markup is
3018 // deliberately not escaped and the scope deliberately is, which is the
3019 // whole difference between what the app authored and what it stored.
3020 let html = fragment(&Node::Canvas(Box::new(
3021 Canvas::new("<p>x</p>").classed("a\" onload=alert(1) b"),
3022 )));
3023
3024 // The quote is what would have ended the attribute; escaped, the rest is
3025 // an absurd class name and nothing more.
3026 assert!(
3027 html.contains("class=\"a&quot; onload=alert(1) b\""),
3028 "{html}"
3029 );
3030 }
3031
3032 #[test]
3033 fn a_screens_own_stylesheet_is_last_in_the_head_and_unlayered() {
3034 // `48a6e9e5`. MNW's custom pages serve creator CSS re-scoped per request,
3035 // and a shell's layer list is fixed for the process. Last and unlayered is
3036 // what makes "on top of" true without the screen naming a layer.
3037 let screen = Screen::list_detail("Profile", false)
3038 .documented(Document::default().styled(".user-canvas p { color: red }"));
3039 let html = Webview::new()
3040 .with_shell(Shell::under("/static").layered(["base"]))
3041 .screen(&screen);
3042
3043 let layers = html.find("@layer").expect("the layer statement is written");
3044 let sheet = html
3045 .find(".user-canvas p { color: red }")
3046 .expect("the screen's sheet is written");
3047 let head_end = html.find("</head>").expect("the head closes");
3048 assert!(layers < sheet, "{html}");
3049 assert!(sheet < head_end, "{html}");
3050 }
3051
3052 #[test]
3053 fn a_screen_that_says_nothing_about_its_style_emits_no_extra_element() {
3054 // The default is the byte the member replaced, the same rule the body
3055 // class and the root attributes answer to.
3056 let bare = Webview::new().screen(&Screen::list_detail("A", false));
3057 let styled = Webview::new()
3058 .screen(&Screen::list_detail("A", false).documented(Document::default().styled("p{}")));
3059
3060 assert_eq!(
3061 bare.matches("<style>").count() + 1,
3062 styled.matches("<style>").count(),
3063 "{styled}"
3064 );
3065 }
3066
3067 #[test]
3068 fn a_stylesheet_cannot_close_the_element_it_is_written_into() {
3069 // `<style>` is a raw-text element, so the parser leaves it at the first
3070 // `</style` and everything after would land in the document as markup. A
3071 // CSS sanitiser has no occasion to notice: inside a string literal that is
3072 // an ordinary run of characters.
3073 let screen = Screen::list_detail("A", false).documented(
3074 Document::default().styled("p::after { content: '</style><script>alert(1)</script>' }"),
3075 );
3076 let html = Webview::new().screen(&screen);
3077
3078 // The property is that the element is not left early, so everything the
3079 // sheet carries is still raw text inside it. `<script>` in there is inert:
3080 // a `<style>` element ends at `</style` and at nothing else.
3081 let sheet = html
3082 .rfind("<style>")
3083 .map(|at| at + "<style>".len())
3084 .expect("the screen's sheet is written");
3085 let ends = html[sheet..]
3086 .find("</style")
3087 .expect("the sheet's element closes");
3088 let inside = &html[sheet..sheet + ends];
3089 assert!(inside.contains("alert(1)</script>' }"), "{html}");
3090 // Broken in CSS's own vocabulary, so a reader of the string gets the
3091 // characters back rather than the sheet losing its rule.
3092 assert!(inside.contains("\\3c/style"), "{html}");
3093 }
3094
3095 #[test]
3096 fn the_close_sequence_is_caught_whatever_its_case() {
3097 // The HTML parser is case-insensitive about it, so this has to be.
3098 let screen = Screen::list_detail("A", false)
3099 .documented(Document::default().styled("p::after { content: '</STYLE>' }"));
3100 let html = Webview::new().screen(&screen);
3101
3102 assert!(!html.contains("</STYLE>"), "{html}");
3103 assert!(html.contains("\\3c/STYLE"), "{html}");
3104 }
3105
3106 #[test]
3107 fn a_root_attribute_named_twice_is_written_once() {
3108 // Two attributes of one name on one tag is markup a parser halves, so the
3109 // renderer picks rather than emitting both. The first statement wins.
3110 let screen = Screen::list_detail("A", false).documented(
3111 Document::default()
3112 .rooted("data-theme", "slate")
3113 .rooted("data-theme", "paper"),
3114 );
3115 let html = Webview::new().screen(&screen);
3116
3117 assert_eq!(html.matches("data-theme=").count(), 1, "{html}");
3118 assert!(html.contains("data-theme=\"slate\""), "{html}");
3119 }
3120
3121 #[test]
3122 fn a_host_assembling_its_own_document_gets_the_same_head_as_a_screen() {
3123 // The property the split exists for. A server converting one screen at a
3124 // time renders both ways at once, and the two heads agreeing is the whole
3125 // reason its templates take the shell at all.
3126 let shell = Shell::default()
3127 .layered(["base", "components"])
3128 .styled("/style.css")
3129 .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">");
3130 let parts = shell.parts();
3131 let screen = Webview::new()
3132 .with_shell(shell)
3133 .screen(&Screen::list_detail("Console", false));
3134
3135 // Everything the SHELL owns is the same markup, in the same order. Two
3136 // things are not the shell's and are stripped before comparing: the title,
3137 // because the host writes it, and the screen's discovery tags, because a
3138 // host on this path has no `Screen` to read them from and writes its own
3139 // head metadata -- which is exactly what the server does today in its
3140 // `block head`.
3141 let head = screen
3142 .split("</head>")
3143 .next()
3144 .expect("the screen has a head")
3145 .replace("<title>Console</title>", "");
3146 let head = strip_discovery(&head);
3147 assert_eq!(parts.head, head);
3148 assert!(screen.contains(&format!("<body{}>", parts.body_attrs)));
3149 }
3150
3151 #[test]
3152 fn the_body_attributes_compose_with_the_hosts_own() {
3153 // Space-prefixed and never a bare `class`, so a template that writes its
3154 // own class attribute after them does not produce two.
3155 let parts = Shell::default().sending("X-CSRF-Token", "abc").parts();
3156 assert!(parts.body_attrs.starts_with(' '), "{}", parts.body_attrs);
3157 assert!(!parts.head.contains("</head>"));
3158 assert!(!parts.head.contains("<title>"));
3159
3160 // A shell told nothing owns nothing on the tag at all.
3161 assert_eq!(Shell::default().parts().body_attrs, "");
3162 }
3163
3164 #[test]
3165 fn a_declared_header_travels_with_every_request_the_document_makes() {
3166 // The whole point is inheritance: it goes on the body once, so a control
3167 // emitted anywhere in the document carries it without knowing it exists.
3168 let parts = Shell::default().sending("X-CSRF-Token", "abc123").parts();
3169 // `:inherited`, because htmx 4 inherits nothing unless the attribute says
3170 // so and a bare `hx-headers` here would reach no control at all.
3171 assert_eq!(
3172 parts.body_attrs,
3173 " hx-headers:inherited=\"{&quot;X-CSRF-Token&quot;:&quot;abc123&quot;}\""
3174 );
3175
3176 // A shell told nothing emits nothing, byte for byte what it emitted before
3177 // this existed.
3178 assert_eq!(Shell::default().parts().body_attrs, "");
3179 }
3180
3181 #[test]
3182 fn a_declared_header_cannot_break_out_of_its_attribute() {
3183 // A token is opaque bytes from a host, and a host that concatenates one
3184 // from somewhere unwise should not get a way out of the tag. Same escaper
3185 // `hx-vals` uses, asserted here too because this is the one place a shell
3186 // puts host-supplied text into markup.
3187 let parts = Shell::default()
3188 .sending("X-Token", "a\"><script>alert(1)</script>")
3189 .parts();
3190 assert!(
3191 !parts.body_attrs.contains("<script>"),
3192 "{}",
3193 parts.body_attrs
3194 );
3195 assert!(
3196 parts.body_attrs.contains("&lt;script&gt;"),
3197 "{}",
3198 parts.body_attrs
3199 );
3200 }
3201
3202 #[test]
3203 fn headers_accumulate_in_the_order_they_were_declared() {
3204 let parts = Shell::default().sending("A", "1").sending("B", "2").parts();
3205 assert!(
3206 parts.body_attrs.contains(
3207 "hx-headers:inherited=\"{&quot;A&quot;:&quot;1&quot;,&quot;B&quot;:&quot;2&quot;}\""
3208 ),
3209 "{}",
3210 parts.body_attrs
3211 );
3212 }
3213
3214 #[test]
3215 fn a_document_that_calls_no_route_ships_no_transport() {
3216 // The five MNW embeds. An embed is an iframe on a third party's page and
3217 // carries not one `hx-` attribute, so htmx there is a script the reader
3218 // downloads and runs to do nothing.
3219 let full = Shell::default().parts();
3220 assert!(full.head.contains("htmx.min.js"));
3221
3222 let bare = Shell::default().without_htmx().parts();
3223 assert!(!bare.head.contains("htmx.min.js"), "{}", bare.head);
3224 // The selection script hangs off htmx, so it goes with it rather than
3225 // being left to bind to an event nothing dispatches.
3226 assert!(!bare.head.contains("quasi-selection.js"), "{}", bare.head);
3227 assert_eq!(bare.body_attrs, "");
3228 // Everything else a document needs is untouched: this drops a transport,
3229 // not the document chrome.
3230 assert!(bare.head.contains("@layer makeover"));
3231 }
3232
3233 #[test]
3234 fn a_nested_region_renders_inside_its_parent() {
3235 let screen =
3236 Screen::list_detail("Tasks", false).with(Slot::new("outer", RegionKind::Pane).with(
3237 Node::Region(Slot::new("inner", RegionKind::Pane).with(Node::text("in"))),
3238 ));
3239 let html = render(&screen);
3240
3241 let outer = html.find("id=\"outer\"").expect("outer renders");
3242 let inner = html.find("id=\"inner\"").expect("inner renders");
3243 assert!(outer < inner);
3244 // The inner region closes inside the outer one, with each region's own
3245 // anchor container as its last child. `ae8e8836` put those there; before it
3246 // the two closing tags were adjacent.
3247 assert!(
3248 html.contains(
3249 "in</p><div class=\"anchored\" id=\"inner-anchored\" data-menu=\"anchored\" \
3250 hidden></div></div><div class=\"anchored\" id=\"outer-anchored\" \
3251 data-menu=\"anchored\" hidden></div></div>"
3252 ),
3253 "{html}"
3254 );
3255 }
3256
3257 #[test]
3258 fn text_from_a_description_can_never_become_markup() {
3259 // The property that has to hold across every variant, because a
3260 // description's strings come from application state. Checked over the whole
3261 // tree rather than per node, so a variant added without escaping fails
3262 // here rather than in production.
3263 let hostile = "<script>alert(1)</script>";
3264 let screen = Screen::list_detail(hostile, false)
3265 .saying(Node::banner(layout::Tone::Danger, hostile))
3266 .with(
3267 Slot::new("s", RegionKind::Pane)
3268 .with(Node::page(hostile))
3269 .with(Node::text(hostile))
3270 .with(Node::act(hostile, Action::get("/x")))
3271 .with(Node::list([Row::new(hostile)
3272 .secondary(hostile)
3273 .meta(hostile)
3274 .act(Act::new(hostile, Action::post("/y")))]))
3275 .with(Node::Token(Tag::removable(hostile, Action::get("/z")))),
3276 );
3277
3278 let html = render(&screen);
3279 assert!(!html.contains("<script>"));
3280 // Twelve sinks: the title, the notice, the heading, the prose, the act's
3281 // label, the row's four parts, the chip's label, and the two the title is
3282 // repeated into for a link preview -- `og:title` and `twitter:title`, which
3283 // are attribute values and escape through the same path. Counted rather
3284 // than merely checked for absence, so a variant that silently stops
3285 // rendering its text fails here too.
3286 assert_eq!(html.matches("&lt;script&gt;").count(), 12);
3287 }
3288
3289 #[test]
3290 fn a_rows_plain_prose_is_escaped_exactly_as_it_always_was() {
3291 // The default case, and the one that must not change: `.secondary("...")`
3292 // still means text, and text is never markup however it is punctuated.
3293 let row = Row::new("Atlas").secondary("**not bold** <b>not bold either</b>");
3294 let node = Node::list(vec![row]);
3295
3296 let html = Webview::new().fragment(&node);
3297
3298 assert!(html.contains("**not bold**"), "got: {html}");
3299 assert!(!html.contains("<strong>"), "got: {html}");
3300 assert!(!html.contains("<b>"), "got: {html}");
3301 assert!(html.contains("&lt;b&gt;"), "got: {html}");
3302 }
3303
3304 #[test]
3305 fn a_rows_rich_prose_is_rendered_inline() {
3306 // The row-prose decision. The description says the string is markdown and
3307 // this renderer draws it as markdown, one line's worth.
3308 let row = Row::new("Atlas").secondary(Prose::rich("**Ships Q3.** `soon`"));
3309 let node = Node::list(vec![row]);
3310
3311 let html = Webview::new().fragment(&node);
3312
3313 assert!(html.contains("<strong>Ships Q3.</strong>"), "got: {html}");
3314 assert!(html.contains("<code>soon</code>"), "got: {html}");
3315 }
3316
3317 #[test]
3318 fn a_rows_rich_prose_keeps_no_blocks() {
3319 // A row is one line tall. A heading, a list and a quote each contribute
3320 // their words and none of them claims a block.
3321 let row = Row::new("Borealis").secondary(Prose::rich("# Goal\n\n> ship it\n\n- one\n- two"));
3322 let node = Node::list(vec![row]);
3323
3324 let html = Webview::new().fragment(&node);
3325
3326 // The outer `<ul class="list">` is the list itself; what must not appear is
3327 // a second one inside the row's own span.
3328 let secondary = html
3329 .split_once(r#"<span class="row-secondary">"#)
3330 .expect("the row draws its secondary")
3331 .1
3332 .split_once("</span>")
3333 .expect("the part closes")
3334 .0;
3335 for block in ["<h1", "<blockquote", "<ul", "<li", "<p"] {
3336 assert!(!secondary.contains(block), "no {block} in a row: {html}");
3337 }
3338 for word in ["Goal", "ship it", "one", "two"] {
3339 assert!(html.contains(word), "{word} survives: {html}");
3340 }
3341 }
3342
3343 #[test]
3344 fn a_rows_rich_prose_carries_no_second_click_target() {
3345 // The row is already the target through `activate`. An anchor inside it
3346 // would be a second target inside the first.
3347 let row = Row::new("Atlas")
3348 .secondary(Prose::rich(
3349 "see [the brief](https://example.com/a/long/path)",
3350 ))
3351 .activate(Action::get("/projects/1"));
3352 let node = Node::list(vec![row]);
3353
3354 let html = Webview::new().fragment(&node);
3355
3356 assert!(html.contains("see the brief"), "the text survives: {html}");
3357 assert!(!html.contains("example.com"), "no href: {html}");
3358 // The row itself is an anchor, drawn from `activate`. That one is the
3359 // target; the assertion is that the prose did not add a second.
3360 assert_eq!(
3361 html.matches("<a ").count(),
3362 1,
3363 "the row is the only target: {html}"
3364 );
3365 }
3366
3367 #[test]
3368 fn a_rows_rich_prose_cannot_smuggle_markup() {
3369 // `Prose::Rich` carries source, not markup, so the renderer decides what is
3370 // drawable. Raw HTML in the source is not.
3371 let row = Row::new("Atlas").secondary(Prose::rich(
3372 "hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))",
3373 ));
3374 let node = Node::list(vec![row]);
3375
3376 let html = Webview::new().fragment(&node);
3377
3378 assert!(!html.contains("<script"), "got: {html}");
3379 assert!(!html.contains("onerror"), "got: {html}");
3380 assert!(!html.contains("javascript:"), "got: {html}");
3381 }
3382
3383 #[test]
3384 fn no_control_ever_says_whether_its_answer_is_a_place() {
3385 // History is derived from the answer in quasi-http, not decided by the
3386 // control at render time. The MNW server has 24 hand-written hx-push-url
3387 // uses across 13 files, which is what asking the control looks like after
3388 // a while: each one is a prediction of what a route will do, made by the
3389 // party that does not know.
3390 let html = fragment(&Node::Table {
3391 marks: ::quasi_router::stage::Marks::none(),
3392 columns: vec![Column::new("Title").width(layout::Width::Fill)],
3393 rows: vec![
3394 Row::cells([Cell::new("Release notes").activate(Action::get("/blog/7"))])
3395 .activate(Action::get("/blog/7/edit")),
3396 ],
3397 more: None,
3398 });
3399 assert!(!html.contains("push-url"), "{html}");
3400 assert!(!html.contains("replace-url"), "{html}");
3401 }
3402
3403 #[test]
3404 fn a_screen_that_says_nothing_is_still_a_findable_page() {
3405 // The default has to be right, because most screens will never mention the
3406 // subject. Indexable, a title a preview can show, and a type.
3407 let html = render(&Screen::sidebar_content("Projects"));
3408
3409 assert!(
3410 html.contains("<meta property=\"og:title\" content=\"Projects\">"),
3411 "{html}"
3412 );
3413 assert!(
3414 html.contains("<meta property=\"og:type\" content=\"website\">"),
3415 "{html}"
3416 );
3417 assert!(!html.contains("robots"), "{html}");
3418
3419 // A None emits nothing rather than an empty tag. A preview showing a blank
3420 // line reads as a broken page, and an empty `description` is a page telling
3421 // a search engine it is about nothing.
3422 assert!(!html.contains("og:description"), "{html}");
3423 assert!(!html.contains("name=\"description\""), "{html}");
3424 assert!(!html.contains("og:image"), "{html}");
3425 assert!(!html.contains("canonical"), "{html}");
3426 }
3427
3428 /// One summary, three tags: the pair a share sheet reads and the plain one a
3429 /// search engine reads. MNW's `base.html` emitted all three and the described
3430 /// document emitted two, so `/pricing` was appending the third through
3431 /// `Shell::head`.
3432 #[test]
3433 fn a_summary_reaches_the_plain_description_meta_and_not_only_the_social_pair() {
3434 let html = render(&Screen::sidebar_content("Pricing").summarised("What you keep."));
3435
3436 assert!(
3437 html.contains("<meta name=\"description\" content=\"What you keep.\">"),
3438 "{html}"
3439 );
3440 // Beside the pair rather than instead of it. All three say the same thing
3441 // and are read by different things.
3442 assert!(
3443 html.contains("<meta property=\"og:description\" content=\"What you keep.\">"),
3444 "{html}"
3445 );
3446 assert!(
3447 html.contains("<meta name=\"twitter:description\" content=\"What you keep.\">"),
3448 "{html}"
3449 );
3450 }
3451
3452 /// A screen that has told crawlers to go away has nothing to gain from
3453 /// describing itself to them -- but a noindex page can still be linked, so the
3454 /// share sheet keeps its preview. The asymmetry is deliberate.
3455 #[test]
3456 fn a_screen_that_refuses_indexing_keeps_its_preview_and_drops_its_description() {
3457 let html = render(
3458 &Screen::sidebar_content("Downloads")
3459 .summarised("Your purchases.")
3460 .indexed(false),
3461 );
3462
3463 assert!(!html.contains("name=\"description\""), "{html}");
3464 assert!(
3465 html.contains("<meta property=\"og:description\" content=\"Your purchases.\">"),
3466 "{html}"
3467 );
3468 assert!(
3469 html.contains("<meta name=\"robots\" content=\"noindex\">"),
3470 "{html}"
3471 );
3472 }
3473
3474 #[test]
3475 fn a_purchased_content_screen_can_say_it_is_not_for_crawlers() {
3476 // Six of the server's screens. This is the assertion the whole decision
3477 // exists to buy: a conversion that drops the tag fails here rather than
3478 // exposing the URLs and being noticed in a search result.
3479 let html = render(&Screen::sidebar_content("Downloads").indexed(false));
3480 assert!(
3481 html.contains("<meta name=\"robots\" content=\"noindex\">"),
3482 "{html}"
3483 );
3484 }
3485
3486 #[test]
3487 fn a_screen_that_offers_a_feed_says_so_where_a_reader_looks() {
3488 let html = render(
3489 &Screen::sidebar_content("Blue Hour").syndicating(quasi_router::Feed::new(
3490 quasi_router::FeedKind::Rss,
3491 "Blue Hour updates",
3492 "/p/blue-hour/feed.xml",
3493 )),
3494 );
3495 // The media type comes off the kind rather than out of a template, which is
3496 // the whole of what typing it bought: `application/rss` written by hand at
3497 // one of three sites is a feed a reader skips.
3498 assert!(
3499 html.contains(
3500 "<link rel=\"alternate\" type=\"application/rss+xml\" \
3501 title=\"Blue Hour updates\" href=\"/p/blue-hour/feed.xml\">"
3502 ),
3503 "{html}"
3504 );
3505 // And a screen that offers none says nothing, rather than an empty link.
3506 assert!(
3507 !render(&Screen::sidebar_content("Blue Hour")).contains("rel=\"alternate\""),
3508 "a screen with no feed claimed one"
3509 );
3510 }
3511
3512 #[test]
3513 fn a_feed_title_is_escaped_like_every_other_string_in_the_head() {
3514 // App-authored text going into an attribute value. The rule in
3515 // `discovery_head` is that none of them is trusted for being ours.
3516 let html = render(
3517 &Screen::sidebar_content("Blog").syndicating(quasi_router::Feed::new(
3518 quasi_router::FeedKind::Atom,
3519 "A \" quote",
3520 "/feed",
3521 )),
3522 );
3523 assert!(html.contains("type=\"application/atom+xml\""), "{html}");
3524 assert!(!html.contains("title=\"A \" quote\""), "unescaped: {html}");
3525 }
3526
3527 #[test]
3528 fn the_caret_starts_where_a_whole_document_said_it_does() {
3529 let screen = Screen::single("Log in").opening_at("email").with(
3530 Slot::new("form", RegionKind::Pane).with(Node::Form {
3531 marks: ::quasi_router::stage::Marks::none(),
3532 action: Action::post("/login"),
3533 submit: "Log in".into(),
3534 fields: vec![
3535 Field::new(layout::FieldKind::Text, "email", "Email"),
3536 Field::new(layout::FieldKind::Secret, "password", "Password"),
3537 ],
3538 }),
3539 );
3540 let html = render(&screen);
3541 assert!(html.contains("autofocus"), "{html}");
3542 // Exactly one, and it is the box the screen named. The other question on
3543 // the form is not touched.
3544 assert_eq!(html.matches("autofocus").count(), 1, "{html}");
3545 let at = html.find("autofocus").expect("emitted");
3546 assert!(
3547 html[..at].rfind("\"email\"").is_some(),
3548 "the caret landed somewhere other than the named box: {html}"
3549 );
3550 }
3551
3552 #[test]
3553 fn a_name_no_question_on_the_screen_carries_moves_no_caret() {
3554 // `Screen::place`'s bargain: the screen is the app's and so is the name, so
3555 // a renderer is the wrong place to discover that an app disagrees with
3556 // itself.
3557 let html = render(
3558 &Screen::single("Log in")
3559 .opening_at("nothing-is-called-this")
3560 .with(
3561 Slot::new("form", RegionKind::Pane).with(Node::Field(Box::new(Field::new(
3562 layout::FieldKind::Text,
3563 "email",
3564 "Email",
3565 )))),
3566 ),
3567 );
3568 assert!(!html.contains("autofocus"), "{html}");
3569 }
3570
3571 #[test]
3572 fn a_fragment_never_moves_the_caret() {
3573 // The accessibility half of the decision. A browser answers most
3574 // interactions with a fragment, and a swap that steals focus takes it out
3575 // of whatever the reader was typing into.
3576 let field = Node::Field(Box::new(Field::new(
3577 layout::FieldKind::Text,
3578 "email",
3579 "Email",
3580 )));
3581 assert!(
3582 !fragment(&field).contains("autofocus"),
3583 "a fragment claimed it"
3584 );
3585 // Nor an overlay, which is a swap into a document the reader is already in.
3586 let over =
3587 Screen::single("Search")
3588 .opening_at("q")
3589 .with(
3590 Slot::new("body", RegionKind::Pane).with(Node::Field(Box::new(Field::new(
3591 layout::FieldKind::Text,
3592 "q",
3593 "Search",
3594 )))),
3595 );
3596 assert!(
3597 !Webview::new().overlay(&over).contains("autofocus"),
3598 "an overlay claimed it"
3599 );
3600 }
3601
3602 #[test]
3603 fn a_band_is_one_element_and_the_nav_is_inside_it() {
3604 // The whole reason the member exists. MNW's narrow-viewport menu is a
3605 // checkbox styling its siblings, and siblings in two parents match nothing;
3606 // split across the two emission points the menu stops opening on a phone.
3607 let document = Webview::new()
3608 .with_shell(
3609 Shell::under("/static").with_chrome(
3610 quasi_router::Chrome::new()
3611 .offering(quasi_router::Place::new(
3612 "discover",
3613 "Discover",
3614 Action::get("/discover"),
3615 ))
3616 .banded(banded()),
3617 ),
3618 )
3619 .screen(&Screen::single("Home"));
3620
3621 let band = document.find("chrome-band").expect("emitted");
3622 let nav = document.find("chrome-nav").expect("emitted");
3623 let close = document.find("</header>").expect("closed");
3624 let main = document.find("<main").expect("emitted");
3625 assert!(
3626 band < nav && nav < close,
3627 "the nav left the band: {document}"
3628 );
3629 assert!(
3630 close < main,
3631 "the band landed after the content: {document}"
3632 );
3633
3634 // The checkbox before everything it discloses: `~` reaches forward and only
3635 // forward, so a toggle written after the search box could never style it.
3636 let toggle = document.find("chrome-disclose-state").expect("emitted");
3637 let search = document.find("chrome-search").expect("emitted");
3638 assert!(toggle < search && search < nav, "{document}");
3639
3640 // And the wordmark comes apart at its mark, read whole.
3641 assert!(
3642 document.contains(">Makenot<span class=\"chrome-brand-mark\">.</span>work</a>"),
3643 "{document}"
3644 );
3645 }
3646
3647 #[test]
3648 fn a_navigating_read_that_is_not_a_click_keeps_its_transport() {
3649 // The correction in `action_attrs`. The `href` shortcut returns early, so a
3650 // navigating read on anything but a click used to come out as an `href` on
3651 // a wrapper element and no htmx at all -- a control that did nothing. A
3652 // band's search box is that shape: a field whose write navigates, emitted
3653 // on a `Fires::ChangeInside` wrapper.
3654 let document = Webview::new()
3655 .with_shell(
3656 Shell::under("/static").with_chrome(
3657 quasi_router::Chrome::new().banded(
3658 quasi_router::Band::new().searching(
3659 Field::new(layout::FieldKind::Text, "q", "Search")
3660 .writes(Action::get("/discover").navigating()),
3661 ),
3662 ),
3663 ),
3664 )
3665 .screen(&Screen::single("Home"));
3666
3667 assert!(document.contains("hx-get=\"/discover\""), "{document}");
3668 assert!(
3669 !document.contains("<div class=\"field-writes\" href="),
3670 "an href on a wrapper: {document}"
3671 );
3672 }
3673
3674 #[test]
3675 fn an_app_with_no_band_gets_the_nav_it_had_before_bands_existed() {
3676 let document = Webview::new()
3677 .with_shell(
3678 Shell::under("/static").with_chrome(quasi_router::Chrome::new().offering(
3679 quasi_router::Place::new("discover", "Discover", Action::get("/discover")),
3680 )),
3681 )
3682 .screen(&Screen::single("Home"));
3683 assert!(document.contains("chrome-nav"), "{document}");
3684 assert!(!document.contains("chrome-band"), "{document}");
3685 assert!(!document.contains("<header"), "{document}");
3686 }
3687
3688 #[test]
3689 fn a_screen_names_what_it_is_about_and_how_it_previews() {
3690 let html = render(
3691 &Screen::sidebar_content("Blue Hour")
3692 .summarised("Nine tracks recorded in one night.")
3693 .illustrated("https://makenot.work/media/cover.png")
3694 .about(quasi_router::SocialKind::Song)
3695 .canonical_at("https://makenot.work/i/7"),
3696 );
3697
3698 assert!(
3699 html.contains("content=\"Nine tracks recorded in one night.\""),
3700 "{html}"
3701 );
3702 assert!(
3703 html.contains("content=\"https://makenot.work/media/cover.png\""),
3704 "{html}"
3705 );
3706 assert!(
3707 html.contains("<meta property=\"og:type\" content=\"music.song\">"),
3708 "{html}"
3709 );
3710 assert!(
3711 html.contains("<link rel=\"canonical\" href=\"https://makenot.work/i/7\">"),
3712 "{html}"
3713 );
3714
3715 // An image means a large card. The Twitter tags are `name`, never
3716 // `property`: they were not part of RDFa, and a card written the other way
3717 // is a card the crawler skips.
3718 assert!(
3719 html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">"),
3720 "{html}"
3721 );
3722 assert!(!html.contains("property=\"twitter:"), "{html}");
3723 }
3724
3725 #[test]
3726 fn a_summary_is_escaped_because_a_person_wrote_it() {
3727 // An item description and a bio are user-authored, and they land in an
3728 // attribute value. The one place in the head where that is true.
3729 let html =
3730 render(&Screen::sidebar_content("Item").summarised("She said \"hi\" & <b>waved</b>"));
3731
3732 assert!(!html.contains("<b>waved"), "{html}");
3733 assert!(html.contains("&quot;hi&quot;"), "{html}");
3734 assert!(html.contains("&amp;"), "{html}");
3735 }
3736
3737 /// A screen exercising every [`Node`] variant and every [`RegionKind`].
3738 ///
3739 /// Written out rather than derived, for the reason `makeover-webview`'s own
3740 /// `part_class` is written out: both enums are `#[non_exhaustive]`-shaped in
3741 /// practice and there is nothing to iterate. A variant added upstream and not
3742 /// added here emits classes this file never sees, which is the one way the
3743 /// check below can be quietly weakened. Grep this function when adding a node.
3744 fn every_kind_of_screen() -> Vec<String> {
3745 let mut htmls = Vec::new();
3746
3747 for kind in [
3748 RegionKind::Band,
3749 RegionKind::Sidebar,
3750 RegionKind::Pane,
3751 RegionKind::TabGroup,
3752 RegionKind::Modal,
3753 ] {
3754 htmls.push(render(
3755 &Screen::list_detail("Everything", false)
3756 .saying(Node::banner(layout::Tone::Warning, "heads up"))
3757 .saying(Node::toast(layout::Tone::Success, "saved"))
3758 .with(Slot::new("region", kind).with(Node::text("in a region"))),
3759 ));
3760 }
3761 htmls.push(render(&Screen::sidebar_content("Everything").with(
3762 Slot::handover("bespoke", "map").with(Node::text("beside a fill")),
3763 )));
3764 htmls.push(render(&Screen::list_detail("Tabbed", true)));
3765 // Every measure, so the accounting below covers all three rather than only
3766 // the default a screen gets for saying nothing.
3767 for measure in [
3768 layout::Measure::Wide,
3769 layout::Measure::Contained,
3770 layout::Measure::Reading,
3771 ] {
3772 htmls.push(render(
3773 &Screen::list_detail("Measured", false).measured(measure),
3774 ));
3775 }
3776
3777 let acts = || Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger);
3778 for node in [
3779 Node::page("A page"),
3780 Node::section("A section"),
3781 Node::text("plain"),
3782 Node::rich("**bold** and a [link](https://example.com)"),
3783 Node::act("Save", Action::post("/save")),
3784 Node::Token(Tag::badge("Paid").tone(layout::Tone::Success)),
3785 Node::Token(Tag::chip("Open", Action::get("/tasks?open=1")).latched()),
3786 Node::banner(layout::Tone::Danger, "it broke"),
3787 Node::toast(layout::Tone::Info, "it saved"),
3788 Node::empty("nothing here").offering(acts()),
3789 Node::failed("it broke").offering(acts()),
3790 Node::field(Field::new(layout::FieldKind::Text, "name", "Name").required()),
3791 Node::field(Field::new(layout::FieldKind::Secret, "pw", "Password").error("too short")),
3792 Node::field(
3793 Field::new(layout::FieldKind::Checkbox, "live", "Live").writes(Action::post("/live")),
3794 ),
3795 Node::field(
3796 Field::new(layout::FieldKind::Text, "slug", "Slug")
3797 .consults(Action::get("/api/validate/slug").replacing("slug-status")),
3798 ),
3799 Node::field(Field::select(
3800 "size",
3801 "Size",
3802 vec![Choice::plain("small"), Choice::new("l", "large")],
3803 )),
3804 Node::field(Field::radio(
3805 "mode",
3806 "Mode",
3807 vec![Choice::plain("one"), Choice::plain("two")],
3808 )),
3809 Node::Form {
3810 marks: ::quasi_router::stage::Marks::none(),
3811 action: Action::post("/new"),
3812 submit: "Create".into(),
3813 fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
3814 },
3815 Node::list([Row::new("fw13")
3816 .secondary("a second line")
3817 .meta("2 days ago")
3818 .token(Tag::badge("Active"))
3819 .meter(Meter::new(3, 10))
3820 .activate(Action::get("/keys/7"))
3821 .act(acts())])
3822 .and_more(Rest::more(10, Action::get("/keys?page=2")).of(50)),
3823 Node::list([Row::new("astra").toggling(true, Action::post("/keys/8/pin"))]),
3824 // A pager offering numbered pages, for the `rest-page*` family.
3825 Node::list([Row::new("fw12")]).and_more(
3826 Rest::page(100, 50)
3827 .of(400)
3828 .back(Action::get("/keys?page=2"))
3829 .forward(Action::get("/keys?page=4"))
3830 .jumping(Jump::new(2, Action::get("/keys?page=2")))
3831 .jumping(Jump::new(3, Action::get("/keys?page=3")).here())
3832 .jumping(Jump::new(4, Action::get("/keys?page=4"))),
3833 ),
3834 Node::Table {
3835 marks: ::quasi_router::stage::Marks::none(),
3836 columns: vec![
3837 Column::new("Name")
3838 .width(layout::Width::Fill)
3839 .priority(layout::Priority::Essential),
3840 Column::new("Status")
3841 .width(layout::Width::Content)
3842 .priority(layout::Priority::Optional),
3843 ],
3844 rows: vec![
3845 Row::cells([
3846 Cell::new("deploy"),
3847 Cell::tag(Tag::badge("Paid").tone(layout::Tone::Success)),
3848 ])
3849 .activate(Action::get("/runs/1")),
3850 Row::cells([Cell::new("build"), Cell::acts([acts()])]),
3851 ],
3852 more: None,
3853 },
3854 Node::Meter(Meter::new(7, 10).label("7 of 10")),
3855 Node::stats([Figure::new("$12.00", "Revenue").change("+3%")]),
3856 ] {
3857 htmls.push(fragment(&node));
3858 }
3859
3860 // The one strip this renderer emits, derived rather than described: a
3861 // region showing one labelled child at a time. `Node::Select` used to
3862 // contribute three of these, one per `Selector`, and `segment` and `toggle`
3863 // left with it -- `option_class` still names them and only `makeover` emits
3864 // them now.
3865 htmls.push(fragment(&Node::Region(
3866 Slot::new("tabs", RegionKind::TabGroup)
3867 .showing_one(0)
3868 .frame(
3869 "Open",
3870 Node::Region(Slot::new("open", RegionKind::Group).with(Node::text("what is open"))),
3871 )
3872 .frame(
3873 "Done",
3874 Node::Region(
3875 Slot::new("done", RegionKind::Group).fed_by(Action::get("/tasks/done")),
3876 ),
3877 ),
3878 )));
3879
3880 htmls
3881 }
3882
3883 /// Every class name the markup carries, from `class="a b c"` attributes.
3884 ///
3885 /// Column identity classes are dropped. `col-Name` is `column_classes`'s own
3886 /// output and names the column rather than the vocabulary, so it is data and
3887 /// there is nothing for a stylesheet to define.
3888 fn emitted_classes(htmls: &[String]) -> std::collections::BTreeSet<String> {
3889 let mut names = std::collections::BTreeSet::new();
3890 for html in htmls {
3891 let mut rest = html.as_str();
3892 while let Some(at) = rest.find("class=\"") {
3893 rest = &rest[at + 7..];
3894 let end = rest.find('"').expect("the attribute closes");
3895 for name in rest[..end].split_whitespace() {
3896 if !name.starts_with("col-") {
3897 names.insert(name.to_string());
3898 }
3899 }
3900 rest = &rest[end..];
3901 }
3902 }
3903 names
3904 }
3905
3906 /// Classes this renderer emits that no rule in the generated stylesheet names,
3907 /// because there is nothing for makeover to say about them.
3908 ///
3909 /// Arrangements and regions are placement, and placement is spacing:
3910 /// `makeover-geometry`'s question, answered per app in `styles.css`. The rest
3911 /// are containers holding things makeover styles one by one, or elements that
3912 /// are already an element before they are a class.
3913 ///
3914 /// This list is the boundary written down, not a todo. A *component* joining it
3915 /// is the bug, and the test is what refuses one.
3916 const BY_DESIGN: &[&str] = &[
3917 // Arrangements, from `Webview::arrangement_class`.
3918 "list-detail",
3919 "list-detail-tabbed",
3920 "sidebar-content",
3921 // Measures, from `Screen::measure`. Placement for the same reason an
3922 // arrangement is: the screen says which of the three it is, and what a
3923 // measure means in pixels is the app's stylesheet answering once instead of
3924 // 69 templates answering separately. `0eccff0d` moved the choice, not the
3925 // number.
3926 "measure-wide",
3927 "measure-contained",
3928 "measure-reading",
3929 // Regions, from `region_class`.
3930 "band",
3931 "bespoke",
3932 // A `RegionKind::Group` came out of no fixture until a tab strip needed
3933 // labelled children, so this sat unlisted rather than accounted-for. It
3934 // belongs beside its siblings: a group is an arrangement, and what a
3935 // grouping costs in space is the app's stylesheet answering once.
3936 "group",
3937 "modal",
3938 "pane",
3939 "region",
3940 "sidebar",
3941 "tabgroup",
3942 // Containers. Each holds things that carry their own styled classes.
3943 //
3944 // `anchored` is the popover container an `Outcome::Anchored` lands in, and
3945 // it has no rule upstream for the reason the rest of this list has none:
3946 // where a menu sits relative to the thing it opened at is the app's
3947 // stylesheet answering once. It is emitted empty and `hidden`, so until an
3948 // app writes that rule it is invisible rather than wrong.
3949 "anchored",
3950 "figures",
3951 "form",
3952 "notices",
3953 "rest",
3954 "row",
3955 "selector",
3956 // Typography. makeover sets no type scale, so a heading and a paragraph
3957 // are an `h2` and a `p` before they are anything this crate named.
3958 "heading",
3959 "rich",
3960 "text",
3961 ];
3962
3963 /// Classes that name *which* of something, on an element already styled by the
3964 /// class beside them.
3965 ///
3966 /// `class="button act-submit"` takes its whole appearance from `button`. The
3967 /// second name exists so an app can reach the submit button of a form without
3968 /// reaching every button, and a rule for it upstream would be makeover deciding
3969 /// that a submit button looks different, which is the app's call.
3970 ///
3971 /// The row parts are makeover's own `part_class` output. `row_rules` writes a
3972 /// colour for `Primary`, `Secondary` and `Meta` and deliberately none for these
3973 /// three: actions carry controls, and tokens and proportions each carry their
3974 /// own tone, so a colour on the container would fight what is inside it.
3975 const MODIFIERS: &[&str] = &[
3976 "act-submit",
3977 // The pager's three parts. `rest-more` was the single load-more button
3978 // these replaced; a pager that can also go back needs to name both ends and
3979 // the position between them, and each end is present whether or not it can
3980 // be pressed so the control keeps its width on the first page and the last.
3981 "rest-next",
3982 // The numbered strip and its two spellings of a page. makeover names no
3983 // pager at all -- `rest` is this renderer's own -- so every part of one is
3984 // a modifier here. `rest-page-here` is a state on `rest-page` rather than a
3985 // name of its own: the page the reader is on is drawn as text and not as a
3986 // control, so the base name is what both share.
3987 "rest-page",
3988 "rest-page-here",
3989 "rest-pages",
3990 "rest-position",
3991 "rest-previous",
3992 "row-actions",
3993 "row-activate",
3994 "row-proportion",
3995 "row-select",
3996 "row-selected",
3997 "row-tokens",
3998 "cell-actions",
3999 "cell-tokens",
4000 "field-consults",
4001 "field-writes",
4002 ];
4003
4004 /// Classes that reach the markup with no rule anywhere, which is a gap rather
4005 /// than a decision.
4006 ///
4007 /// Every one is a component: something makeover-layout names, that a described
4008 /// screen produces, that arrives unstyled. This is the same failure the
4009 /// SSH-keys tab found, one layer up — the name is not invented here, it is
4010 /// correct and nothing defines it.
4011 ///
4012 /// Three separate causes, none of them fixable in this crate alone:
4013 ///
4014 /// - `form-*`, `has-error`, `visible` and `placeholder-action` are emitted by
4015 /// `makeover_webview::form` and `::placeholder` and styled by no rule that
4016 /// crate's own `stylesheet` writes. A field's anatomy is makeover's from end
4017 /// to end, so both halves are over there.
4018 /// - `cell-fill` and `cell-keeps` come off `column_classes`, and the rules that
4019 /// make them mean anything come off `list::narrowing_css`, which needs the
4020 /// columns and is therefore per-table. Nothing calls it here, so a described
4021 /// table has no column tracks and no narrowing: every column is content-width
4022 /// and none of them ever drops. goingson generates its `tables.css` from it in
4023 /// its own `build.rs`, which is the shape a described table cannot use,
4024 /// because its columns are known at render time and not at build time.
4025 /// - `banner` and `toast` are `makeover_layout::Notice`, which the description
4026 /// layer names and `component_rules` has no section for.
4027 ///
4028 /// Shrinking this list is the work. Growing it needs a reason written here.
4029 const GAPS: &[&str] = &[
4030 "form-checkbox-label",
4031 "form-error",
4032 "form-group",
4033 "form-label",
4034 "form-radio-group",
4035 "form-radio-label",
4036 "has-error",
4037 "visible",
4038 "placeholder-action",
4039 "cell-fill",
4040 "cell-keeps",
4041 "banner",
4042 "toast",
4043 ];
4044
4045 #[test]
4046 fn every_class_this_renderer_emits_is_one_makeover_defines() {
4047 // The invariant the SSH-keys tab found three counterexamples to, checked
4048 // by enumeration rather than by remembering the three. `act`, `tone-danger`
4049 // and `chip-latched` were each a name this renderer made up, and each one
4050 // rendered a described control as unstyled text beside a hand-written one
4051 // that had a rule. A fourth is a matter of time without this.
4052 let css = makeover_webview::stylesheet(&Emit::default());
4053 let emitted = emitted_classes(&every_kind_of_screen());
4054
4055 // Whole-name matching. `.row` is in the stylesheet and `.row-primary`
4056 // starts with it, so a substring search would call every misspelling styled.
4057 let styled = |name: &str| {
4058 css.match_indices(&format!(".{name}")).any(|(at, found)| {
4059 css[at + found.len()..]
4060 .chars()
4061 .next()
4062 .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
4063 })
4064 };
4065
4066 let unstyled: Vec<&str> = emitted
4067 .iter()
4068 .map(String::as_str)
4069 .filter(|name| !styled(name))
4070 .collect();
4071
4072 let mut accounted: Vec<&str> = [BY_DESIGN, MODIFIERS, GAPS].concat();
4073 accounted.sort_unstable();
4074 assert_eq!(
4075 accounted.len(),
4076 accounted
4077 .iter()
4078 .collect::<std::collections::BTreeSet<_>>()
4079 .len(),
4080 "a name is in two of the three lists, which means two answers to one \
4081 question"
4082 );
4083
4084 assert_eq!(
4085 unstyled, accounted,
4086 "a class this renderer emits has no rule and no entry above. If \
4087 makeover spells it differently, use makeover's spelling -- that is the \
4088 whole of the SSH-keys bug. Otherwise put it in BY_DESIGN, MODIFIERS or \
4089 GAPS with the reason, and note that GAPS is work rather than a \
4090 decision."
4091 );
4092 }
4093
4094 #[test]
4095 fn a_class_prefix_reaches_the_markup_the_way_it_reaches_the_stylesheet() {
4096 // The prefix is one setting shared by two emitters, and the failure is
4097 // silent in the same way: a prefixed app whose renderer forgot the prefix
4098 // on one element gets a stylesheet that matches everything except that
4099 // element.
4100 let emit = Emit {
4101 class_prefix: "mk-",
4102 ..Emit::default()
4103 };
4104 let html = Webview::new()
4105 .with_emit(emit)
4106 .fragment(&Node::list([Row::new("fw13").token(Tag::badge("Active"))]));
4107
4108 assert!(html.contains("class=\"mk-row\""), "{html}");
4109 assert!(html.contains("mk-row-primary"), "{html}");
4110 assert!(html.contains("mk-badge"), "{html}");
4111 assert!(!html.contains("\"row\""), "{html}");
4112 }
4113
4114 #[test]
4115 fn an_invalidated_slot_is_addressed_by_name_and_swapped_inside_its_region() {
4116 // `innerHTML:` rather than a bare `true`, because a bare one replaces the
4117 // element carrying the id and that element is the region `slot_html`
4118 // emitted, classes and all. The region would keep its contents and lose
4119 // its layout.
4120 let html = Webview::new().invalidated("task-count", &Node::text("4 left"));
4121
4122 assert!(
4123 html.starts_with("<div hx-swap-oob=\"innerHTML:#task-count\">"),
4124 "{html}"
4125 );
4126 assert!(html.contains("4 left"), "{html}");
4127 assert!(html.ends_with("</div>"), "{html}");
4128 }
4129
4130 #[test]
4131 fn a_slot_id_cannot_break_out_of_the_out_of_band_selector() {
4132 // A slot id reaches this from the description, and the description is the
4133 // app's. It is escaped for the same reason `slot_html` escapes it.
4134 let html = Webview::new().invalidated("a\"><script>x</script>", &Node::text("hi"));
4135 assert!(!html.contains("<script>"), "{html}");
4136 }
4137
4138 #[test]
4139 fn a_tick_carries_the_value_it_contributes_to_the_selection() {
4140 // `5f2b8753`. A checkbox with no value submits `on`, which says a box was
4141 // checked and not which one, so every app gathered them in JS instead.
4142 let html = fragment(&Node::list([
4143 Row::new("First").ticking("m-1", false),
4144 Row::new("Second").ticking("m-2", true),
4145 ]));
4146
4147 assert!(html.contains("name=\"ticked\" value=\"m-1\""), "{html}");
4148 assert!(html.contains("name=\"ticked\" value=\"m-2\""), "{html}");
4149 assert_eq!(html.matches("checked").count(), 1, "{html}");
4150 }
4151
4152 #[test]
4153 fn a_commit_control_carries_the_hook_the_selection_script_reads() {
4154 // Two things no description can say: how many rows are ticked, and that a
4155 // control over none of them should not be pressable. The ticks are the
4156 // browser's until something submits them, so the browser says both -- and
4157 // this is the whole of what it needs emitted to do it.
4158 let act = fragment(&Node::Act(
4159 Act::new("Complete", Action::post("/tasks/list/complete")).over("chosen"),
4160 ));
4161 assert!(act.contains("data-over=\"chosen\""), "{act}");
4162
4163 // And an ordinary control does not, so the script never touches it.
4164 let plain = fragment(&Node::Act(Act::new("Save", Action::post("/save"))));
4165 assert!(!plain.contains("data-over"), "{plain}");
4166 }
4167
4168 #[test]
4169 fn a_control_that_asks_for_a_value_reveals_the_box_and_sends_it_with_the_ticks() {
4170 // `033ff3ca`. MNW's bulk bar presses "Set Price", reveals a box, and applies
4171 // it to whatever is ticked. `details` is the browser's own disclosure, so
4172 // none of that is script here.
4173 let html = fragment(&Node::Act(
4174 Act::new("Set Price", Action::post("/items/price"))
4175 .over("chosen")
4176 .asking(
4177 Field::new(layout::FieldKind::Number, "price", "New price ($)")
4178 .hint("Enter 0 to make items free."),
4179 ),
4180 ));
4181
4182 assert!(html.starts_with("<details"), "{html}");
4183 assert!(html.contains("<summary"), "{html}");
4184 assert!(html.contains("Set Price"), "{html}");
4185 assert!(html.contains("name=\"price\""), "{html}");
4186 assert!(html.contains("Enter 0 to make items free."), "{html}");
4187 // What the press sends: the box it revealed, and the ticks it acts over.
4188 assert!(html.contains("closest details"), "{html}");
4189 assert!(html.contains("row-select"), "{html}");
4190 // The count and the refusal land on the control that fires, not on the
4191 // summary that opens it.
4192 assert_eq!(html.matches("data-over=\"chosen\"").count(), 1, "{html}");
4193 }
4194
4195 #[test]
4196 fn a_box_a_control_asked_for_does_not_also_write_on_its_own() {
4197 // It is answered by the press, so a write of its own would send the value
4198 // twice: once as it was typed, once when the verb fired.
4199 let html = fragment(&Node::Act(
4200 Act::new("Add Tag", Action::post("/items/tag")).asking(
4201 Field::new(layout::FieldKind::Text, "tag", "Tag slug")
4202 .writes(Action::post("/items/tag/live")),
4203 ),
4204 ));
4205
4206 assert!(!html.contains("/items/tag/live"), "{html}");
4207 assert!(!html.contains("data-over"), "{html}");
4208 }
4209
4210 #[test]
4211 fn the_selection_script_is_linked_after_htmx_and_can_be_left_out() {
4212 let with = Webview::new().screen(&Screen::list_detail("A", false));
4213 let htmx = with.find("htmx.min.js").expect("htmx is linked");
4214 let selection = with
4215 .find("quasi-selection.js")
4216 .expect("the selection script is linked");
4217 // It listens for htmx's own settle event, so binding before htmx exists
4218 // would bind to an event nothing dispatches.
4219 assert!(htmx < selection, "{with}");
4220
4221 // Dropping it is a supported shape and it fails safe: a commit control
4222 // stays enabled and says no count, which is what
4223 // every screen did before the script existed.
4224 let without = Webview::new()
4225 .with_shell(Shell::default().without_selection())
4226 .screen(&Screen::list_detail("A", false));
4227 assert!(!without.contains("quasi-selection.js"), "{without}");
4228 }
4229
4230 #[test]
4231 fn the_selection_script_reads_only_the_hooks_this_crate_emits() {
4232 // It ships from here rather than being copied per app so that the script
4233 // and the markup that feeds it move together. This is the assertion that
4234 // they still agree: both hooks it looks for are emitted above.
4235 assert!(crate::SELECTION_JS.contains(".row-select"), "the tick hook");
4236 assert!(
4237 crate::SELECTION_JS.contains("[data-over]"),
4238 "the commit hook"
4239 );
4240 // And that it names nothing of any app's. The script is shipped by a
4241 // renderer and read by every app, so an app's noun in it -- even in a
4242 // comment, even as an example -- is the coupling this whole layer exists to
4243 // refuse.
4244 for word in ["task", "email", "contact", "goingson"] {
4245 assert!(
4246 !crate::SELECTION_JS.to_lowercase().contains(word),
4247 "the script names {word}"
4248 );
4249 }
4250 }
4251
4252 #[test]
4253 fn a_table_row_joins_the_same_selection_a_list_row_does() {
4254 // The member a table row deliberately did not have until goingson's task list
4255 // asked for it. The class is the one a list row's tick carries, and that is
4256 // the point: a commit control gathers by it, so a screen holding a list and
4257 // a table holds one selection rather than two that look alike.
4258 let html = fragment(&Node::Table {
4259 marks: ::quasi_router::stage::Marks::none(),
4260 columns: vec![Column::new("title")],
4261 rows: vec![
4262 Row::cells(["First"]).ticking("t-1", false),
4263 Row::cells(["Second"]).ticking("t-2", true),
4264 ],
4265 more: None,
4266 });
4267
4268 assert!(html.contains("name=\"ticked\" value=\"t-1\""), "{html}");
4269 assert!(html.contains("name=\"ticked\" value=\"t-2\""), "{html}");
4270 assert_eq!(html.matches("class=\"row-select\"").count(), 2, "{html}");
4271 assert_eq!(html.matches(" checked").count(), 1, "{html}");
4272 }
4273
4274 #[test]
4275 fn a_table_row_offers_what_it_does_not_show() {
4276 // `Row::menu`, the member audiofiles' file list asked for. One fact on one
4277 // type since the 2026-09-05 collapse, so it emits the same
4278 // `data-menu="row"` a host already binds to.
4279 let html = fragment(&Node::Table {
4280 marks: ::quasi_router::stage::Marks::none(),
4281 columns: vec![Column::new("title"), Column::new("size")],
4282 rows: vec![
4283 Row::cells(["kick.wav", "2.1 MB"])
4284 .offers(Act::new("Preview", Action::post("/files/1/preview")))
4285 .offers(
4286 Act::new("Delete", Action::post("/files/1/delete")).confirm("Delete kick.wav?"),
4287 ),
4288 ],
4289 more: None,
4290 });
4291
4292 assert!(html.contains(r#"data-menu="row""#), "{html}");
4293 assert!(html.contains("class=\"table-row-menu\""), "{html}");
4294 // Hidden rather than absent, for the list row's reason: the host opens it,
4295 // and a menu that is not in the document cannot be opened.
4296 assert!(html.contains(" hidden>"), "{html}");
4297 assert!(html.contains("Preview"), "{html}");
4298 assert!(html.contains(r#"hx-confirm="Delete kick.wav?""#), "{html}");
4299
4300 // The menu takes no column, so the head is unchanged and the body row still
4301 // holds exactly the two cells the columns name. This is the assertion that
4302 // would catch a menu emitted as a cell: the tick above is one because it is
4303 // drawn in line, and this is not.
4304 assert_eq!(html.matches("role=\"columnheader\"").count(), 2, "{html}");
4305 assert!(!html.contains("table-select"), "{html}");
4306 }
4307
4308 #[test]
4309 fn a_table_row_that_offers_nothing_emits_no_menu_container() {
4310 // The empty case, asserted rather than assumed: an empty `Vec<Act>` is what
4311 // every row of every table already carries, so a container emitted
4312 // unconditionally would be one hidden div per row of every table in the tree.
4313 let html = fragment(&Node::Table {
4314 marks: ::quasi_router::stage::Marks::none(),
4315 columns: vec![Column::new("title")],
4316 rows: vec![Row::cells(["kick.wav"])],
4317 more: None,
4318 });
4319 assert!(!html.contains("data-menu"), "{html}");
4320 assert!(!html.contains("table-row-menu"), "{html}");
4321 }
4322
4323 #[test]
4324 fn a_tick_takes_a_gutter_cell_in_the_head_as_well_as_the_body() {
4325 // `display: table` aligns by position, so a body row with one more cell
4326 // than the head puts every heading one place left of the values under it.
4327 let ticked = fragment(&Node::Table {
4328 marks: ::quasi_router::stage::Marks::none(),
4329 columns: vec![Column::new("title"), Column::new("size")],
4330 rows: vec![Row::cells(["kick.wav", "2.1 MB"]).ticking("t-1", false)],
4331 more: None,
4332 });
4333 assert_eq!(
4334 ticked.matches("role=\"columnheader\"").count(),
4335 3,
4336 "{ticked}"
4337 );
4338 assert_eq!(ticked.matches("class=\"cell").count(), 3, "{ticked}");
4339
4340 // And a table nobody can tick grows no gutter at all.
4341 let plain = fragment(&Node::Table {
4342 marks: ::quasi_router::stage::Marks::none(),
4343 columns: vec![Column::new("title"), Column::new("size")],
4344 rows: vec![Row::cells(["kick.wav", "2.1 MB"])],
4345 more: None,
4346 });
4347 assert_eq!(plain.matches("role=\"columnheader\"").count(), 2, "{plain}");
4348 assert!(!plain.contains("row-select"), "{plain}");
4349 }
4350
4351 #[test]
4352 fn a_field_that_writes_without_a_selection_gathers_only_itself() {
4353 let html = fragment(&Node::field(
4354 Field::select("folder", "Folder", vec![Choice::plain("Inbox")])
4355 .writes(Action::get("/emails/list")),
4356 ));
4357
4358 assert_eq!(html.matches("hx-include").count(), 1, "{html}");
4359 assert!(!html.contains(".row-select"), "{html}");
4360 }
4361
4362 #[test]
4363 fn a_commit_control_gathers_every_tick_on_the_screen() {
4364 // Declarative, because a checkbox already submits its own name and value:
4365 // all that was missing was something saying which boxes belong together.
4366 // That is the whole of what the per-app gathering JS did.
4367 let html = fragment(&Node::Act(
4368 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
4369 ));
4370
4371 assert!(html.contains("hx-include=\".row-select\""), "{html}");
4372 assert!(html.contains("hx-post=\"/mail/archive\""), "{html}");
4373 }
4374
4375 #[test]
4376 fn a_control_over_nothing_gathers_nothing() {
4377 let html = fragment(&Node::Act(Act::new(
4378 "Delete",
4379 Action::post("/mail/1/delete"),
4380 )));
4381 assert!(!html.contains("hx-include"), "{html}");
4382 }
4383
4384 #[test]
4385 fn the_gathering_selector_follows_a_hosts_class_prefix() {
4386 // A host setting `Emit::class_prefix` moves the class the ticks carry, and
4387 // the selector has to move with it or the commit control gathers nothing.
4388 let emit = Emit {
4389 class_prefix: "q-",
4390 ..Emit::default()
4391 };
4392 let render = Webview::new().with_emit(emit);
4393
4394 let boxes = render.fragment(&Node::list([Row::new("First").ticking("m-1", false)]));
4395 let button = render.fragment(&Node::Act(
4396 Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
4397 ));
4398
4399 assert!(boxes.contains("q-row-select"), "{boxes}");
4400 assert!(button.contains("hx-include=\".q-row-select\""), "{button}");
4401 }
4402
4403 #[test]
4404 fn an_overlay_is_the_inside_of_a_container_and_not_a_document() {
4405 // The whole difference from a screen: what is under it keeps its document.
4406 let screen = Screen::sidebar_content("Palette")
4407 .with(Slot::new("results", RegionKind::Pane).with(Node::text("Open task")));
4408 let html = Webview::new().overlay(&screen);
4409 assert!(!html.contains("<html"), "{html}");
4410 assert!(!html.contains("<body"), "{html}");
4411 assert!(!html.contains("<title"), "{html}");
4412 assert!(html.contains("Open task"), "{html}");
4413 }
4414
4415 #[test]
4416 fn an_overlay_names_the_container_it_lands_in() {
4417 // What `quasi-http` turns into the retarget header. A renderer answering
4418 // `None` here is one with no overlay container, and this one has one.
4419 assert_eq!(
4420 Webview::new().overlay_target(),
4421 Some(crate::chrome::OVERLAY_ID)
4422 );
4423 }
4424
4425 #[test]
4426 fn a_document_carries_the_apps_bindings_and_the_container_they_open_into() {
4427 use quasi_router::Chrome;
4428
4429 let shell = Shell::default().with_chrome(Chrome::new().bind(
4430 "ctrl+k",
4431 "Search",
4432 Action::get("/palette"),
4433 ));
4434 let html = Webview::new()
4435 .with_shell(shell)
4436 .screen(&Screen::list_detail("Tasks", false));
4437 assert!(html.contains("hx-get=\"/palette\""), "{html}");
4438 assert!(html.contains("from:body"), "{html}");
4439 assert!(html.contains("id=\"quasi-overlay\""), "{html}");
4440 // After the content and before the body closes: chrome is the app's, so it
4441 // sits outside what a screen's markup is.
4442 let overlay_at = html.find("id=\"quasi-overlay\"").expect("emitted");
4443 assert!(
4444 overlay_at > html.find("</main>").expect("main closes"),
4445 "{html}"
4446 );
4447 assert!(html.ends_with("</body></html>"), "{html}");
4448 }
4449
4450 #[test]
4451 fn an_app_declaring_no_chrome_gets_the_document_it_always_got() {
4452 // What makes this additive: nothing is emitted, so nothing moved.
4453 let html = render(&Screen::list_detail("Tasks", false));
4454 assert!(!html.contains("quasi-overlay"), "{html}");
4455 assert!(!html.contains("data-chrome"), "{html}");
4456 }
4457
4458 /// A carousel: three frames, the second up, and no label anywhere.
4459 fn gallery() -> Slot {
4460 Slot::widget("shots", "carousel")
4461 .extend((0..3).map(|n| {
4462 Node::Image(quasi_router::screen::Image::new(
4463 format!("/shot-{n}.png"),
4464 format!("shot {n}"),
4465 ))
4466 }))
4467 .showing_one(1)
4468 }
4469
4470 #[test]
4471 fn a_region_showing_everything_emits_exactly_what_it_always_did() {
4472 // The whole additive claim. `Showing::All` is the default, so every
4473 // description written before the member existed has to come out unchanged:
4474 // no wrapper, no hook, no row.
4475 let screen = Screen::list_detail("Tasks", false)
4476 .with(Slot::new("main", RegionKind::Pane).with(Node::text("plain")));
4477 let html = render(&screen);
4478
4479 assert!(!html.contains("data-showing"));
4480 assert!(!html.contains("showing-frame"));
4481 assert!(!html.contains("showing-position"));
4482 }
4483
4484 #[test]
4485 fn a_carousel_gets_a_row_without_this_renderer_knowing_what_a_carousel_is() {
4486 // The point of the whole design. Nothing below reads the widget's name, and
4487 // a second assembly showing one child at a time gets the same row for free.
4488 let screen = Screen::list_detail("Product", false).with(gallery());
4489 let html = render(&screen);
4490
4491 assert!(html.contains("data-showing=\"one\""), "{html}");
4492 assert!(html.contains("data-shows=\"previous\""), "{html}");
4493 assert!(html.contains("data-shows=\"next\""), "{html}");
4494 // Position counts from one for a reader, and off `current()` rather than
4495 // off `shown`, so a clamped index reports where the frame actually is.
4496 assert!(html.contains(">2 / 3</span>"), "{html}");
4497 // The name is still there and is still nobody's business here.
4498 assert!(html.contains("data-widget=\"carousel\""));
4499 }
4500
4501 #[test]
4502 fn the_row_sits_under_the_frames_and_overlays_nothing() {
4503 // Max, 2026-08-14: the shipped arrows were absolutely positioned over the
4504 // picture, which a terminal cannot honestly do and which read as clutter
4505 // here. In flow, after the content, on every host.
4506 let html = render(&Screen::list_detail("Product", false).with(gallery()));
4507
4508 let last_frame = html.rfind("showing-frame").expect("frames are wrapped");
4509 let row = html.rfind("showing-position").expect("a row is derived");
4510 assert!(row > last_frame, "{html}");
4511 }
4512
4513 #[test]
4514 fn only_the_current_frame_is_marked_and_the_rest_are_still_in_the_document() {
4515 // Degradation runs toward more content. Every frame ships; the rule that
4516 // collapses them waits for whatever binds the region, so a reader with no
4517 // script gets the whole gallery instead of one frame and two dead buttons.
4518 let html = render(&Screen::list_detail("Product", false).with(gallery()));
4519
4520 assert_eq!(html.matches("showing-frame").count(), 3, "{html}");
4521 assert_eq!(html.matches("showing-frame current").count(), 1, "{html}");
4522 assert!(html.contains("/shot-0.png") && html.contains("/shot-2.png"));
4523 }
4524
4525 #[test]
4526 fn labelled_children_get_a_strip_and_it_is_makeovers_tab_markup() {
4527 // A tab strip is already a described thing. Deriving a second spelling of
4528 // one is how `tabs` and `segmented` came to render flat.
4529 let screen = Screen::list_detail("Project", false).with(
4530 Slot::new("detail", RegionKind::TabGroup)
4531 .frame(
4532 "Overview",
4533 Node::Region(Slot::new("overview", RegionKind::Pane)),
4534 )
4535 .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane)))
4536 .showing_one(1),
4537 );
4538 let html = render(&screen);
4539
4540 assert!(html.contains("data-selector=\"tab\""), "{html}");
4541 assert!(html.contains("role=\"tablist\""), "{html}");
4542 assert!(html.contains(">Overview</button>"), "{html}");
4543 assert!(html.contains("chosen\" data-shows=\"1\""), "{html}");
4544 assert!(html.contains("aria-selected=\"true\""), "{html}");
4545 assert!(html.contains("aria-selected=\"false\""), "{html}");
4546 // The words are the last thing on the control, after the program that
4547 // moves the frame. `0081563e` put that program between them, which is why
4548 // the two are asserted apart rather than as one string.
4549 assert!(html.contains(">Files</button>"), "{html}");
4550 assert!(html.contains(">Overview</button>"), "{html}");
4551 // A strip, not a counter row: the labels are what the reader steers by.
4552 assert!(!html.contains("showing-position"), "{html}");
4553 }
4554
4555 #[test]
4556 fn a_tab_whose_panel_is_a_route_carries_the_address_and_the_panel_does_not() {
4557 // `dfbc88ce`. The strip is what calls the route, so the four panels nobody
4558 // pressed do not fetch on load. MNW's library page is the measurement: five
4559 // tabs, five database reads per page view under the previous emission.
4560 let screen = Screen::list_detail("Library", false).with(
4561 Slot::new("tab-content", RegionKind::TabGroup)
4562 // The shown panel came with the screen. `9b958e7b`: no placeholder
4563 // before first content, and it is the panel being looked at that
4564 // would have had one.
4565 .frame(
4566 "Purchases",
4567 Node::Region(
4568 Slot::new("purchases", RegionKind::Pane).with(Node::text("what you bought")),
4569 ),
4570 )
4571 .frame(
4572 "Feed",
4573 Node::Region(
4574 Slot::new("feed", RegionKind::Pane).fed_by(Action::get("/library/tabs/feed")),
4575 ),
4576 )
4577 .showing_one(0),
4578 );
4579 let html = render(&screen);
4580
4581 // The address is on the control that was pressed.
4582 assert!(
4583 html.contains("aria-selected=\"false\" hx-get=\"/library/tabs/feed\""),
4584 "{html}"
4585 );
4586 // And not on the panel, which is the whole point: one `load` here is five
4587 // requests on the page this was measured against.
4588 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
4589 // A panel nobody has asked for is not waiting on anything.
4590 assert!(!html.contains("aria-busy"), "{html}");
4591 // The frame still moves, so both marks are on the button: htmx makes the
4592 // request and the binder shows the frame.
4593 assert!(html.contains("data-shows=\"1\""), "{html}");
4594 // The shown panel came with the screen. `9b958e7b`: no placeholder before
4595 // first content, and it is the panel being looked at that would have had one.
4596 assert!(html.contains("what you bought"), "{html}");
4597 }
4598
4599 #[test]
4600 fn a_tab_is_a_button_even_though_its_address_is_a_read() {
4601 // The one place `control_tag`'s rule inverts. A read of a route is normally
4602 // an anchor so middle-click and copy-link work, and a tab's address answers
4603 // with a fragment: following that link lands on a shell-less scrap. So the
4604 // strip keeps its button and the address stays on `hx-get` alone.
4605 let screen = Screen::list_detail("Library", false).with(
4606 Slot::new("tab-content", RegionKind::TabGroup)
4607 .frame(
4608 "Purchases",
4609 Node::Region(
4610 Slot::new("purchases", RegionKind::Pane)
4611 .fed_by(Action::get("/library/tabs/purchases")),
4612 ),
4613 )
4614 .frame(
4615 "Feed",
4616 Node::Region(
4617 Slot::new("feed", RegionKind::Pane).fed_by(Action::get("/library/tabs/feed")),
4618 ),
4619 )
4620 .showing_one(0),
4621 );
4622 let html = render(&screen);
4623
4624 assert!(!html.contains("href="), "{html}");
4625 assert!(
4626 html.contains("<button type=\"button\" class=\"tab"),
4627 "{html}"
4628 );
4629 }
4630
4631 #[test]
4632 fn a_carousel_of_frames_already_here_still_calls_no_route() {
4633 // The other half of the same rule, and the reason nothing reads the region's
4634 // kind: presence picks between them. A gallery's frames are downloaded, so
4635 // its controls move between them and ask for nothing.
4636 let screen = Screen::list_detail("Project", false).with(
4637 Slot::new("detail", RegionKind::TabGroup)
4638 .frame(
4639 "Overview",
4640 Node::Region(Slot::new("overview", RegionKind::Pane)),
4641 )
4642 .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane)))
4643 .showing_one(0),
4644 );
4645 let html = render(&screen);
4646
4647 assert!(html.contains("data-shows=\"1\""), "{html}");
4648 assert!(!html.contains("hx-get"), "{html}");
4649 }
4650
4651 #[test]
4652 fn a_carousel_carries_the_program_that_moves_its_frames() {
4653 // `0081563e`. The frames are downloaded already, so stepping between them
4654 // is local, and until this landed the buttons were marks a host had to bind
4655 // before a described gallery worked at all. Nothing hand-written: the
4656 // program is emitted from the description, which is the whole of what the
4657 // attribute line permits.
4658 let html = render(&Screen::list_detail("Product", false).with(gallery()));
4659
4660 // Every frame says which region it belongs to and which child it is. That
4661 // is what a nested region needs: a tab group over a gallery must not step
4662 // through the gallery's frames.
4663 assert_eq!(html.matches("data-frame=\"shots\"").count(), 3, "{html}");
4664 assert!(html.contains("data-shown=\"2\""), "{html}");
4665 // The counter is addressed as well as classed, because the step writes into
4666 // it and a class is what a stylesheet may rename.
4667 assert!(html.contains("data-position=\"shots\""), "{html}");
4668 // Both directions carry a program, and the wrap is in the arithmetic rather
4669 // than in a branch.
4670 assert_eq!(html.matches(" _=\"on click").count(), 2, "{html}");
4671 assert!(html.contains("mod 3"), "{html}");
4672 }
4673
4674 #[test]
4675 fn a_tab_moves_the_frame_and_carries_the_strips_marks_with_it() {
4676 // Three things say the same fact and all three move together: the frame
4677 // that is current, the button that is chosen, and `aria-selected`, which is
4678 // what a screen reader hears.
4679 let screen = Screen::list_detail("Project", false).with(
4680 Slot::new("detail", RegionKind::TabGroup)
4681 .frame(
4682 "Overview",
4683 Node::Region(Slot::new("overview", RegionKind::Pane)),
4684 )
4685 .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane)))
4686 .showing_one(0),
4687 );
4688 let html = render(&screen);
4689
4690 assert!(html.contains("take .chosen"), "{html}");
4691 assert!(
4692 html.contains("set @aria-selected of me to &#39;true&#39;"),
4693 "{html}"
4694 );
4695 assert!(
4696 html.contains("take .current from &lt;[data-frame=&#39;detail&#39;]/&gt;"),
4697 "{html}"
4698 );
4699 // A strip has no counter row, so nothing writes a position.
4700 assert!(!html.contains("data-position"), "{html}");
4701 }
4702
4703 #[test]
4704 fn a_disclosure_opens_and_closes_the_one_child_under_it() {
4705 // The only control here that toggles rather than picks, and the one place
4706 // `aria-expanded` is read back off the frame rather than tracked.
4707 let screen = Screen::list_detail("Item", false).with(
4708 Slot::new("more", RegionKind::Group)
4709 .frame("Details", Node::Region(Slot::new("body", RegionKind::Pane)))
4710 .showing_at_most_one(Some(0)),
4711 );
4712 let html = render(&screen);
4713
4714 assert!(html.contains("toggle .current"), "{html}");
4715 assert!(html.contains("set @aria-expanded of me"), "{html}");
4716 }
4717
4718 #[test]
4719 fn a_region_showing_everything_carries_no_program_either() {
4720 // The additive claim, extended. A description written before `Showing`
4721 // existed emits no chrome, so there is nothing local to perform and nothing
4722 // for an interpreter to read.
4723 let screen = Screen::list_detail("Tasks", false)
4724 .with(Slot::new("main", RegionKind::Pane).with(Node::text("plain")));
4725 let html = render(&screen);
4726
4727 assert!(!html.contains(" _=\""), "{html}");
4728 assert!(!html.contains("data-frame"), "{html}");
4729 }
4730
4731 #[test]
4732 fn a_region_with_no_frames_is_stepped_by_nothing() {
4733 // `mod 0` is not a number. An empty gallery is the measured case: three MNW
4734 // pages draw one whenever the creator uploaded nothing, and the counter row
4735 // is still emitted because the region is still a carousel.
4736 let screen = Screen::list_detail("Product", false)
4737 .with(Slot::widget("shots", "carousel").showing_one(0));
4738 let html = render(&screen);
4739
4740 assert!(html.contains("data-shows=\"next\""), "{html}");
4741 assert!(!html.contains(" _=\""), "{html}");
4742 assert!(html.contains(">0 / 0</span>"), "{html}");
4743 }
4744
4745 #[test]
4746 fn a_region_id_that_is_not_a_handle_gets_the_mark_and_no_program() {
4747 // A program is code and the id goes inside a selector inside it, so the
4748 // gate is the id being a plain handle rather than an escaping pass:
4749 // hyperscript interpolates `${...}` inside a query literal, and a CSS hex
4750 // escape is read by browsers and not by every selector engine.
4751 //
4752 // What such a region gets is the markup it got before this existed: the
4753 // `data-shows` mark, and whatever the host binds to it.
4754 let screen = Screen::list_detail("Product", false).with(
4755 Slot::widget("${window.alert(1)}", "carousel")
4756 .with(Node::text("a"))
4757 .with(Node::text("b"))
4758 .showing_one(0),
4759 );
4760 let html = render(&screen);
4761
4762 assert!(html.contains("data-shows=\"next\""), "{html}");
4763 assert!(!html.contains(" _=\""), "{html}");
4764 assert!(!html.contains("alert(1)/&gt;"), "{html}");
4765 }
4766
4767 #[test]
4768 fn creator_prose_is_fenced_off_from_the_interpreter() {
4769 // What loading hyperscript obliges, and the one node whose content somebody
4770 // other than the app wrote. `data-disable-scripting` stops the interpreter
4771 // reading `_` attributes anywhere under it.
4772 let html = fragment(&Node::rich("**hello**"));
4773
4774 assert!(html.contains("data-disable-scripting"), "{html}");
4775 }
4776
4777 #[test]
4778 fn hyperscript_enters_in_exactly_one_module() {
4779 // The same claim `htmx_enters_in_exactly_two_functions` makes, for the other
4780 // language this crate emits. If an `_` attribute is written anywhere else,
4781 // the provenance rule still holds -- it is emitted -- but the place to read
4782 // every program stops being one file.
4783 assert!(
4784 !include_str!("node.rs").contains(" _=\\\""),
4785 "node.rs writes a program"
4786 );
4787
4788 // The adoption's one rule, kept where the programs are. `js` is the only
4789 // path in the interpreter that reaches `new Function`, and MNW serves
4790 // `script-src 'self'` with no `unsafe-eval`.
4791 let source = include_str!("hyperscript.rs");
4792 assert!(!source.contains(" js "), "{source}");
4793 }
4794
4795 #[test]
4796 fn a_region_that_is_not_in_a_strip_still_fetches_itself() {
4797 // The suppression is the labelled-strip case and nothing wider. MNW's payout
4798 // summary is the case that must not change: one slow region on a screen that
4799 // is otherwise local reads, arriving behind its stand-in.
4800 let screen = Screen::list_detail("Dashboard", false)
4801 .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts")));
4802 let html = render(&screen);
4803
4804 assert!(html.contains("hx-trigger=\"load\""), "{html}");
4805 assert!(html.contains("aria-busy=\"true\""), "{html}");
4806 }
4807
4808 #[test]
4809 fn a_strip_sits_above_the_panes_it_opens() {
4810 // The folder semantic. A tab after its pane would not read as the tab of
4811 // it, and this is the only placement decision the derivation makes.
4812 let screen = Screen::list_detail("Project", false).with(
4813 Slot::new("detail", RegionKind::TabGroup)
4814 .frame(
4815 "Overview",
4816 Node::Region(Slot::new("overview", RegionKind::Pane)),
4817 )
4818 .frame("Files", Node::Region(Slot::new("files", RegionKind::Pane)))
4819 .showing_one(0),
4820 );
4821 let html = render(&screen);
4822
4823 let strip = html
4824 .find("data-selector=\"tab\"")
4825 .expect("a strip is derived");
4826 let first_frame = html.find("showing-frame").expect("frames are wrapped");
4827 assert!(strip < first_frame, "{html}");
4828 }
4829
4830 #[test]
4831 fn a_named_child_that_can_close_is_a_summary_line() {
4832 // Disclosure, which is `871e7f21` and is the third findings this member
4833 // collapses. A strip of one tab is not what a summary line is, so the
4834 // dismissible case is checked before the labelled one.
4835 let screen = Screen::list_detail("Item", false).with(
4836 Slot::widget("more", "disclosure")
4837 .frame(
4838 "Technical details",
4839 Node::Region(Slot::new("body", RegionKind::Pane).with(Node::text("the rest"))),
4840 )
4841 .showing_at_most_one(None),
4842 );
4843 let html = render(&screen);
4844
4845 assert!(html.contains("data-showing=\"at-most-one\""), "{html}");
4846 assert!(html.contains("aria-expanded=\"false\""), "{html}");
4847 assert!(html.contains(">Technical details</button>"), "{html}");
4848 assert!(!html.contains("data-shows=\"next\""), "{html}");
4849 }
4850
4851 #[test]
4852 fn a_derived_control_names_no_route_and_the_transport_stays_in_one_function() {
4853 // The controls move between children already in the document, so there is
4854 // nothing to fetch. Emitting htmx here would put the transport in a second
4855 // place, which is the claim `action_attrs` exists to keep true.
4856 let html = render(&Screen::list_detail("Product", false).with(gallery()));
4857 let row = &html[html.find("showing-position").expect("a row is derived") - 200..];
4858
4859 assert!(!row.contains("hx-get"), "{row}");
4860 assert!(!row.contains("hx-post"), "{row}");
4861 }
4862
4863 // ---------------------------------------------------------------- timeline
4864
4865 /// A row with one word in it, for placing on an axis.
4866 fn placed(at: u16, minutes: u16, text: &str) -> quasi_router::screen::Placed {
4867 quasi_router::screen::Placed::new(at, minutes, Row::new(text))
4868 }
4869
4870 #[test]
4871 fn a_timeline_places_each_entry_as_a_percentage_of_its_span() {
4872 // 09:00 for an hour, on a midnight-to-midnight axis: 37.5% down, 4.1667%
4873 // tall. The arithmetic is Track::fraction's, asserted here because this is
4874 // where a renderer would be tempted to do its own.
4875 let node = Node::Timeline {
4876 marks: ::quasi_router::stage::Marks::none(),
4877 track: layout::Track::DAY,
4878 entries: vec![placed(540, 60, "Standup")],
4879 focus: None,
4880 };
4881 let html = fragment(&node);
4882
4883 assert!(html.contains("--track-at:37.5000%"), "{html}");
4884 assert!(html.contains("--track-for:4.1667%"), "{html}");
4885 // Full width when nothing collides, from the defaults rather than from a
4886 // special case in the emitter.
4887 assert!(html.contains("--track-lane:0"), "{html}");
4888 assert!(html.contains("--track-lanes:1"), "{html}");
4889 }
4890
4891 #[test]
4892 fn overlapping_entries_take_lanes_and_the_rest_stay_wide() {
4893 // Two at once and one after. The two collide, so the track is two lanes
4894 // wide; the third does not collide with either but shares the width,
4895 // because the lane count is per-track rather than per-cluster. That is a
4896 // layout decision this renderer owns and the test records it as one.
4897 let node = Node::Timeline {
4898 marks: ::quasi_router::stage::Marks::none(),
4899 track: layout::Track::DAY,
4900 entries: vec![
4901 placed(540, 60, "Standup"), // 09:00-10:00
4902 placed(570, 60, "Interview"), // 09:30-10:30
4903 placed(720, 30, "Lunch"), // 12:00-12:30
4904 ],
4905 focus: None,
4906 };
4907 let html = fragment(&node);
4908
4909 assert!(html.contains("--track-lane:0"), "{html}");
4910 assert!(html.contains("--track-lane:1"), "{html}");
4911 assert!(
4912 !html.contains("--track-lane:2"),
4913 "two collide, not three: {html}"
4914 );
4915 assert_eq!(html.matches("--track-lanes:2").count(), 3, "{html}");
4916 }
4917
4918 #[test]
4919 fn things_that_only_touch_do_not_collide() {
4920 // 09:00-10:00 and 10:00-11:00 are back to back. `to` is exclusive, so they
4921 // share no minute and both keep the full width. Off-by-one here is the
4922 // classic day-view bug: every hour on the hour reads as a conflict.
4923 let node = Node::Timeline {
4924 marks: ::quasi_router::stage::Marks::none(),
4925 track: layout::Track::DAY,
4926 entries: vec![placed(540, 60, "First"), placed(600, 60, "Second")],
4927 focus: None,
4928 };
4929 let html = fragment(&node);
4930
4931 assert!(html.contains("--track-lanes:1"), "{html}");
4932 assert!(!html.contains("--track-lane:1"), "{html}");
4933 }
4934
4935 #[test]
4936 fn a_timeline_labels_its_ruler_in_wall_clock_and_wraps_past_midnight() {
4937 // An overnight span counts past 1440 so it needs no date, and the ruler
4938 // wraps that back to a clock a person reads. 22:00 to 02:00, hourly.
4939 let node = Node::Timeline {
4940 marks: ::quasi_router::stage::Marks::none(),
4941 track: layout::Track::over(layout::Span::new(1320, 1560)),
4942 entries: vec![],
4943 focus: None,
4944 };
4945 let html = fragment(&node);
4946
4947 assert!(html.contains(">22:00<"), "{html}");
4948 assert!(html.contains(">00:00<"), "{html}");
4949 assert!(html.contains(">01:00<"), "{html}");
4950 assert!(!html.contains(">25:00<"), "the clock wrapped: {html}");
4951 }
4952
4953 #[test]
4954 fn a_timeline_carries_its_focus_as_a_moment_and_never_as_an_offset() {
4955 // "Show me 09:00", not a pixel. The host decides how to get there, which is
4956 // the arrangement that replaces goingson's hardcoded targetHour.
4957 let node = Node::Timeline {
4958 marks: ::quasi_router::stage::Marks::none(),
4959 track: layout::Track::DAY,
4960 entries: vec![],
4961 focus: Some(540),
4962 };
4963 let html = fragment(&node);
4964
4965 assert!(html.contains("data-focus=\"540\""), "{html}");
4966 assert!(!html.contains("scrollTop"), "{html}");
4967 assert!(!html.contains("px"), "{html}");
4968 }
4969
4970 #[test]
4971 fn a_placed_rows_text_cannot_become_markup() {
4972 // The property the whole port exists for. An entry's row goes through the
4973 // same escaping every other row does; being placed is not a way around it.
4974 let node = Node::Timeline {
4975 marks: ::quasi_router::stage::Marks::none(),
4976 track: layout::Track::DAY,
4977 entries: vec![placed(540, 60, "<script>alert('x')</script>")],
4978 focus: None,
4979 };
4980 let html = fragment(&node);
4981
4982 assert!(!html.contains("<script>"), "{html}");
4983 assert!(html.contains("&lt;script&gt;"), "{html}");
4984 }
4985
4986 #[test]
4987 fn a_timeline_emits_no_size_of_its_own() {
4988 // Every number this renderer writes is a percentage of the span or a lane
4989 // index. A pixel here would be quasi deciding how tall an hour is, which is
4990 // makeover-geometry's question and the line makeover-webview's track_rules
4991 // holds from the other side.
4992 let node = Node::Timeline {
4993 marks: ::quasi_router::stage::Marks::none(),
4994 track: layout::Track::DAY,
4995 entries: vec![placed(540, 60, "Standup")],
4996 focus: None,
4997 };
4998 let html = fragment(&node);
4999
5000 for unit in ["px", "rem", "em;", "vh"] {
5001 assert!(!html.contains(unit), "emitted a {unit}: {html}");
5002 }
5003 }
5004
5005 // ---------------------------------------------------------------- widths
5006
5007 #[test]
5008 fn peer_columns_are_a_row_of_fills_and_carry_no_width() {
5009 // A board: three columns, no master and no detail. `RegionKind::Columns`
5010 // used to say this and is retired (quasicoherent `cf981aaa`); a run of
5011 // members that each ask to fill says it, and says it in a type a table
5012 // column has always used. What is emitted is the description's own word,
5013 // and how much a fill actually gets is the stylesheet's.
5014 let column = |id: &str, label: &str| {
5015 Node::Region(Slot::new(id, RegionKind::Pane).with(Node::Heading {
5016 level: layout::Heading::Section,
5017 text: label.into(),
5018 }))
5019 };
5020 let board = Slot::new("board", RegionKind::Group).across(
5021 Run::new(layout::Fallback::Wrap)
5022 .spread(
5023 column("pending", "Pending"),
5024 layout::Priority::Essential,
5025 layout::Width::Fill,
5026 )
5027 .spread(
5028 column("started", "Started"),
5029 layout::Priority::Essential,
5030 layout::Width::Fill,
5031 )
5032 .spread(
5033 column("done", "Completed"),
5034 layout::Priority::Essential,
5035 layout::Width::Fill,
5036 ),
5037 );
5038 let html = render(&Screen::list_detail("Tasks", false).with(board));
5039
5040 assert_eq!(html.matches("data-width=\"fill\"").count(), 3, "{html}");
5041 // No share, no count, no order index. Every one of those would be this
5042 // renderer deciding something peers settle by being peers.
5043 assert!(!html.contains("data-share"), "{html}");
5044 assert!(!html.contains("data-columns"), "{html}");
5045 for label in ["Pending", "Started", "Completed"] {
5046 assert!(html.contains(label), "{html}");
5047 }
5048 }
5049
5050 #[test]
5051 fn a_split_is_a_pane_that_takes_what_it_needs_beside_one_that_fills() {
5052 // The other retired kind. Two panes side by side, the left choosing what
5053 // the right shows, is content then fill: exactly what a table row of a
5054 // content column and a fill column has always been.
5055 let pane = |id: &str| Node::Region(Slot::new(id, RegionKind::Pane));
5056 let split = Slot::new("review", RegionKind::Group).across(
5057 Run::new(layout::Fallback::Stack)
5058 .beside(pane("listing"), layout::Priority::Essential)
5059 .spread(
5060 pane("reading"),
5061 layout::Priority::Essential,
5062 layout::Width::Fill,
5063 ),
5064 );
5065 let html = render(&Screen::list_detail("Review", false).with(split));
5066
5067 // Only the filling half says anything, because content is what a run
5068 // member already drew and saying it would be a hook that changes nothing.
5069 assert_eq!(html.matches("data-width=").count(), 1, "{html}");
5070 assert!(html.contains("data-width=\"fill\""), "{html}");
5071 let listing = html.find("id=\"listing\"").expect("the left pane renders");
5072 let reading = html.find("id=\"reading\"").expect("the right pane renders");
5073 assert!(listing < reading, "{html}");
5074 }
5075
5076 #[test]
5077 fn a_member_that_says_nothing_about_width_is_wrapped_in_nothing() {
5078 // The default is the drawing every run member had before the width
5079 // existed, and the test for that is markup identity rather than a class
5080 // this renderer promises not to emit.
5081 let bare = Slot::new("bar", RegionKind::Band).across(
5082 Run::new(layout::Fallback::Wrap).beside(Node::text("title"), layout::Priority::Essential),
5083 );
5084 let said = Slot::new("bar", RegionKind::Band).across(Run::new(layout::Fallback::Wrap).spread(
5085 Node::text("title"),
5086 layout::Priority::Essential,
5087 layout::Width::Content,
5088 ));
5089
5090 assert_eq!(
5091 render(&Screen::list_detail("T", false).with(bare)),
5092 render(&Screen::list_detail("T", false).with(said)),
5093 );
5094 }
5095
5096 #[test]
5097 fn a_day_strip_labels_days_and_not_a_wall_clock() {
5098 // The defect this fixes, kept as its test. A fifteen-day span on a
5099 // thirty-one day month rendered with exact geometry under an 00:00 ruler,
5100 // because Track::fraction is unit-agnostic and nothing else noticed.
5101 let march = layout::Track::days(layout::Span::new(0, 31));
5102 let node = Node::Timeline {
5103 marks: ::quasi_router::stage::Marks::none(),
5104 track: march,
5105 entries: vec![quasi_router::screen::Placed::new(2, 15, Row::new("Leave"))],
5106 focus: None,
5107 };
5108 let html = fragment(&node);
5109
5110 // Day one, not day zero: offsets are zero-based like every axis here and
5111 // nobody calls the first of the month the zeroth.
5112 assert!(html.contains(">1<"), "{html}");
5113 assert!(html.contains(">8<"), "{html}");
5114 assert!(
5115 !html.contains("00:00"),
5116 "a month strip under a wall clock: {html}"
5117 );
5118
5119 // And the geometry that was always right stays right: day 3 of 31 for 15.
5120 assert!(html.contains("--track-at:6.4516%"), "{html}");
5121 assert!(html.contains("--track-for:48.3871%"), "{html}");
5122 }
5123
5124 #[test]
5125 fn a_day_view_still_labels_a_wall_clock() {
5126 // The other half of the same guard: fixing the strip must not cost the
5127 // screen this vocabulary was added for.
5128 let node = Node::Timeline {
5129 marks: ::quasi_router::stage::Marks::none(),
5130 track: layout::Track::DAY,
5131 entries: vec![],
5132 focus: None,
5133 };
5134 let html = fragment(&node);
5135 assert!(html.contains(">09:00<"), "{html}");
5136 }
5137
5138 #[test]
5139 fn a_field_says_how_much_of_its_row_it_asked_for() {
5140 // `6d6a9160`: fill is determined at the description stage. A column has
5141 // said this since the beginning and a leaf control could not, which was an
5142 // inconsistency rather than a principle.
5143 //
5144 // An attribute rather than a class, for `data-tone`'s reason: this is a
5145 // fact the description carried, not a hook this renderer invented.
5146 let sized = fragment(&Node::Field(Box::new(
5147 Field::new(layout::FieldKind::Text, "query", "Search").width(layout::Width::Content),
5148 )));
5149 assert!(sized.contains("data-width=\"content\""));
5150
5151 // `Fill` is the default and is what a control did before the member
5152 // existed, so saying it would be a hook that changes nothing -- and it
5153 // emits no wrapper either.
5154 let quiet = fragment(&Node::Field(Box::new(Field::new(
5155 layout::FieldKind::Text,
5156 "query",
5157 "Search",
5158 ))));
5159 assert!(!quiet.contains("data-width"));
5160 assert!(!quiet.contains("field-writes"));
5161 }
5162
5163 #[test]
5164 fn a_field_that_consults_asks_on_the_keystroke_after_the_value_settles() {
5165 let html = fragment(&Node::Field(Box::new(
5166 Field::new(layout::FieldKind::Text, "username", "Username")
5167 .consults(Action::get("/api/validate/username").replacing("username-status")),
5168 )));
5169
5170 // `keyup changed`, not `change`: the question is about the value being
5171 // written, and `change` does not fire until the box is left.
5172 assert!(html.contains("hx-trigger=\"keyup changed delay:500ms\""));
5173 assert!(html.contains("hx-get=\"/api/validate/username\""));
5174 // The verdict lands where the description said, because a validate route
5175 // is not described and cannot name a region itself.
5176 assert!(html.contains("hx-target=\"#username-status\""));
5177 // The control is inside the wrapper, so the value is found back rather
5178 // than assumed -- the same reason a field that writes carries this.
5179 assert!(html.contains("hx-include=\"find input, find select, find textarea\""));
5180 assert!(html.contains("field-consults"));
5181 }
5182
5183 #[test]
5184 fn consulting_and_writing_are_two_routes_and_so_two_wrappers() {
5185 // One element takes one verb and one address. A field that asks about its
5186 // value while it is typed and writes it when it settles names two routes,
5187 // and collapsing them onto one element would silently drop one.
5188 let html = fragment(&Node::Field(Box::new(
5189 Field::new(layout::FieldKind::Text, "slug", "Slug")
5190 .consults(Action::get("/api/validate/slug"))
5191 .writes(Action::post("/projects/1/slug")),
5192 )));
5193
5194 assert_eq!(html.matches("field-consults").count(), 1);
5195 assert_eq!(html.matches("field-writes").count(), 1);
5196 assert!(html.contains("hx-get=\"/api/validate/slug\""));
5197 assert!(html.contains("hx-post=\"/projects/1/slug\""));
5198 // Consult outside, write inside: the width and the tick-gathering stay on
5199 // the wrapper that already carried them.
5200 assert!(
5201 html.find("field-consults") < html.find("field-writes"),
5202 "{html}"
5203 );
5204 }
5205
5206 #[test]
5207 fn a_consult_waits_as_long_as_the_description_says() {
5208 let html = fragment(&Node::Field(Box::new(
5209 Field::new(layout::FieldKind::Text, "q", "Search").consulting(
5210 Consult::new(Action::get("/discover/tag-suggest"))
5211 .after(std::time::Duration::from_millis(120)),
5212 ),
5213 )));
5214 assert!(html.contains("delay:120ms"), "{html}");
5215 }
5216
5217 #[test]
5218 fn a_box_asking_two_routes_gets_a_wrapper_each() {
5219 // MNW's discover search. Two routes cannot share one element -- htmx takes
5220 // one verb and one address per element -- which is the same reason a
5221 // consult and a write are two wrappers.
5222 let html = fragment(&Node::Field(Box::new(
5223 Field::new(layout::FieldKind::Text, "q", "Search")
5224 .consulting(
5225 Consult::new(Action::get("/discover/suggestions").replacing("suggestions"))
5226 .after(std::time::Duration::from_millis(200)),
5227 )
5228 .consulting(
5229 Consult::new(Action::get("/discover/results").replacing("results"))
5230 .after(std::time::Duration::from_millis(150)),
5231 ),
5232 )));
5233
5234 assert_eq!(html.matches("field-consults").count(), 2, "{html}");
5235 assert!(html.contains(r#"hx-get="/discover/suggestions""#), "{html}");
5236 assert!(html.contains(r#"hx-get="/discover/results""#), "{html}");
5237 assert!(html.contains("delay:200ms"), "{html}");
5238 assert!(html.contains("delay:150ms"), "{html}");
5239 }
5240
5241 #[test]
5242 fn a_question_gathers_the_controls_it_says_it_carries() {
5243 // The half that made discover's results route unsayable: it answers about
5244 // the current filters, so a question asked without them answers about a
5245 // screen the user is not looking at. Named by field name rather than by
5246 // the `.discover-filter` class the shipped markup groups with, because a
5247 // class is a fact about the document and a terminal has none.
5248 let html = fragment(&Node::Field(Box::new(
5249 Field::new(layout::FieldKind::Text, "q", "Search")
5250 .consulting(Consult::new(Action::get("/discover/results")).sending(["mode", "sort"])),
5251 )));
5252
5253 assert!(
5254 html.contains("[name=&#39;mode&#39;], [name=&#39;sort&#39;]"),
5255 "{html}"
5256 );
5257 // Its own control is still found back, which is what the field group has
5258 // always needed: the value being typed belongs to the control inside the
5259 // wrapper the trigger sits on.
5260 assert!(html.contains("find input"), "{html}");
5261 }
5262
5263 #[test]
5264 fn a_question_that_carries_nothing_gathers_only_its_own_control() {
5265 let html = fragment(&Node::Field(Box::new(
5266 Field::new(layout::FieldKind::Text, "username", "Username")
5267 .consults(Action::get("/api/validate/username")),
5268 )));
5269
5270 assert!(!html.contains("[name="), "{html}");
5271 }
5272
5273 #[test]
5274 fn a_consult_with_a_floor_does_not_ask_about_one_letter() {
5275 // MNW's two comboboxes, measured: both refuse under two characters, and
5276 // both routes guard emptiness only. Without the floor in the markup a
5277 // described typeahead asks `a%` over the whole catalogue.
5278 let html = fragment(&Node::Field(Box::new(
5279 Field::new(layout::FieldKind::Text, "q", "Find a tag").consulting(
5280 Consult::new(Action::get("/discover/tag-suggest"))
5281 .after(std::time::Duration::from_millis(150))
5282 .at_least(2),
5283 ),
5284 )));
5285
5286 // The filter belongs to the event and the delay is a modifier after it.
5287 // `event.target` because the trigger is on the wrapper and the value is on
5288 // the control inside it.
5289 assert!(
5290 html.contains("hx-trigger=\"keyup[event.target.value.length&gt;=2] changed delay:150ms\""),
5291 "{html}"
5292 );
5293 }
5294
5295 #[test]
5296 fn a_select_that_consults_is_heard_on_input_rather_than_on_a_keystroke() {
5297 // `aeb44860`. A folder select that re-asks for a list of mail is the same
5298 // question a search box asks, and `keyup` never hears it move: a pointer
5299 // never releases a key over one.
5300 let html = fragment(&Node::Field(Box::new(
5301 Field::new(layout::FieldKind::Select, "folder", "Folder").consulting(
5302 Consult::new(Action::get("/mail/list").replacing("mail-list"))
5303 .after(std::time::Duration::ZERO),
5304 ),
5305 )));
5306 assert!(
5307 html.contains("hx-trigger=\"input changed delay:0ms\""),
5308 "{html}"
5309 );
5310 assert!(html.contains("hx-get=\"/mail/list\""), "{html}");
5311 assert!(html.contains("field-consults"), "{html}");
5312 }
5313
5314 #[test]
5315 fn a_dragged_value_that_consults_is_heard_on_input_too() {
5316 // A range is built up rather than chosen in one go, which is the other
5317 // question `typed_into` is deliberately not answering: nobody types into a
5318 // slider either.
5319 let html = fragment(&Node::Field(Box::new(
5320 Field::new(layout::FieldKind::Range, "cap", "Cap")
5321 .consults(Action::get("/price").replacing("price")),
5322 )));
5323 assert!(
5324 html.contains("hx-trigger=\"input changed delay:500ms\""),
5325 "{html}"
5326 );
5327 }
5328
5329 #[test]
5330 fn a_typed_box_keeps_the_keystroke_it_had() {
5331 // The half that does not move. `input` would serve here as well, and the
5332 // pair is kept because a box being typed into is the shape the delay and
5333 // the character floor were measured on.
5334 for kind in [
5335 layout::FieldKind::Text,
5336 layout::FieldKind::Secret,
5337 layout::FieldKind::Number,
5338 layout::FieldKind::Email,
5339 layout::FieldKind::Url,
5340 layout::FieldKind::Tel,
5341 layout::FieldKind::Textarea,
5342 ] {
5343 let html = fragment(&Node::Field(Box::new(
5344 Field::new(kind, "q", "Search").consults(Action::get("/find")),
5345 )));
5346 assert!(
5347 html.contains("hx-trigger=\"keyup changed delay:500ms\""),
5348 "{kind:?}: {html}"
5349 );
5350 }
5351 }
5352
5353 #[test]
5354 fn a_consult_without_a_floor_asks_whatever_is_there() {
5355 let html = fragment(&Node::Field(Box::new(
5356 Field::new(layout::FieldKind::Text, "username", "Username")
5357 .consults(Action::get("/api/validate/username")),
5358 )));
5359 // No filter at all rather than one that always passes: the four wizard
5360 // fields this member was built from ask about a single letter on purpose,
5361 // since one letter can already be taken.
5362 assert!(
5363 html.contains("hx-trigger=\"keyup changed delay:500ms\""),
5364 "{html}"
5365 );
5366 assert!(!html.contains("value.length"), "{html}");
5367 }
5368
5369 #[test]
5370 fn a_field_that_both_writes_and_sizes_itself_says_both_on_one_wrapper() {
5371 let both = fragment(&Node::Field(Box::new(
5372 Field::new(layout::FieldKind::Text, "query", "Search")
5373 .width(layout::Width::Content)
5374 .writes(Action::post("/search")),
5375 )));
5376 assert_eq!(both.matches("field-writes").count(), 1);
5377 assert!(both.contains("data-width=\"content\""));
5378 }
5379
5380 /// One of every shape that could plausibly cache a size.
5381 ///
5382 /// A field at each [`layout::Width`], a table with mixed
5383 /// [`layout::Priority`], a list that says how much more there is, and a nested
5384 /// region. Shared by the three renderers' path-independence tests, written out
5385 /// in each rather than lifted into a crate they would all have to depend on:
5386 /// the fixture is six lines and a shared one would be a new public surface.
5387 fn every_shape_that_could_cache() -> Screen {
5388 Screen::sidebar_content("Any width").with(
5389 Slot::new("main", RegionKind::Pane)
5390 .with(Node::Field(Box::new(Field::new(
5391 layout::FieldKind::Text,
5392 "wide",
5393 "Wide",
5394 ))))
5395 .with(Node::Field(Box::new(
5396 Field::new(layout::FieldKind::Text, "tight", "Tight").width(layout::Width::Content),
5397 )))
5398 .with(Node::Field(Box::new(
5399 Field::new(layout::FieldKind::Text, "held", "Held").width(layout::Width::Fixed),
5400 )))
5401 .with(Node::Table {
5402 marks: ::quasi_router::stage::Marks::none(),
5403 columns: vec![
5404 Column::new("Name").priority(layout::Priority::Essential),
5405 Column::new("Kind").priority(layout::Priority::Secondary),
5406 Column::new("Added").priority(layout::Priority::Optional),
5407 ],
5408 rows: vec![Row::cells(["kick.wav", "sample", "2026-08-12"])],
5409 more: Some(Rest::more(1, Action::get("/samples?from=1"))),
5410 })
5411 .with(Node::Table {
5412 marks: ::quasi_router::stage::Marks::none(),
5413 columns: Vec::new(),
5414 rows: vec![Row::new("one"), Row::new("two")],
5415 more: Some(Rest::more(2, Action::get("/rows?from=2"))),
5416 })
5417 .with(Node::Region(
5418 Slot::group("nested").with(Node::text("inside")),
5419 )),
5420 )
5421 }
5422
5423 #[test]
5424 fn the_markup_is_not_a_function_of_the_width() {
5425 // "Any width, one answer", `makeover-layout` 0.27.4. This renderer keeps
5426 // the property the cheapest way there is: it is never told the width, so
5427 // narrowing is the stylesheet's and lives in `@media`, where there is
5428 // nowhere to keep the width you came from.
5429 //
5430 // Two halves. The first is that a reused renderer answers the same as a
5431 // fresh one, which is what would fail the day someone memoised a track
5432 // list on the `Webview`. The second is that the document carries no
5433 // measurement at all, which is what would fail the day someone reached for
5434 // an inline `style` to size something -- an inline width is a number
5435 // computed once, and a number computed once is the previous frame's
5436 // answer.
5437 let screen = every_shape_that_could_cache();
5438
5439 let reused = Webview::new();
5440 let first = reused.screen(&screen);
5441 for _ in 0..4 {
5442 assert_eq!(reused.screen(&screen), first);
5443 }
5444 assert_eq!(Webview::new().screen(&screen), first);
5445
5446 // No absolute length reaches the markup. The one inline style the
5447 // document carries is the arrangement's share, and it is in `fr`: a
5448 // proportion of whatever there turns out to be, which is a fact the
5449 // description stated rather than a size this renderer worked out.
5450 for style in first.split("style=\"").skip(1) {
5451 let value = style.split('"').next().expect("an attribute closes");
5452 for unit in ["px", "vw", "vh", "ch", "pt"] {
5453 assert!(!value.contains(unit), "{unit} in {value}");
5454 }
5455 assert!(value.contains("fr"), "{value}");
5456 }
5457 }
5458
5459 #[test]
5460 fn a_region_member_says_when_it_drops() {
5461 // The other half of "Any width, one answer": a placement that is not a
5462 // column can finally say what it is worth, so a band narrows by the rule
5463 // the stylesheet already applies to tables rather than by a breakpoint the
5464 // app hand-rolled.
5465 let screen = Screen::sidebar_content("Toolbar").with(
5466 Slot::new("bar", quasi_router::RegionKind::Band)
5467 .with(Node::text("Library"))
5468 .with_ranked(Node::text("Filter"), layout::Priority::Secondary)
5469 .with_ranked(Node::text("Sort"), layout::Priority::Optional),
5470 );
5471 let html = render(&screen);
5472
5473 // The classes are `makeover-webview`'s, and the rules that hide them come
5474 // off its stylesheet, gated by `@media` per size class. Nothing here is
5475 // told a width.
5476 assert!(html.contains("cell-drops-next"), "{html}");
5477 assert!(html.contains("cell-drops-first"), "{html}");
5478
5479 // An essential member emits no wrapper at all, which is why the change
5480 // reached every existing screen without moving any of their markup.
5481 assert_eq!(html.matches("<div class=\"cell-drops").count(), 2);
5482 assert!(html.contains("<p class=\"text\">Library</p>"), "{html}");
5483 }
5484
5485 #[test]
5486 fn an_awaiting_control_locks_itself_while_it_waits() {
5487 // `d8d6f380`. The guard, which is the half the shipped server writes twice
5488 // against 57 spinners, and the half a double-submitted purchase needs.
5489 let html = fragment(&Node::Act(Act::new(
5490 "Buy",
5491 Action::post("/checkout").awaiting(),
5492 )));
5493 assert!(html.contains(r#"hx-disable="this""#), "{html}");
5494 assert!(html.contains(r#"data-awaiting="indeterminate""#), "{html}");
5495 assert!(!html.contains("data-awaiting-amount"), "{html}");
5496
5497 // No mark, nothing said. A control that waits on nothing worth saying so
5498 // about draws exactly what it drew before this existed.
5499 let plain = fragment(&Node::Act(Act::new("Save", Action::post("/save"))));
5500 assert!(!plain.contains("hx-disable"), "{plain}");
5501 assert!(!plain.contains("data-awaiting"), "{plain}");
5502 }
5503
5504 #[test]
5505 fn an_awaiting_form_locks_its_button_and_not_the_form() {
5506 // `930947c3`. The test above builds an `Act`, which is why this got through:
5507 // `hx-disable="this"` locks a button and does nothing at all to a
5508 // `<form>`. htmx sets the `disabled` attribute on whatever the selector
5509 // resolves to, `<form>` has no such attribute, and it does not reach the
5510 // controls inside the way `<fieldset disabled>` would. So the guard has to
5511 // name the button, or a form marked as waiting accepts a second submit
5512 // while the first is still in flight.
5513 let html = fragment(&Node::Form {
5514 marks: ::quasi_router::stage::Marks::none(),
5515 action: Action::post("/api/users/me/ssh-keys").awaiting(),
5516 submit: "Add SSH Key".into(),
5517 fields: vec![Field::new(layout::FieldKind::Text, "label", "Label")],
5518 });
5519 assert!(
5520 html.contains(r#"hx-disable="find button[type='submit']""#),
5521 "{html}"
5522 );
5523 assert!(!html.contains(r#"hx-disable="this""#), "{html}");
5524 assert!(html.contains(r#"data-awaiting="indeterminate""#), "{html}");
5525 // The thing the selector has to find is really there, spelled the way the
5526 // selector spells it. Two places, one shape, and this is the seam.
5527 assert!(html.contains(r#"<button type="submit""#), "{html}");
5528
5529 // The fields stay usable. A guard that took the text away from a reader
5530 // still correcting it would be a worse bug than the one this fixes.
5531 assert!(html.contains(r#"<input type="text""#), "{html}");
5532 assert!(!html.contains("<input disabled"), "{html}");
5533 }
5534
5535 #[test]
5536 fn a_measured_wait_carries_its_amount_and_never_a_duration() {
5537 let html = fragment(&Node::Act(Act::new(
5538 "Upload",
5539 Action::post("/media").awaiting_amount(41_943_040),
5540 )));
5541 assert!(html.contains(r#"data-awaiting="determinate""#), "{html}");
5542 assert!(
5543 html.contains(r#"data-awaiting-amount="41943040""#),
5544 "{html}"
5545 );
5546 // The amount is what there is, not how long it will take. Nothing in the
5547 // markup may read as a prediction, because the description carries none.
5548 assert!(!html.contains("duration"), "{html}");
5549 assert!(!html.contains("eta"), "{html}");
5550 }
5551
5552 #[test]
5553 fn a_tab_strip_and_what_shares_its_row_land_in_one_flex_row() {
5554 // goingson's bug, rendered. The toolbar is inside the same row as the
5555 // strip and neither is positioned, which is the whole of the fix: the
5556 // stylesheet rule this replaces pinned the toolbar over the strip with
5557 // position: absolute and so took it out of flow.
5558 let screen = Screen::sidebar_content("Work").with(
5559 Slot::new("work-view", RegionKind::TabGroup)
5560 .across(Run::new(layout::Fallback::Menu).beside(
5561 Node::Region(Slot::new("work-toolbar", RegionKind::Band)),
5562 layout::Priority::Secondary,
5563 ))
5564 .frame("Tasks", Node::Region(Slot::new("tasks", RegionKind::Pane)))
5565 .showing_one(0),
5566 );
5567
5568 let html = render(&screen);
5569 assert!(html.contains("class=\"run run-menu\""), "{html}");
5570 // The strip leads the row and the member follows it, which is the folder
5571 // semantic the unwrapped strip already had.
5572 let run = html
5573 .split("class=\"run run-menu\"")
5574 .nth(1)
5575 .expect("the row");
5576 let strip = run.find("Tasks").expect("the strip is in the row");
5577 let toolbar = run.find("work-toolbar").expect("the member is in the row");
5578 assert!(strip < toolbar, "{run}");
5579 assert!(!html.contains("position:"), "{html}");
5580 }
5581
5582 #[test]
5583 fn a_region_with_no_run_emits_exactly_the_markup_it_always_did() {
5584 // The additivity claim, checked rather than asserted in a doc comment.
5585 let bare = Screen::sidebar_content("Work").with(
5586 Slot::new("work-view", RegionKind::TabGroup)
5587 .frame("Tasks", Node::Region(Slot::new("tasks", RegionKind::Pane))),
5588 );
5589
5590 assert!(!render(&bare).contains("class=\"run"));
5591 }
5592
5593 #[test]
5594 fn a_region_fed_by_a_call_asks_for_itself() {
5595 // MNW's payout summary: one slow part of a screen that is otherwise local
5596 // reads. A hand-split route before there was a word for it.
5597 let screen = Screen::list_detail("Payments", false).with(
5598 Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()),
5599 );
5600 let html = render(&screen);
5601 assert!(html.contains(r#"hx-trigger="load""#), "{html}");
5602 assert!(html.contains(r#"hx-get="/dashboard/payouts""#), "{html}");
5603 // Pending while it is on its way, which is the state that was already
5604 // sayable and had no way to fill itself.
5605 assert!(html.contains(r#"aria-busy="true""#), "{html}");
5606 // A region is not somewhere to go, so the read does not become an anchor.
5607 assert!(!html.contains(r#"href="/dashboard/payouts""#), "{html}");
5608 // Nothing aims the answer. The router names what it changed, which is
5609 // decision 7 and is not weakened by the request starting here.
5610 assert!(!html.contains("hx-target"), "{html}");
5611 }
5612
5613 #[test]
5614 fn a_live_region_asks_again_on_the_renderer_cadence() {
5615 // MNW's admin queue summary, which hand-writes `every 10s` in the template
5616 // today. The description says the number moves without the user and the
5617 // interval is this crate's, so every live region in every screen it draws
5618 // moves at one speed.
5619 let screen = Screen::list_detail("Admin", false).with(
5620 Slot::new("queue", RegionKind::Pane)
5621 .fed_by(Action::get("/admin/queue"))
5622 .live(),
5623 );
5624 let html = render(&screen);
5625 let expected = format!(r#"hx-trigger="load, every {}s""#, crate::CADENCE.as_secs());
5626 assert!(html.contains(&expected), "{html}");
5627 assert!(html.contains(r#"hx-get="/admin/queue""#), "{html}");
5628 // `load` is kept beside the interval: htmx waits out a whole period first,
5629 // and a region blank for ten seconds is slower than what this replaces.
5630 assert!(!html.contains(r#"hx-trigger="every"#), "{html}");
5631 // Still not somewhere to go.
5632 assert!(!html.contains(r#"href="/admin/queue""#), "{html}");
5633 }
5634
5635 #[test]
5636 fn a_still_region_asks_once_as_it_always_did() {
5637 // The default is the old behaviour, asserted from the markup rather than
5638 // from the field, so a description written before `live` existed renders
5639 // byte for byte as it did.
5640 let screen = Screen::list_detail("Payments", false)
5641 .with(Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts")));
5642 let html = render(&screen);
5643 assert!(html.contains(r#"hx-trigger="load""#), "{html}");
5644 assert!(!html.contains("every"), "{html}");
5645 }
5646
5647 // ── The frame a mount puts around a screen ──
5648
5649 #[test]
5650 fn a_mount_that_declares_no_frame_draws_none() {
5651 // The default has to be the old markup, byte for byte, or every host that
5652 // serves a screen changes what it emits when this arrives.
5653 let html = render(
5654 &Screen::list_detail("Compose", false)
5655 .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))),
5656 );
5657 assert!(!html.contains("frame"), "{html}");
5658 }
5659
5660 #[test]
5661 fn a_framed_mount_offers_its_verbs_outside_the_screen() {
5662 // goingson's compose window. The verbs belong to the mount, so they sit
5663 // outside `<main>`: a fragment replaces what is inside it, and a verb row
5664 // swept away by the first navigation is the per-screen repetition this
5665 // member exists to end.
5666 let webview = Webview::new().with_frame(
5667 Frame::new()
5668 .offering(Act::new("Send", Action::post("/compose/send")))
5669 .offering(
5670 Act::new("Discard", Action::post("/compose/discard"))
5671 .confirm("Discard this draft?"),
5672 )
5673 .reporting(),
5674 );
5675 let html = webview.screen(
5676 &Screen::list_detail("Compose", false)
5677 .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))),
5678 );
5679
5680 let main_ends = html.find("</main>").expect("a screen has a main");
5681 let verbs = html.find("Send").expect("the verb is drawn");
5682 assert!(verbs > main_ends, "the frame outlives the screen inside it");
5683
5684 assert!(html.contains("hx-post=\"/compose/send\""), "{html}");
5685 // A verb is an ordinary act, so it carries its confirmation with no second
5686 // vocabulary.
5687 assert!(
5688 html.contains("hx-confirm=\"Discard this draft?\""),
5689 "{html}"
5690 );
5691 }
5692
5693 #[test]
5694 fn a_mount_that_reports_has_somewhere_for_a_banner_to_rest() {
5695 let webview = Webview::new().with_frame(Frame::new().reporting());
5696 let html = webview.screen(
5697 &Screen::list_detail("Compose", false)
5698 .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))),
5699 );
5700
5701 assert!(html.contains(crate::frame::STATUS_ID), "{html}");
5702 // Polite: a status line reports what happened and does not interrupt what
5703 // is being typed, which is the difference from the toast a modal raises.
5704 assert!(html.contains("aria-live=\"polite\""), "{html}");
5705 }
5706
5707 #[test]
5708 fn a_mount_that_only_offers_verbs_has_no_status_line() {
5709 // The modal half of the measured pair: it raises a toast, and its messages
5710 // stack the way every other screen's do.
5711 let webview = Webview::new()
5712 .with_frame(Frame::new().offering(Act::new("Cancel", Action::post("/close"))));
5713 let html = webview.screen(
5714 &Screen::list_detail("Compose", false)
5715 .with(Slot::new("body", RegionKind::Pane).with(Node::text("body"))),
5716 );
5717
5718 assert!(html.contains("Cancel"), "{html}");
5719 assert!(!html.contains(crate::frame::STATUS_ID), "{html}");
5720 }
5721
5722 #[test]
5723 fn a_banner_rests_in_the_frame_and_a_toast_still_floats() {
5724 // The status line without a channel: `Screen::notices` already carries the
5725 // messages, and a reporting mount changes where one of the two kinds lands
5726 // rather than adding a second way to say it.
5727 let mut screen = Screen::list_detail("Compose", false)
5728 .with(Slot::new("body", RegionKind::Pane).with(Node::text("body")));
5729 screen
5730 .notices
5731 .push(Node::banner(quasi_router::layout::Tone::Danger, "Not sent"));
5732 screen.notices.push(Node::Notice {
5733 kind: quasi_router::layout::Notice::Toast,
5734 tone: quasi_router::layout::Tone::Info,
5735 text: "Saved".into(),
5736 act: None,
5737 });
5738
5739 let html = Webview::new()
5740 .with_frame(Frame::new().reporting())
5741 .screen(&screen);
5742
5743 let status = html.find(crate::frame::STATUS_ID).expect("a status line");
5744 let banner = html.find("Not sent").expect("the banner is drawn");
5745 let toast = html.find("Saved").expect("the toast is drawn");
5746
5747 assert!(banner > status, "a banner rests in the frame");
5748 assert!(
5749 toast < status,
5750 "a toast floats over the screen as it always did"
5751 );
5752 }
5753
5754 #[test]
5755 fn an_unreporting_mount_leaves_both_kinds_where_they_were() {
5756 // The default has to be the old markup. A mount with no place for one to
5757 // rest changes nothing about either kind.
5758 let mut screen = Screen::list_detail("Compose", false)
5759 .with(Slot::new("body", RegionKind::Pane).with(Node::text("body")));
5760 screen
5761 .notices
5762 .push(Node::banner(quasi_router::layout::Tone::Danger, "Not sent"));
5763
5764 let framed = Webview::new()
5765 .with_frame(Frame::new().offering(Act::new("Cancel", Action::post("/close"))))
5766 .screen(&screen);
5767 let bare = render(&screen);
5768
5769 let notices = framed.find("notices").expect("the notices block");
5770 assert!(framed.find("Not sent").expect("drawn") > notices);
5771 assert!(bare.contains("Not sent"));
5772 }
5773
5774 #[test]
5775 fn a_readout_carries_the_instant_and_which_way_it_runs() {
5776 // The two attributes the script reads, and the first words beside them so a
5777 // reader with no script sees a time rather than a hole.
5778 let started = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
5779 let html = fragment(&Node::since(started));
5780
5781 assert!(html.contains("data-clock=\"since\""), "{html}");
5782 assert!(html.contains("data-at=\"1000000000\""), "{html}");
5783 assert!(html.contains("class=\"clock\""), "{html}");
5784 assert!(!html.contains("></span>"), "the first words are emitted");
5785
5786 assert!(fragment(&Node::until(started)).contains("data-clock=\"until\""));
5787 assert!(fragment(&Node::age(started)).contains("data-clock=\"age\""));
5788 }
5789
5790 #[test]
5791 fn a_readout_sits_in_a_row_and_in_a_cell() {
5792 // The measured shape: an elapsed time beside the title of the thing it is
5793 // about, rather than a block of its own.
5794 let started = std::time::SystemTime::now();
5795 let row = fragment(&Node::list([
5796 Row::new("First").part(layout::RowPart::Meta, Node::since(started))
5797 ]));
5798 assert!(row.contains("data-clock=\"since\""), "{row}");
5799
5800 let table = fragment(&Node::Table {
5801 marks: ::quasi_router::stage::Marks::none(),
5802 columns: vec![Column::new("title"), Column::new("elapsed")],
5803 rows: vec![Row::cells([
5804 Cell::new("First"),
5805 Cell {
5806 content: vec![Node::since(started)],
5807 ..Cell::default()
5808 },
5809 ])],
5810 more: None,
5811 });
5812 assert!(table.contains("data-clock=\"since\""), "{table}");
5813 }
5814
5815 #[test]
5816 fn the_clock_script_is_linked_and_can_be_left_out() {
5817 let with = Webview::new().screen(&Screen::list_detail("A", false));
5818 assert!(with.contains("quasi-clock.js"), "{with}");
5819
5820 // Dropping it fails safe, like dropping the selection script: the words the
5821 // server emitted stay on the page and stop moving.
5822 let without = Webview::new()
5823 .with_shell(Shell::default().without_clock())
5824 .screen(&Screen::list_detail("A", false));
5825 assert!(!without.contains("quasi-clock.js"), "{without}");
5826
5827 // And it survives a document that calls no route, which the selection
5828 // script does not: a page with no routes can still say how long ago
5829 // something happened.
5830 let embed = Webview::new()
5831 .with_shell(Shell::default().without_htmx())
5832 .screen(&Screen::list_detail("A", false));
5833 assert!(embed.contains("quasi-clock.js"), "{embed}");
5834 assert!(!embed.contains("quasi-selection.js"), "{embed}");
5835 }
5836
5837 #[test]
5838 fn the_runtime_for_what_the_emitter_writes_is_linked_and_can_be_left_out() {
5839 // `0081563e`. The programs on a region's chrome are read by _hyperscript
5840 // and by nothing else, so the document that carries them names it.
5841 let with = Webview::new().screen(&Screen::list_detail("A", false));
5842 assert!(with.contains("_hyperscript.min.js"), "{with}");
5843
5844 let without = Webview::new()
5845 .with_shell(Shell::default().without_hyperscript())
5846 .screen(&Screen::list_detail("A", false));
5847 assert!(!without.contains("_hyperscript.min.js"), "{without}");
5848
5849 // Independent of the transport, unlike the selection script: the frames of
5850 // a carousel are in the document, so a page that calls no route can still
5851 // have something local to perform.
5852 let embed = Webview::new()
5853 .with_shell(Shell::default().without_htmx())
5854 .screen(&Screen::list_detail("A", false));
5855 assert!(embed.contains("_hyperscript.min.js"), "{embed}");
5856 }
5857
5858 #[test]
5859 fn a_toast_carries_the_hook_the_script_takes_it_away_by_and_a_banner_does_not() {
5860 // `4453bf82`. The description says a toast goes away on its own and never
5861 // says when. In this renderer the markup is emitted once and then held by a
5862 // browser, so the mark is what the browser's clock finds it by, and a
5863 // banner carries none because a banner has no deadline.
5864 let toast = fragment(&Node::toast(layout::Tone::Success, "Saved"));
5865 assert!(toast.contains(r#"data-notice="toast""#), "{toast}");
5866
5867 let banner = fragment(&Node::banner(layout::Tone::Danger, "Not sent"));
5868 assert!(!banner.contains("data-notice"), "{banner}");
5869
5870 // The hook is an attribute rather than the class, because a class carries
5871 // the host's prefix and a script shipped by this crate cannot know it.
5872 let emit = crate::Emit {
5873 class_prefix: "qs-",
5874 ..crate::Emit::default()
5875 };
5876 let prefixed = Webview::new()
5877 .with_emit(emit)
5878 .fragment(&Node::toast(layout::Tone::Success, "Saved"));
5879 assert!(prefixed.contains(r#"data-notice="toast""#), "{prefixed}");
5880 assert!(prefixed.contains("qs-toast"), "{prefixed}");
5881 }
5882
5883 #[test]
5884 fn the_clock_script_takes_a_toast_away_on_this_renderers_own_schedule() {
5885 assert!(crate::CLOCK_JS.contains("data-notice"), "the toast hook");
5886 assert!(crate::CLOCK_JS.contains("remove()"), "and it is taken away");
5887 // It reads the duration rather than carrying it: `makeover-build` writes
5888 // `--timing-dismiss` into every consumer's `timing.css` from the same
5889 // `Intent::Dismiss` the terminal and egui renderers resolve.
5890 assert!(
5891 crate::CLOCK_JS.contains("--timing-dismiss"),
5892 "named, not numbered"
5893 );
5894 }
5895
5896 /// The script's fallback is a number in a second language, which is the
5897 /// arrangement the two elapsed-time format implementations already run on:
5898 /// allowed, so long as something fails when the two part. This is that
5899 /// something. A page with no `timing.css` gets the crate's value and not a
5900 /// stale copy of it.
5901 #[test]
5902 fn the_clock_scripts_fallback_is_the_duration_the_crate_names() {
5903 let named = format!(
5904 "LINGER_FALLBACK = {};",
5905 makeover_timing::Intent::Dismiss.ms()
5906 );
5907 assert!(crate::CLOCK_JS.contains(&named), "{named}");
5908
5909 // The same arrangement for the leaving half, added with `43bdbff7`.
5910 let fade = format!("FADE_FALLBACK = {};", makeover_timing::Motion::Fade.ms());
5911 assert!(crate::CLOCK_JS.contains(&fade), "{fade}");
5912 }
5913
5914 /// `Intent::Dismiss` is how long a notice lives *before it starts to leave*,
5915 /// and the leaving is `Motion::Fade`.
5916 ///
5917 /// The two-step is what this asserts, because the failure it replaces looked
5918 /// identical from the outside: a toast that goes away.
5919 #[test]
5920 fn a_toast_leaves_before_it_is_removed_rather_than_vanishing_at_dismiss() {
5921 assert!(
5922 crate::CLOCK_JS.contains("--motion-fade"),
5923 "the leaving duration is read, not numbered"
5924 );
5925 assert!(
5926 crate::CLOCK_JS.contains("data-leaving"),
5927 "and the attribute makeover-webview's rule transitions on is set"
5928 );
5929 }
5930
5931 /// The node goes on a timer and never on `transitionend`.
5932 ///
5933 /// `timing.css` zeroes `--motion-fade` under `prefers-reduced-motion`, and a
5934 /// zero-length transition may fire no event at all. A listener would hang there
5935 /// forever, for exactly the reader who asked for less motion -- so the one
5936 /// reader this could strand is the one it must not.
5937 #[test]
5938 fn the_leaving_step_cannot_strand_a_toast_under_reduced_motion() {
5939 // The listener, not the word: the comment above the timer names the event
5940 // it is deliberately not using, and a test that could not tell those apart
5941 // would forbid explaining the decision.
5942 assert!(
5943 !crate::CLOCK_JS.contains("addEventListener(\"transitionend"),
5944 "a transition that never fires is a toast that never leaves"
5945 );
5946 assert!(
5947 !crate::CLOCK_JS.contains("ontransitionend"),
5948 "and not by property either"
5949 );
5950 }
5951
5952 #[test]
5953 fn the_clock_script_reads_only_the_hooks_this_crate_emits() {
5954 // It ships from here rather than being copied per app so that the script
5955 // and the markup that feeds it move together. This is the assertion that
5956 // they still agree.
5957 assert!(crate::CLOCK_JS.contains("data-clock"), "the kind hook");
5958 assert!(crate::CLOCK_JS.contains("data-at"), "the instant hook");
5959 for kind in ["since", "until", "age"] {
5960 assert!(crate::CLOCK_JS.contains(kind), "the script knows {kind}");
5961 }
5962
5963 // And that it names nothing of any app's, for the reason the selection
5964 // script may not: it is shipped by a renderer and read by every app.
5965 for word in ["task", "email", "contact", "goingson"] {
5966 assert!(
5967 !crate::CLOCK_JS.to_lowercase().contains(word),
5968 "the script names {word}"
5969 );
5970 }
5971 }
5972
5973 #[test]
5974 fn the_shell_serves_the_clock_script_from_where_the_host_says() {
5975 let html = Webview::under("/assets").screen(&Screen::list_detail("A", false));
5976 assert!(html.contains("src=\"/assets/quasi-clock.js\""), "{html}");
5977 }
5978
5979 /// Three elements and not one: the wrapper that asks, the box that is a
5980 /// combobox, and the list it owns. Every id derives from the field's name.
5981 #[test]
5982 fn a_field_that_owns_a_suggestion_list_is_a_combobox() {
5983 let html = fragment(&Node::Field(Box::new(
5984 Field::new(layout::FieldKind::Text, "q", "Search").suggesting(
5985 Consult::new(Action::get("/discover/suggestions"))
5986 .after(std::time::Duration::from_millis(200))
5987 .at_least(2),
5988 ),
5989 )));
5990
5991 assert!(html.contains("field-suggests"), "{html}");
5992 assert!(html.contains(r#"hx-get="/discover/suggestions""#), "{html}");
5993 assert!(html.contains("delay:200ms"), "{html}");
5994 // The answer lands in the list the field owns, from an id nobody authored.
5995 assert!(html.contains(r##"hx-target="#q-suggestions""##), "{html}");
5996 assert!(html.contains(r#"id="q-suggestions""#), "{html}");
5997 assert!(html.contains(r#"role="listbox""#), "{html}");
5998 assert!(html.contains(r#"role="combobox""#), "{html}");
5999 assert!(html.contains(r#"aria-controls="q-suggestions""#), "{html}");
6000 assert!(html.contains(r#"aria-expanded="false""#), "{html}");
6001 // The list is named by the box it belongs to, so a reader hears what it is.
6002 assert!(html.contains(r#"aria-label="Search""#), "{html}");
6003 }
6004
6005 /// MNW's fee calculator: five dials that are peers, and a panel that
6006 /// recomputes when any of them moves. The question is the region's, the values
6007 /// are gathered by containment, and nothing nominates one dial as the owner of
6008 /// the recompute.
6009 #[test]
6010 fn a_region_recomputes_from_the_dials_inside_it() {
6011 let html = render(
6012 &Screen::list_detail("Pricing", false).with(
6013 Slot::group("pricing-calculator")
6014 .with(Node::Field(Box::new(Field::new(
6015 layout::FieldKind::Number,
6016 "item_price",
6017 "Price per item",
6018 ))))
6019 .with(Node::Region(
6020 Slot::group("results-panel").with(Node::text("You keep $9.00")),
6021 ))
6022 .consulting(
6023 Consult::new(Action::get("/pricing/compare").replacing("results-panel"))
6024 .after(std::time::Duration::from_millis(300)),
6025 ),
6026 ),
6027 );
6028
6029 assert!(html.contains("region-consults"), "{html}");
6030 assert!(html.contains(r#"hx-get="/pricing/compare""#), "{html}");
6031 assert!(html.contains(r##"hx-target="#results-panel""##), "{html}");
6032 // `input` and not `keyup`: a tier radio is a dial too, and `keyup` never
6033 // hears it. Not `change` either, which would fire again on blur.
6034 assert!(
6035 html.contains(r#"hx-trigger="input changed delay:300ms""#),
6036 "{html}"
6037 );
6038 // The dials are gathered by containment, which is the whole of what moving
6039 // the question up to the region bought.
6040 assert!(
6041 html.contains(r#"hx-include="find input, find select, find textarea""#),
6042 "{html}"
6043 );
6044 // Outside the region, because a region that also names a `fed_by` has
6045 // already spent htmx's one address per element.
6046 let wrapper = html.find("region-consults").unwrap();
6047 let region = html.find(r#"id="pricing-calculator""#).unwrap();
6048 assert!(wrapper < region, "{html}");
6049 }
6050
6051 /// A dial that sits outside the panel names itself the way a field's consult
6052 /// names a sibling: by field name. What is inside needs no naming.
6053 #[test]
6054 fn a_region_consult_carries_what_it_says_it_carries_from_outside() {
6055 let html = render(
6056 &Screen::list_detail("Pricing", false).with(
6057 Slot::group("calculator")
6058 .with(Node::Field(Box::new(Field::new(
6059 layout::FieldKind::Number,
6060 "sales",
6061 "Sales per month",
6062 ))))
6063 .consulting(
6064 Consult::new(Action::get("/pricing/compare").replacing("results"))
6065 .sending(["tier"]),
6066 ),
6067 ),
6068 );
6069
6070 // What the caller named first, then what the trigger has to find back. Two
6071 // `hx-include` attributes on one element is markup a parser drops half of,
6072 // so they are joined rather than written twice.
6073 assert!(
6074 html.contains(
6075 r#"hx-include="find input, find select, find textarea, [name=&#39;tier&#39;]""#
6076 ),
6077 "{html}"
6078 );
6079 }
6080
6081 /// Two panels recomputing from one set of dials are two questions, and one
6082 /// element cannot carry both: htmx takes one verb and one address.
6083 #[test]
6084 fn a_region_asking_twice_is_two_wrappers() {
6085 let html = render(
6086 &Screen::list_detail("Pricing", false).with(
6087 Slot::group("calculator")
6088 .with(Node::Field(Box::new(Field::new(
6089 layout::FieldKind::Number,
6090 "sales",
6091 "Sales",
6092 ))))
6093 .consulting(Consult::new(
6094 Action::get("/pricing/compare").replacing("results"),
6095 ))
6096 .consulting(Consult::new(
6097 Action::get("/pricing/chart").replacing("chart"),
6098 )),
6099 ),
6100 );
6101
6102 assert_eq!(html.matches("region-consults").count(), 2, "{html}");
6103 assert!(html.contains(r##"hx-target="#results""##), "{html}");
6104 assert!(html.contains(r##"hx-target="#chart""##), "{html}");
6105 }
6106
6107 /// A suggestion source is not one of the questions the field merely asks: MNW's
6108 /// discover box owns a list AND re-reads its results, and the two are separate
6109 /// wrappers pointing at separate places.
6110 #[test]
6111 fn owning_a_list_is_not_the_same_as_asking_a_question() {
6112 let html = fragment(&Node::Field(Box::new(
6113 Field::new(layout::FieldKind::Text, "q", "Search")
6114 .suggesting(Consult::new(Action::get("/discover/suggestions")).at_least(2))
6115 .consulting(
6116 Consult::new(Action::get("/discover/results").replacing("results"))
6117 .sending(["mode", "sort"]),
6118 ),
6119 )));
6120
6121 assert_eq!(html.matches("field-suggests").count(), 1, "{html}");
6122 assert_eq!(html.matches("field-consults").count(), 1, "{html}");
6123 assert!(html.contains(r##"hx-target="#q-suggestions""##), "{html}");
6124 assert!(html.contains(r##"hx-target="#results""##), "{html}");
6125 }
6126
6127 /// The candidates, drawn as the inside of the list. A `Candidate`'s value is
6128 /// what the box is set to, its label is what is read, and its detail is the
6129 /// line that tells it from a row reading the same.
6130 #[test]
6131 fn the_answer_to_a_suggestion_route_is_the_inside_of_the_list() {
6132 let html = Webview::new().suggestions(
6133 "q",
6134 &[
6135 Candidate::new("rust-lang", "Rust"),
6136 Candidate::plain("format").detailed("Audio"),
6137 ],
6138 );
6139
6140 assert!(html.contains(r#"role="option""#), "{html}");
6141 assert!(html.contains(r#"id="q-suggestions-0""#), "{html}");
6142 assert!(html.contains(r#"data-at="0""#), "{html}");
6143 assert!(html.contains(r#"data-value="rust-lang""#), "{html}");
6144 assert!(html.contains(">Rust<"), "{html}");
6145 // The second line is its own element, because it is styled apart and is not
6146 // part of what the typed value matches. `1fcf2e9b`.
6147 assert!(html.contains("form-suggestion-detail"), "{html}");
6148 assert!(html.contains(">Audio<"), "{html}");
6149 // A candidate that says nothing about picking still picks locally, so both
6150 // rows carry the writing program and neither carries a route.
6151 assert_eq!(html.matches("on click set box").count(), 2, "{html}");
6152 assert!(!html.contains("hx-get"), "{html}");
6153 }
6154
6155 /// Picking is local by default, and a candidate carrying an action has that
6156 /// performed instead: nothing is written, and the row is an htmx control
6157 /// rather than a value the program copies.
6158 #[test]
6159 fn a_candidate_that_carries_an_action_does_not_write_its_value() {
6160 let html = Webview::new().suggestions(
6161 "q",
6162 &[
6163 Candidate::new("rust-lang", "Rust").picking(Action::get("/projects/rust-lang")),
6164 Candidate::plain("ruby"),
6165 ],
6166 );
6167
6168 // The acting row: htmx calls the route, and there is no value for the
6169 // writing program to copy into the box.
6170 assert!(html.contains(r#"hx-get="/projects/rust-lang""#), "{html}");
6171 assert!(!html.contains(r#"data-value="rust-lang""#), "{html}");
6172 // It still closes the list, which is the half `suggestion_acting` keeps,
6173 // and it keeps none of the writing half: the write appears once across the
6174 // two rows, on the local one.
6175 assert!(html.contains("set @aria-expanded of box"), "{html}");
6176 assert_eq!(html.matches("set the value of box").count(), 1, "{html}");
6177
6178 // The local row beside it is untouched, which is what "local stays the
6179 // default" has to mean for every site that exists today.
6180 assert!(html.contains(r#"data-value="ruby""#), "{html}");
6181 assert_eq!(
6182 html.matches("set picked to @data-value").count(),
6183 1,
6184 "{html}"
6185 );
6186 }
6187
6188 /// At the row rather than at an act: a pick that replaces the document is
6189 /// drawn as an anchor, because an anchor is what a browser navigates. `href`
6190 /// on a `div` is an attribute nothing reads.
6191 #[test]
6192 fn a_candidate_whose_pick_navigates_is_a_link() {
6193 let html = Webview::new().suggestions(
6194 "q",
6195 &[
6196 Candidate::new("/p/rust-lang", "Rust")
6197 .picking(Action::get("/p/rust-lang").navigating()),
6198 Candidate::new("audio.genre.ambient", "Ambient")
6199 .picking(Action::get("/discover/results").replacing("results-container")),
6200 ],
6201 );
6202
6203 assert!(html.contains(r#"<a class="form-suggestion""#), "{html}");
6204 assert!(html.contains(r#"href="/p/rust-lang""#), "{html}");
6205 assert!(html.contains("</a>"), "{html}");
6206 // The one beside it is a call htmx makes, so it stays the element it was:
6207 // a link there would offer a fragment to middle-click.
6208 assert!(html.contains(r#"<div class="form-suggestion""#), "{html}");
6209 assert_eq!(html.matches("hx-get").count(), 1, "{html}");
6210 assert!(!html.contains("hx-get=\"/p/rust-lang\""), "{html}");
6211 }
6212
6213 /// Enter reaches a candidate that acts. Such a row carries no `data-value`,
6214 /// because nothing is written when picking performs a call, and writing one
6215 /// anyway put `null` in the box and sent no call at all.
6216 #[test]
6217 fn the_keyboard_presses_a_candidate_that_acts() {
6218 let html = fragment(&Node::Field(Box::new(
6219 Field::new(layout::FieldKind::Text, "q", "Search")
6220 .suggesting(Consult::new(Action::get("/discover/suggestions")).at_least(2)),
6221 )));
6222
6223 assert!(html.contains("if no (@data-value of cur)"), "{html}");
6224 assert!(html.contains("call cur.click()"), "{html}");
6225 }
6226
6227 /// A candidate is app text, so it reaches a program through the document and
6228 /// never through the program's own source.
6229 #[test]
6230 fn a_candidate_cannot_write_its_own_program() {
6231 let html = Webview::new().suggestions(
6232 "q",
6233 &[Candidate::new(
6234 "' then log 'pwned",
6235 "<script>alert(1)</script>",
6236 )],
6237 );
6238
6239 assert!(!html.contains("<script>"), "{html}");
6240 // The payload is in the attribute the program reads from, once, and the
6241 // program itself is this crate's own text with nothing interpolated into
6242 // it.
6243 assert_eq!(html.matches("then log").count(), 1, "{html}");
6244 assert!(
6245 html.contains(r#"data-value="&#39; then log &#39;pwned""#),
6246 "{html}"
6247 );
6248 assert!(html.contains("set picked to @data-value"), "{html}");
6249 }
6250
6251 #[test]
6252 fn a_field_whose_name_is_not_a_handle_draws_no_list() {
6253 let html = fragment(&Node::Field(Box::new(
6254 Field::new(layout::FieldKind::Text, "q[]", "Search")
6255 .suggests(Action::get("/discover/suggestions")),
6256 )));
6257
6258 assert!(!html.contains("role=\"combobox\""), "{html}");
6259 assert!(!html.contains("role=\"listbox\""), "{html}");
6260 assert!(
6261 Webview::new()
6262 .suggestions("q[]", &[Candidate::plain("a")])
6263 .is_empty()
6264 );
6265 assert_eq!(Webview::new().suggestions_target("q[]"), None);
6266 }
6267
6268 /// The header is the whole story for a plain link and none of it for an htmx
6269 /// control, so the script that turns an XHR into a download is linked by
6270 /// default -- and, unlike the clock, goes when htmx goes, because there is no
6271 /// XHR left to intercept.
6272 #[test]
6273 fn the_download_script_is_linked_and_goes_with_htmx() {
6274 let with = Webview::new().screen(&Screen::list_detail("A", false));
6275 assert!(with.contains("quasi-download.js"), "{with}");
6276
6277 let without = Webview::new()
6278 .with_shell(Shell::default().without_download())
6279 .screen(&Screen::list_detail("A", false));
6280 assert!(!without.contains("quasi-download.js"), "{without}");
6281
6282 let embed = Webview::new()
6283 .with_shell(Shell::default().without_htmx())
6284 .screen(&Screen::list_detail("A", false));
6285 assert!(!embed.contains("quasi-download.js"), "{embed}");
6286 }
6287
6288 /// It reads the header `quasi-http` writes, so the two are one fact rather than
6289 /// two that can drift. This is the cheap half of that: the name the adapter
6290 /// emits appears in the script that parses it.
6291 #[test]
6292 fn the_download_script_reads_the_header_the_adapter_writes() {
6293 assert!(crate::DOWNLOAD_JS.contains("content-disposition"));
6294 // As it appears in the script's regex, where the star is escaped.
6295 assert!(crate::DOWNLOAD_JS.contains(r"filename\*=UTF-8''"));
6296 assert!(crate::DOWNLOAD_JS.contains("attachment"));
6297 }
6298
6299 /// The two attributes the fill script reads, and what makes them attributes
6300 /// rather than an emitted program: a media file's name is app text, and text a
6301 /// user typed is the one thing `crate::hyperscript` never builds a program out
6302 /// of.
6303 #[test]
6304 fn an_act_that_deposits_a_value_names_the_box_and_carries_what_lands_in_it() {
6305 let html = Webview::new().screen(&Screen::list_detail("Write", false).with(
6306 Slot::new("picker", RegionKind::Modal).with(Node::Act(
6307 Act::new("kick.png", Action::local()).filling("body", "![](media/kick.png)"),
6308 )),
6309 ));
6310
6311 assert!(html.contains(r#"data-fills="body""#), "{html}");
6312 assert!(
6313 html.contains(r#"data-fill="![](media/kick.png)""#),
6314 "{html}"
6315 );
6316 // Local, so no transport at all: the deposit is the whole of what the press
6317 // does and there is no address to call.
6318 assert!(html.contains("data-local"), "{html}");
6319 assert!(!html.contains("hx-get"), "{html}");
6320 }
6321
6322 /// A value is a value wherever it came from. The escaping is the emitter's
6323 /// ordinary one, which is exactly the property that lets this be data.
6324 #[test]
6325 fn a_deposited_value_that_looks_like_markup_stays_a_value() {
6326 let html = Webview::new().screen(&Screen::list_detail("Write", false).with(
6327 Slot::new("picker", RegionKind::Modal).with(Node::Act(
6328 Act::new("odd", Action::local()).filling("body", r#"" onclick="alert(1)"#),
6329 )),
6330 ));
6331
6332 // The quotes are encoded, so the value cannot close the attribute it is in
6333 // and the `onclick` never becomes one.
6334 assert!(
6335 html.contains(r#"data-fill="&quot; onclick=&quot;alert(1)""#),
6336 "{html}"
6337 );
6338 assert!(!html.contains(r#"" onclick=""#), "{html}");
6339 }
6340
6341 /// Absent by default, or every control on every screen starts claiming a box.
6342 #[test]
6343 fn an_ordinary_control_deposits_nothing() {
6344 let html = Webview::new().screen(
6345 &Screen::list_detail("Tasks", false).with(
6346 Slot::new("body", RegionKind::Pane)
6347 .with(Node::Act(Act::new("Delete", Action::post("/delete")))),
6348 ),
6349 );
6350 assert!(!html.contains("data-fills"), "{html}");
6351 assert!(!html.contains("data-fill="), "{html}");
6352 }
6353
6354 /// Linked by default, and it stays when htmx goes: what it reads is two
6355 /// attributes on a control, so a document that calls no route can still deposit
6356 /// a value in a box.
6357 #[test]
6358 fn the_fill_script_is_linked_and_outlives_htmx() {
6359 let with = Webview::new().screen(&Screen::list_detail("A", false));
6360 assert!(with.contains("quasi-fill.js"), "{with}");
6361
6362 let embed = Webview::new()
6363 .with_shell(Shell::default().without_htmx())
6364 .screen(&Screen::list_detail("A", false));
6365 assert!(embed.contains("quasi-fill.js"), "{embed}");
6366
6367 let without = Webview::new()
6368 .with_shell(Shell::default().without_fill())
6369 .screen(&Screen::list_detail("A", false));
6370 assert!(!without.contains("quasi-fill.js"), "{without}");
6371 }
6372
6373 /// The script reads the hooks this emitter writes, and puts the value where a
6374 /// browser puts inserted text. A stale copy in an app's static directory is a
6375 /// picker that silently writes nothing, which is what pairing the two here
6376 /// catches.
6377 #[test]
6378 fn the_fill_script_reads_the_hooks_the_emitter_writes() {
6379 assert!(
6380 crate::FILL_JS.contains("data-fills"),
6381 "the destination hook"
6382 );
6383 assert!(crate::FILL_JS.contains("data-fill"), "the value hook");
6384 // At the selection, and it says so the way a browser does.
6385 assert!(crate::FILL_JS.contains("selectionStart"));
6386 assert!(crate::FILL_JS.contains("setSelectionRange"));
6387 // And it tells the page the value settled, or an autosave never sees it.
6388 assert!(crate::FILL_JS.contains(r#"new Event("input""#));
6389 }
6390
6391 /// Every class in a rendered document, unprefixed.
6392 ///
6393 /// Scraped from `class="..."` rather than predicted, which is the point: a name
6394 /// this crate emits and nobody wrote down shows up here.
6395 fn classes_emitted(html: &str) -> std::collections::BTreeSet<String> {
6396 let mut found = std::collections::BTreeSet::new();
6397 let mut rest = html;
6398 while let Some(at) = rest.find(" class=\"") {
6399 rest = &rest[at + " class=\"".len()..];
6400 let end = rest.find('"').expect("an attribute closes");
6401 for name in rest[..end].split_whitespace() {
6402 found.insert(name.to_owned());
6403 }
6404 rest = &rest[end..];
6405 }
6406 found
6407 }
6408
6409 /// A screen exercising every [`Node`] kind and every affordance that carries a
6410 /// class of its own.
6411 ///
6412 /// Deliberately one screen rather than a list of fragments: the chrome, the
6413 /// frame and the region wrappers only exist around a whole document, and they
6414 /// are where most of this crate's own vocabulary lives.
6415 fn everything() -> Screen {
6416 // Reordering, which is what makes a heading sortable and draws
6417 // `table-sort`; ticks on the rows draw `table-select-head` beside them.
6418 let sortable_columns = vec![
6419 Column::new("Name")
6420 .width(layout::Width::Fill)
6421 .reorder(Action::get("/by-name")),
6422 Column::new("Size").width(layout::Width::Fill),
6423 ];
6424
6425 Screen::list_detail("Everything", true)
6426 .with(
6427 // No label: a screen's own slot is revealed by nothing, so since
6428 // `2cdc6761` it cannot carry the name of a control that reveals it.
6429 Slot::new("main", RegionKind::Pane)
6430 // A question of the region's own, which is what draws
6431 // `region-consults` around it. `cb62a9dc`.
6432 .consulting(Consult::new(Action::get("/recompute").replacing("detail")))
6433 .with(Node::page("A heading"))
6434 .with(Node::section("A section"))
6435 .with(Node::text("Some text"))
6436 .with(Node::rich("**rich**"))
6437 .with(Node::Act(Act::new("Press", Action::post("/press"))))
6438 .with(Node::Act(
6439 Act::new("Ask first", Action::post("/ask")).asking(Field::new(
6440 layout::FieldKind::Text,
6441 "why",
6442 "Why",
6443 )),
6444 ))
6445 .with(Node::Link {
6446 text: "A link".into(),
6447 action: Action::get("/elsewhere"),
6448 })
6449 .with(Node::Act(Act::new(
6450 "Open in a window",
6451 Action::get("/there").elsewhere(),
6452 )))
6453 .with(Node::since(instant()))
6454 .with(Node::until(instant()))
6455 .with(Node::age(instant()))
6456 .with(Node::Image(
6457 quasi_router::screen::Image::new("/a.png", "A picture").caption("Its caption"),
6458 ))
6459 .with(Node::Token(Tag::badge("Badge")))
6460 // Removable, which is the only way `chip-remove` is emitted.
6461 .with(Node::Token(Tag::removable("Chip", Action::post("/x"))))
6462 // Latched, for the state class.
6463 .with(Node::Token(
6464 Tag::chip("Latched", Action::post("/t"))
6465 .tone(layout::Tone::Info)
6466 .latched(),
6467 ))
6468 .with(Node::banner(layout::Tone::Info, "A banner"))
6469 .with(Node::toast(layout::Tone::Success, "A toast"))
6470 .with(Node::empty("Nothing here"))
6471 .with(Node::failed("It went wrong"))
6472 .with(Node::field(
6473 Field::new(layout::FieldKind::Text, "name", "Name")
6474 .writes(Action::post("/name")),
6475 ))
6476 .with(Node::field(
6477 Field::new(layout::FieldKind::Text, "who", "Who").consulting(
6478 Consult::new(Action::get("/people"))
6479 .after(std::time::Duration::from_millis(120)),
6480 ),
6481 ))
6482 // Suggesting, which is the wiring that draws `field-suggests`
6483 // around a listbox.
6484 .with(Node::field(
6485 Field::new(layout::FieldKind::Text, "person", "Person")
6486 .suggesting(Consult::new(Action::get("/people"))),
6487 ))
6488 .with(Node::Form {
6489 marks: ::quasi_router::stage::Marks::none(),
6490 action: Action::post("/go"),
6491 submit: "Go".into(),
6492 fields: vec![Field::new(layout::FieldKind::Text, "q", "Query")],
6493 })
6494 .with(Node::list([
6495 // Activating, for `row-activate`.
6496 Row::new("First").meta("meta").activate(Action::get("/1")),
6497 // Current, for `row-current`.
6498 Row {
6499 current: true,
6500 ..Row::new("Second")
6501 },
6502 // Chosen, for `row-chosen`: a live selection, which is
6503 // neither of the two above it.
6504 Row {
6505 chosen: Some(true),
6506 ..Row::new("Second and a half")
6507 },
6508 // A tick and a menu, for `row-select` and `row-menu`. Two
6509 // acts, because one is drawn beside the row and a menu is
6510 // what the second one makes.
6511 Row::new("Third")
6512 .ticking("third", true)
6513 .offers(Act::new("Delete", Action::post("/del")))
6514 .offers(Act::new("Archive", Action::post("/arch")))
6515 .secondary("underneath")
6516 .relaxed(),
6517 // An outline, for `row-branch`, `row-disclose` and
6518 // `row-nested`: an open branch with a child under it, and a
6519 // shut one whose child is in the document and hidden.
6520 Row::new("Fourth").disclosing(true),
6521 Row::new("Fourth, inside").depth(quasi_router::layout::Nesting::at(1)),
6522 Row::new("Fifth").disclosing(false),
6523 Row::new("Fifth, inside").depth(quasi_router::layout::Nesting::at(1)),
6524 ]))
6525 .with(Node::Table {
6526 marks: ::quasi_router::stage::Marks::none(),
6527 columns: sortable_columns,
6528 rows: vec![
6529 // Ticked and unticked, for `table-select` and
6530 // `table-row-selected`.
6531 Row::cells(["kick.wav", "2.1 MB"]).ticking("kick", false),
6532 Row::cells(["snare.wav", "1.4 MB"])
6533 .ticking("snare", true)
6534 .current(true),
6535 // Chosen, for `table-row-chosen`.
6536 Row::cells(["tom.wav", "0.9 MB"]).choosing("tom", true),
6537 // Two acts, so the row carries a menu.
6538 Row::cells(["hat.wav", "0.3 MB"])
6539 .ticking("hat", false)
6540 .offers(Act::new("Delete", Action::post("/del")))
6541 .offers(Act::new("Archive", Action::post("/arch"))),
6542 // An outline in a table, for `table-disclose` and its
6543 // head cell.
6544 Row::cells(["kit", ""]).disclosing(true),
6545 Row::cells(["kit/ride.wav", "1.1 MB"])
6546 .depth(quasi_router::layout::Nesting::at(1)),
6547 ],
6548 // A pager with both directions and a position, for the four
6549 // `rest-*` classes.
6550 more: Some(
6551 Rest::page(100, 50)
6552 .of(400)
6553 .back(Action::get("/t?page=2"))
6554 .forward(Action::get("/t?page=4")),
6555 ),
6556 })
6557 // A pager that offers numbered pages, for the three
6558 // `rest-page*` classes. Its own list rather than added to the
6559 // table above, because a strip replaces the position readout
6560 // and that one is what covers `rest-position`.
6561 .with(
6562 Node::list([Row::new("One")]).and_more(
6563 Rest::page(100, 50)
6564 .of(400)
6565 .back(Action::get("/t?page=2"))
6566 .forward(Action::get("/t?page=4"))
6567 .jumping(Jump::new(2, Action::get("/t?page=2")))
6568 .jumping(Jump::new(3, Action::get("/t?page=3")).here())
6569 .jumping(Jump::new(4, Action::get("/t?page=4"))),
6570 ),
6571 )
6572 // A tab strip, for `selector` and `tab`. Derived from a region
6573 // showing one labelled child at a time, which is the only way
6574 // there is to describe one.
6575 .with(Node::Region(
6576 Slot::new("tabs", RegionKind::TabGroup)
6577 .showing_one(0)
6578 .frame(
6579 "Open",
6580 Node::Region(
6581 Slot::new("open", RegionKind::Group)
6582 .with(Node::text("what is open")),
6583 ),
6584 )
6585 .frame("Done", Node::Region(Slot::new("done", RegionKind::Group))),
6586 ))
6587 .with(Node::Meter(Meter::new(3, 10)))
6588 // One figure that answers a click, for `figure-act`.
6589 .with(Node::Stats {
6590 marks: ::quasi_router::stage::Marks::none(),
6591 figures: vec![
6592 (Figure::new("12", "Open"), None),
6593 (
6594 Figure::new("3", "Overdue"),
6595 Some(Action::get("/tasks?due=past")),
6596 ),
6597 ],
6598 })
6599 .with(Node::Region(
6600 Slot::new("nested", RegionKind::Pane).with(Node::text("inside")),
6601 )),
6602 )
6603 .with(Slot::new("aside", RegionKind::Pane).with(Node::text("beside")))
6604 // A screen that says something, two ways. The banner rests in the
6605 // frame when the mount reports; the toast floats regardless, which is
6606 // what draws the `notices` container.
6607 .saying(Node::banner(layout::Tone::Warning, "Careful"))
6608 .saying(Node::toast(layout::Tone::Success, "Saved"))
6609 }
6610
6611 /// One screen per region kind, arrangement and measure.
6612 ///
6613 /// [`everything`] covers the nodes; these cover the wrappers, which is the
6614 /// other half of this renderer's own vocabulary and the half a fragment can
6615 /// never show.
6616 fn every_shape() -> Vec<Screen> {
6617 let kinds = [
6618 RegionKind::Band,
6619 RegionKind::Sidebar,
6620 RegionKind::Pane,
6621 RegionKind::Group,
6622 RegionKind::TabGroup,
6623 RegionKind::Modal,
6624 RegionKind::Handover {
6625 name: "graph".into(),
6626 },
6627 RegionKind::Ceded {
6628 name: "revenue-chart".into(),
6629 },
6630 RegionKind::Widget {
6631 name: "timer".into(),
6632 },
6633 ];
6634
6635 let mut screens: Vec<Screen> = kinds
6636 .into_iter()
6637 .map(|kind| {
6638 Screen::list_detail("Shapes", false)
6639 .with(Slot::new("r", kind).with(Node::text("in it")))
6640 })
6641 .collect();
6642
6643 screens.push(Screen::list_detail("Tabbed", true));
6644 screens.push(Screen::sidebar_content("Sidebar"));
6645 for measure in [
6646 layout::Measure::Wide,
6647 layout::Measure::Contained,
6648 layout::Measure::Reading,
6649 ] {
6650 screens.push(Screen::list_detail("Measured", false).measured(measure));
6651 }
6652 screens
6653 }
6654
6655 /// A fixed instant, so the readouts render without a clock.
6656 fn instant() -> std::time::SystemTime {
6657 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000)
6658 }
6659
6660 #[test]
6661 fn every_class_this_renderer_emits_is_one_it_wrote_down() {
6662 // goingson `43a682b0`, and the guard that makes `vocabulary::OWN` honest.
6663 //
6664 // makeover can scrape the stylesheet it just generated and have no second
6665 // source to drift from. This crate generates no CSS -- it emits markup, and
6666 // there is nothing to read back -- so the list is written by hand and this
6667 // is what keeps it true. A name added to an emitter and not to the list
6668 // fails here rather than quietly shrinking the set an app checks against,
6669 // which would make that app's dead-CSS answer wrong in the safe-looking
6670 // direction.
6671 let opts = Emit::default();
6672
6673 let document = Webview::new()
6674 .with_frame(
6675 Frame::new()
6676 .reporting()
6677 .offering(Act::new("Save", Action::post("/save"))),
6678 )
6679 .with_shell(
6680 Shell::under("/static").with_chrome(
6681 quasi_router::Chrome::new()
6682 .offering(quasi_router::Place::new(
6683 "tasks",
6684 "Tasks",
6685 Action::get("/tasks"),
6686 ))
6687 .banded(banded())
6688 .presenting(
6689 "timer",
6690 quasi_router::Role::Activity,
6691 Node::text("00:12:04"),
6692 ),
6693 ),
6694 )
6695 .screen(&everything());
6696
6697 let mut emitted = classes_emitted(&document);
6698 for screen in every_shape() {
6699 emitted.extend(classes_emitted(&render(&screen)));
6700 }
6701 assert!(
6702 !emitted.is_empty(),
6703 "the corpus rendered no classes at all, so this test proves nothing"
6704 );
6705
6706 let stranger: Vec<&String> = emitted
6707 .iter()
6708 .filter(|class| !crate::vocabulary::covers(class, &opts))
6709 .collect();
6710 assert!(
6711 stranger.is_empty(),
6712 "{} class(es) emitted that `vocabulary` does not name. Add them to \
6713 `vocabulary::OWN`, or to makeover if makeover should be styling them:\n{}",
6714 stranger.len(),
6715 stranger
6716 .iter()
6717 .map(|c| format!(" .{c}"))
6718 .collect::<Vec<_>>()
6719 .join("\n")
6720 );
6721 }
6722
6723 #[test]
6724 fn every_name_this_renderer_wrote_down_is_one_it_still_emits() {
6725 // The other direction, and the one that fails unsafely if it is missing. A
6726 // stale entry in `OWN` is a class an app's drift check believes is live, so
6727 // the app keeps a rule for markup nothing produces -- which is precisely
6728 // the dead CSS the check exists to find, preserved by the check itself.
6729 //
6730 // Only this crate's own names. makeover's half legitimately contains
6731 // vocabulary these screens never reach (`facet-*`, `well`, `card`), because
6732 // makeover styles more than a described screen asks for.
6733 let opts = Emit::default();
6734
6735 let mut emitted = classes_emitted(
6736 &Webview::new()
6737 .with_frame(
6738 Frame::new()
6739 .reporting()
6740 .offering(Act::new("Save", Action::post("/save"))),
6741 )
6742 .with_shell(
6743 Shell::under("/static").with_chrome(
6744 quasi_router::Chrome::new()
6745 .offering(quasi_router::Place::new(
6746 "tasks",
6747 "Tasks",
6748 Action::get("/tasks"),
6749 ))
6750 .banded(banded())
6751 .presenting(
6752 "timer",
6753 quasi_router::Role::Activity,
6754 Node::text("00:12:04"),
6755 ),
6756 ),
6757 )
6758 .screen(&everything()),
6759 );
6760 for screen in every_shape() {
6761 emitted.extend(classes_emitted(&render(&screen)));
6762 }
6763
6764 let stale: Vec<&&str> = crate::vocabulary::OWN
6765 .iter()
6766 .filter(|name| !emitted.contains(&makeover_webview::class(name, &opts)))
6767 .collect();
6768
6769 assert!(
6770 stale.is_empty(),
6771 "{} name(s) in `vocabulary::OWN` that the corpus never emits. Either an \
6772 emitter stopped writing them -- delete them, and the app rules that \
6773 match them -- or this corpus stopped covering them:\n{}",
6774 stale.len(),
6775 stale
6776 .iter()
6777 .map(|c| format!(" .{c}"))
6778 .collect::<Vec<_>>()
6779 .join("\n")
6780 );
6781 }
6782
6783 /// The region says which control and which value bring it out, and the emitter
6784 /// writes exactly that: no verb, no trigger, no target, because there is no
6785 /// request in a reveal.
6786 #[test]
6787 fn a_conditional_region_emits_its_condition_and_no_transport() {
6788 let html = render(
6789 &Screen::list_detail("Pricing", false).with(
6790 Slot::new("body", RegionKind::Pane)
6791 .with(Node::Field(Box::new(Field::new(
6792 layout::FieldKind::Checkbox,
6793 "pwyw",
6794 "Pay what you want",
6795 ))))
6796 .with(Node::Region(
6797 Slot::group("pwyw-settings")
6798 .revealed_by(Reveal::ticked("pwyw"))
6799 .with(Node::text("Suggested price")),
6800 )),
6801 ),
6802 );
6803
6804 assert!(html.contains(r#"data-reveal="pwyw""#), "{html}");
6805 assert!(html.contains(r#"data-reveal-when="any""#), "{html}");
6806 // The whole point of the member: the section is already in the document, so
6807 // nothing is fetched to show it.
6808 assert!(!html.contains("hx-get=\"/pwyw"), "{html}");
6809 assert!(!html.contains("data-reveal-values"), "{html}");
6810 }
6811
6812 /// The value shapes, as the six MNW sites need them: one value for a select
6813 /// resting on `custom`, several for a position that takes any of two.
6814 #[test]
6815 fn a_condition_on_a_value_carries_the_values_it_accepts() {
6816 let one = render(
6817 &Screen::list_detail("Licensing", false).with(
6818 Slot::new("body", RegionKind::Pane).with(Node::Region(
6819 Slot::group("dash-custom-license")
6820 .revealed_by(Reveal::holding("license_kind", "custom")),
6821 )),
6822 ),
6823 );
6824 assert!(one.contains(r#"data-reveal="license_kind""#), "{one}");
6825 assert!(one.contains(r#"data-reveal-when="value""#), "{one}");
6826 assert!(
6827 one.contains(r#"data-reveal-values="[&quot;custom&quot;]""#),
6828 "{one}"
6829 );
6830
6831 let several = render(
6832 &Screen::list_detail("Placement", false).with(
6833 Slot::new("body", RegionKind::Pane)
6834 .with(Node::Region(Slot::group("offset-input").revealed_by(
6835 Reveal::holding_one_of("position", ["before", "after"]),
6836 ))),
6837 ),
6838 );
6839 assert!(
6840 several.contains(r#"data-reveal-values="[&quot;before&quot;,&quot;after&quot;]""#),
6841 "{several}"
6842 );
6843
6844 let unticked = render(&Screen::list_detail("Defaults", false).with(
6845 Slot::new("body", RegionKind::Pane).with(Node::Region(
6846 Slot::group("advanced").revealed_by(Reveal::unticked("use_defaults")),
6847 )),
6848 ));
6849 assert!(
6850 unticked.contains(r#"data-reveal-when="none""#),
6851 "{unticked}"
6852 );
6853 }
6854
6855 /// A watched value is app text and goes into an attribute, so it is escaped as
6856 /// JSON and then as HTML, the way every other payload this emitter writes is.
6857 #[test]
6858 fn a_watched_value_cannot_close_the_attribute_it_sits_in() {
6859 let html = render(&Screen::list_detail("Odd", false).with(
6860 Slot::new("body", RegionKind::Pane).with(Node::Region(
6861 Slot::group("odd").revealed_by(Reveal::holding("kind", r#"" onclick="alert(1)"#)),
6862 )),
6863 ));
6864
6865 assert!(
6866 html.contains(r#"data-reveal-values="[&quot;\&quot; onclick=\&quot;alert(1)&quot;]""#),
6867 "{html}"
6868 );
6869 assert!(!html.contains(r#"" onclick=""#), "{html}");
6870 }
6871
6872 /// Absent by default, or every region on every screen would start claiming a
6873 /// control to watch.
6874 #[test]
6875 fn an_ordinary_region_names_no_condition() {
6876 let html = render(
6877 &Screen::list_detail("Tasks", false)
6878 .with(Slot::new("body", RegionKind::Pane).with(Node::text("plain"))),
6879 );
6880 assert!(!html.contains("data-reveal"), "{html}");
6881 }
6882
6883 /// Linked by default, and it stays when htmx goes: a document that calls no
6884 /// route can still carry a form with a section that only sometimes applies.
6885 #[test]
6886 fn the_reveal_script_is_linked_and_outlives_htmx() {
6887 let with = Webview::new().screen(&Screen::list_detail("A", false));
6888 assert!(with.contains("quasi-reveal.js"), "{with}");
6889
6890 let embed = Webview::new()
6891 .with_shell(Shell::default().without_htmx())
6892 .screen(&Screen::list_detail("A", false));
6893 assert!(embed.contains("quasi-reveal.js"), "{embed}");
6894
6895 let without = Webview::new()
6896 .with_shell(Shell::default().without_reveal())
6897 .screen(&Screen::list_detail("A", false));
6898 assert!(!without.contains("quasi-reveal.js"), "{without}");
6899 }
6900
6901 /// The script reads the three attributes this emitter writes and nothing else.
6902 /// A stale copy in an app's static directory is a form whose sections all show
6903 /// at once, which is what pairing the two here catches.
6904 #[test]
6905 fn the_reveal_script_reads_the_marks_the_emitter_writes() {
6906 for mark in ["data-reveal", "data-reveal-when", "data-reveal-values"] {
6907 assert!(crate::REVEAL_JS.contains(mark), "{mark} is not read");
6908 }
6909 // No request in it, and no program built from app text: the values arrive
6910 // as data and are parsed as JSON.
6911 assert!(
6912 !crate::REVEAL_JS.contains("fetch("),
6913 "the reveal makes no request"
6914 );
6915 assert!(!crate::REVEAL_JS.contains("new Function"), "no eval");
6916 }
6917
6918 // One conditional question inside a form: `8fdb814c`, goingson's zone picker.
6919
6920 /// A form's questions are a flat list, so a single conditional question inside
6921 /// one carries the condition itself. The box stays inside the form and still
6922 /// submits, which is the half a region outside the form could not have.
6923 #[test]
6924 fn a_conditional_question_carries_its_condition_and_stays_in_its_form() {
6925 let html = render(&Screen::list_detail("Event", false).with(
6926 Slot::new("body", RegionKind::Pane).with(Node::Form {
6927 marks: ::quasi_router::stage::Marks::none(),
6928 action: Action::post("/events"),
6929 submit: "Save".into(),
6930 fields: vec![
6931 Field::select(
6932 "tz_kind",
6933 "Time zone",
6934 vec![Choice::new("local", "Anchored to a place")],
6935 ),
6936 Field::new(layout::FieldKind::Text, "timezone", "Anchored to")
6937 .revealed_by(Reveal::holding("tz_kind", "local")),
6938 ],
6939 }),
6940 ));
6941
6942 assert!(html.contains(r#"data-reveal="tz_kind""#), "{html}");
6943 assert!(html.contains(r#"data-reveal-when="value""#), "{html}");
6944 assert!(
6945 html.contains(r#"data-reveal-values="[&quot;local&quot;]""#),
6946 "{html}"
6947 );
6948 // One form, and the conditional box is inside it: a browser submits a
6949 // hidden input, and that is what keeps a closed section's value.
6950 assert_eq!(html.matches("<form").count(), 1, "{html}");
6951 let form = &html[html.find("<form").expect("a form")..];
6952 let box_at = form.find(r#"name="timezone""#).expect("the box");
6953 assert!(box_at < form.find("</form>").expect("the end"), "{form}");
6954 // No request in a reveal.
6955 assert!(!html.contains("hx-get=\"/events/timezone"), "{html}");
6956 }
6957
6958 /// Absent by default, or every question on every form would start claiming a
6959 /// control to watch.
6960 #[test]
6961 fn an_ordinary_question_names_no_condition() {
6962 let html = render(&form_with(Field::new(
6963 layout::FieldKind::Text,
6964 "title",
6965 "Title",
6966 )));
6967 assert!(!html.contains("data-reveal"), "{html}");
6968 }
6969
6970 // A question answered N times: `60d1753c`, ruled 2026-08-25.
6971
6972 /// The reminders form goingson `8fdb814c` restores: one question, three
6973 /// answers, eight at most.
6974 fn reminders() -> Field {
6975 Field::new(layout::FieldKind::Number, "reminder", "Reminder").repeating(
6976 Repeat::answered(["300", "900", "3600"])
6977 .most(8)
6978 .adding("Add reminder")
6979 .removing("Remove"),
6980 )
6981 }
6982
6983 fn form_with(field: Field) -> Screen {
6984 Screen::list_detail("Event", false).with(Slot::new("body", RegionKind::Pane).with(Node::Form {
6985 marks: ::quasi_router::stage::Marks::none(),
6986 action: Action::post("/events"),
6987 submit: "Save".into(),
6988 fields: vec![field],
6989 }))
6990 }
6991
6992 /// One submit carrying N values under one question, which is the whole of what
6993 /// the member is for. Each slot submits under `name[i]`, so a host reads them
6994 /// back as a list and nothing is a second form.
6995 #[test]
6996 fn every_slot_of_a_repeating_question_submits_under_its_own_indexed_name() {
6997 let html = render(&form_with(reminders()));
6998
6999 let standing = &html[..html.find("<template").expect("a blank slot")];
7000 for at in 0..3 {
7001 assert!(
7002 standing.contains(&format!(r#"name="reminder[{at}]""#)),
7003 "{standing}"
7004 );
7005 }
7006 // The fourth name is in the blank the add control clones and nowhere else,
7007 // so nothing outside the template submits it.
7008 assert!(!standing.contains(r#"name="reminder[3]""#), "{standing}");
7009 assert_eq!(html.matches(r#"name="reminder[3]""#).count(), 1, "{html}");
7010 // One form and one submit, not three.
7011 assert_eq!(html.matches("<form").count(), 1, "{html}");
7012 assert_eq!(html.matches("act-submit").count(), 1, "{html}");
7013 // And the values the description offered are back in their own boxes.
7014 assert!(html.contains(r#"value="300""#), "{html}");
7015 assert!(html.contains(r#"value="3600""#), "{html}");
7016 }
7017
7018 /// The second hard part. A refusal names one answer, and the message lands on
7019 /// that box rather than on the question.
7020 #[test]
7021 fn an_error_can_belong_to_one_slot_rather_than_to_the_question() {
7022 let field = Field::new(layout::FieldKind::Number, "reminder", "Reminder")
7023 .repeating(Repeat::answered(["300", "-1", "3600"]).wrong(1, "Must not be negative"));
7024 let html = render(&form_with(field));
7025
7026 // The message rides on the slot that owns it: makeover-webview writes the
7027 // error's id off the field's name, and the field's name is the slot's.
7028 assert!(html.contains(r#"id="reminder[1]-error""#), "{html}");
7029 assert!(html.contains("Must not be negative"), "{html}");
7030 assert!(!html.contains(r#"id="reminder[0]-error""#), "{html}");
7031 assert!(!html.contains(r#"id="reminder[2]-error""#), "{html}");
7032 }
7033
7034 /// What is wrong with the *set* is the question's own error, and it is not any
7035 /// slot's.
7036 #[test]
7037 fn an_error_about_the_set_sits_on_the_question() {
7038 let mut field = reminders();
7039 field.error = Some("At most eight reminders".into());
7040 let html = render(&form_with(field));
7041
7042 assert!(html.contains("At most eight reminders"), "{html}");
7043 assert!(!html.contains(r#"id="reminder[0]-error""#), "{html}");
7044 }
7045
7046 /// The third hard part, and it costs no round trip: the blank slot is already
7047 /// in the document, so adding one is a clone rather than a request.
7048 #[test]
7049 fn adding_and_removing_a_slot_asks_no_route() {
7050 let html = render(&form_with(reminders()));
7051
7052 assert!(html.contains("<template"), "{html}");
7053 assert!(html.contains("data-repeat-add"), "{html}");
7054 assert!(html.contains("data-repeat-remove"), "{html}");
7055 assert!(html.contains("Add reminder"), "{html}");
7056 // The two controls call nothing, and they do not submit the form they sit
7057 // in either.
7058 let group = &html[html.find("field-repeat").expect("a group")..];
7059 let controls = group.match_indices("data-repeat-add").count()
7060 + group.match_indices("data-repeat-remove").count();
7061 assert_eq!(controls, 5, "three slots, a blank, and the add: {group}");
7062 assert_eq!(
7063 group.matches("type=\"button\"").count(),
7064 5,
7065 "no control here submits: {group}"
7066 );
7067 assert!(!group.contains("hx-get"), "{group}");
7068 assert!(!group.contains("hx-post"), "{group}");
7069 }
7070
7071 /// The floor and the ceiling are the description's, and the first paint already
7072 /// says which control is spent. The script recomputes the same answer after
7073 /// every change.
7074 #[test]
7075 fn the_floor_and_the_ceiling_reach_the_controls() {
7076 let full = render(&form_with(
7077 Field::new(layout::FieldKind::Number, "reminder", "Reminder")
7078 .repeating(Repeat::answered(["300", "900"]).most(2)),
7079 ));
7080 assert!(full.contains(r#"data-repeat-most="2""#), "{full}");
7081 assert!(full.contains("data-repeat-add disabled"), "{full}");
7082
7083 let floored = render(&form_with(
7084 Field::new(layout::FieldKind::Text, "guest", "Guest")
7085 .repeating(Repeat::answered(["ana"]).least(1)),
7086 ));
7087 assert!(floored.contains(r#"data-repeat-least="1""#), "{floored}");
7088 assert!(floored.contains("data-repeat-remove disabled"), "{floored}");
7089 }
7090
7091 /// Zero answers is a real state and draws the add control alone, which is the
7092 /// "zero or more" half of the done condition.
7093 #[test]
7094 fn a_question_nobody_has_answered_still_offers_a_slot_to_add() {
7095 let html = render(&form_with(
7096 Field::new(layout::FieldKind::Number, "reminder", "Reminder")
7097 .repeating(Repeat::new().adding("Add reminder")),
7098 ));
7099 // Nothing standing: the only `reminder[0]` on the page is the blank the add
7100 // control clones, which submits nothing until it is taken out.
7101 assert!(html.contains("data-repeat-slots></div>"), "{html}");
7102 assert_eq!(html.matches(r#"name="reminder[0]""#).count(), 1, "{html}");
7103 assert!(html.contains("data-repeat-add"), "{html}");
7104 assert!(html.contains("Add reminder"), "{html}");
7105 }
7106
7107 /// Every slot is an ordinary field of the same emitter, so a repeating question
7108 /// inherits what a field already gets: its kind, its bounds, its unit.
7109 #[test]
7110 fn a_slot_keeps_everything_the_question_said_about_itself() {
7111 let mut field = reminders();
7112 field.min = Some("0".into());
7113 field.unit = Some("s".into());
7114 let html = render(&form_with(field));
7115
7116 assert_eq!(html.matches(r#"type="number""#).count(), 4, "{html}");
7117 assert_eq!(html.matches(r#"min="0""#).count(), 4, "{html}");
7118 assert!(html.contains(r#"id="reminder[0]-unit""#), "{html}");
7119 }
7120
7121 /// The name and the label are app text and both go into attributes, so both are
7122 /// escaped like every other payload this emitter writes.
7123 #[test]
7124 fn a_repeating_question_cannot_close_the_attribute_it_sits_in() {
7125 let html = render(&form_with(
7126 Field::new(layout::FieldKind::Text, "odd", r#"" onclick="alert(1)"#)
7127 .repeating(Repeat::answered(["one"])),
7128 ));
7129 assert!(!html.contains(r#"" onclick=""#), "{html}");
7130 assert!(html.contains("&quot; onclick=&quot;alert(1)"), "{html}");
7131 }
7132
7133 /// Absent by default, or every field in every form would start drawing a
7134 /// fieldset and two controls.
7135 #[test]
7136 fn an_ordinary_field_repeats_nothing() {
7137 let html = render(&form_with(Field::new(
7138 layout::FieldKind::Text,
7139 "title",
7140 "Title",
7141 )));
7142 assert!(!html.contains("data-repeat"), "{html}");
7143 assert!(!html.contains("<fieldset"), "{html}");
7144 assert!(html.contains(r#"name="title""#), "{html}");
7145 }
7146
7147 /// Linked by default, and it goes when htmx does: without htmx every response
7148 /// is a navigation, and a navigation has no focus to preserve.
7149 ///
7150 /// The failure it fixes is silent to anyone not navigating by keyboard, which
7151 /// is why the default matters more here than elsewhere: nobody would report
7152 /// the script being absent.
7153 #[test]
7154 fn the_focus_script_is_linked_by_default_and_goes_with_htmx() {
7155 let with = Webview::new().screen(&Screen::list_detail("A", false));
7156 assert!(with.contains("quasi-focus.js"), "{with}");
7157
7158 let without = Webview::new()
7159 .with_shell(Shell::default().without_htmx())
7160 .screen(&Screen::list_detail("A", false));
7161 assert!(!without.contains("quasi-focus.js"), "{without}");
7162 }
7163
7164 /// Every script this renderer ships has to be served by whoever embeds it, so
7165 /// the constant and the default path have to agree. They are written in two
7166 /// files and nothing else ties them together.
7167 #[test]
7168 fn the_focus_script_is_served_under_the_name_the_shell_asks_for() {
7169 let shell = Shell::default();
7170 assert_eq!(shell.focus_src.as_deref(), Some("/static/quasi-focus.js"));
7171 assert!(
7172 crate::FOCUS_JS.contains("htmx:after:settle"),
7173 "the settle hook"
7174 );
7175 assert!(
7176 crate::FOCUS_JS.contains("htmx:before:request"),
7177 "and the record taken before it"
7178 );
7179 }
7180
7181 /// Linked by default, and it stays when htmx goes: folding a branch is the
7182 /// reader tidying their own view, and the rows it hides and shows are already
7183 /// in the page.
7184 #[test]
7185 fn the_outline_script_is_linked_and_outlives_htmx() {
7186 let with = Webview::new().screen(&Screen::list_detail("A", false));
7187 assert!(with.contains("quasi-outline.js"), "{with}");
7188
7189 let embed = Webview::new()
7190 .with_shell(Shell::default().without_htmx())
7191 .screen(&Screen::list_detail("A", false));
7192 assert!(embed.contains("quasi-outline.js"), "{embed}");
7193
7194 let without = Webview::new()
7195 .with_shell(Shell::default().without_outline())
7196 .screen(&Screen::list_detail("A", false));
7197 assert!(!without.contains("quasi-outline.js"), "{without}");
7198 }
7199
7200 /// Linked by default, and it stays when htmx goes: adding a slot is a clone of
7201 /// markup already in the page, so a document that calls no route can still
7202 /// carry a repeating question.
7203 #[test]
7204 fn the_repeat_script_is_linked_and_outlives_htmx() {
7205 let with = Webview::new().screen(&Screen::list_detail("A", false));
7206 assert!(with.contains("quasi-repeat.js"), "{with}");
7207
7208 let embed = Webview::new()
7209 .with_shell(Shell::default().without_htmx())
7210 .screen(&Screen::list_detail("A", false));
7211 assert!(embed.contains("quasi-repeat.js"), "{embed}");
7212
7213 let without = Webview::new()
7214 .with_shell(Shell::default().without_repeat())
7215 .screen(&Screen::list_detail("A", false));
7216 assert!(!without.contains("quasi-repeat.js"), "{without}");
7217 }
7218
7219 /// The script reads the marks this emitter writes and nothing else. A stale
7220 /// copy in an app's static directory is a form whose add control is dead, which
7221 /// is what pairing the two here catches.
7222 #[test]
7223 fn the_repeat_script_reads_the_marks_the_emitter_writes() {
7224 for mark in [
7225 "data-repeat",
7226 "data-repeat-label",
7227 "data-repeat-least",
7228 "data-repeat-most",
7229 "data-repeat-slots",
7230 "data-repeat-at",
7231 "data-repeat-add",
7232 "data-repeat-remove",
7233 ] {
7234 assert!(crate::REPEAT_JS.contains(mark), "{mark} is not read");
7235 }
7236 // No request in it, and no program built from app text: the question's name
7237 // and its label arrive as escaped attributes.
7238 assert!(
7239 !crate::REPEAT_JS.contains("fetch("),
7240 "the repeat makes no request"
7241 );
7242 assert!(!crate::REPEAT_JS.contains("new Function"), "no eval");
7243 }
7244
7245 #[test]
7246 fn the_shell_serves_the_binder_that_makes_a_wait_visible() {
7247 // `d43ea1c5`. The emitter said which calls wait, the design system drew
7248 // what a wait looks like, and nothing connected the two: `aria-busy` is
7249 // true only between two events, so it is this script's to set.
7250 let with = Webview::new().screen(&Screen::list_detail("A", false));
7251 assert!(with.contains("quasi-awaiting.js"), "{with}");
7252
7253 // Not independent of htmx, unlike reveal, repeat and fill: what it listens
7254 // to is the request lifecycle, and a document that makes no requests has
7255 // none.
7256 let embed = Webview::new()
7257 .with_shell(Shell::default().without_htmx())
7258 .screen(&Screen::list_detail("A", false));
7259 assert!(!embed.contains("quasi-awaiting.js"), "{embed}");
7260
7261 let without = Webview::new()
7262 .with_shell(Shell::default().without_awaiting())
7263 .screen(&Screen::list_detail("A", false));
7264 assert!(!without.contains("quasi-awaiting.js"), "{without}");
7265 }
7266
7267 #[test]
7268 fn the_shell_serves_the_script_that_submits_a_moment_rather_than_the_words() {
7269 // makeover-layout 0.37.0's `Field::as_instant`. The description says a
7270 // wall-clock value is submitted as the moment it names and declines to say
7271 // how, because the browser is the only party that knows what "your
7272 // computer's time zone" means.
7273 let with = Webview::new().screen(&Screen::list_detail("A", false));
7274 assert!(with.contains("quasi-instant.js"), "{with}");
7275
7276 // Not independent of htmx: it rewrites the request htmx is about to make.
7277 let embed = Webview::new()
7278 .with_shell(Shell::default().without_htmx())
7279 .screen(&Screen::list_detail("A", false));
7280 assert!(!embed.contains("quasi-instant.js"), "{embed}");
7281
7282 let without = Webview::new()
7283 .with_shell(Shell::default().without_instant())
7284 .screen(&Screen::list_detail("A", false));
7285 assert!(!without.contains("quasi-instant.js"), "{without}");
7286 }
7287
7288 #[test]
7289 fn the_shell_serves_the_script_that_folds_a_run_that_ran_out_of_room() {
7290 // `Fallback::Menu` says the members that no longer fit move into one
7291 // overflow control. makeover-webview's CSS cannot ask whether they fit --
7292 // @container compares against a length -- so the measuring is a script.
7293 let with = Webview::new().screen(&Screen::list_detail("A", false));
7294 assert!(with.contains("quasi-menu.js"), "{with}");
7295
7296 // Independent of htmx: a run has room whether or not the page has routes.
7297 let embed = Webview::new()
7298 .with_shell(Shell::default().without_htmx())
7299 .screen(&Screen::list_detail("A", false));
7300 assert!(embed.contains("quasi-menu.js"), "{embed}");
7301
7302 let without = Webview::new()
7303 .with_shell(Shell::default().without_menu())
7304 .screen(&Screen::list_detail("A", false));
7305 assert!(!without.contains("quasi-menu.js"), "{without}");
7306 }
7307
7308 #[test]
7309 fn the_menu_script_marks_the_run_before_it_measures_it() {
7310 // The mark is what turns `flex-wrap: wrap` off. Measuring first would
7311 // always find room, because a wrapped run always fits, and the fold would
7312 // never happen. It is also the whole of the degrade: a page that never runs
7313 // this keeps wrap and every member stays reachable.
7314 let at_mark = crate::MENU_JS.find("data-menu").expect("writes the mark");
7315 let at_measure = crate::MENU_JS
7316 .find("scrollWidth")
7317 .expect("measures the overflow");
7318 assert!(
7319 at_mark < at_measure,
7320 "the mark is written before the measure"
7321 );
7322
7323 // Priority, read off what `Ranked` already emits rather than off a second
7324 // spelling of the same fact.
7325 assert!(
7326 crate::MENU_JS.contains("cell-drops-first"),
7327 "reads priority"
7328 );
7329 assert!(crate::MENU_JS.contains("cell-drops-next"), "reads priority");
7330
7331 // A tab strip is one member of its run and its tabs are what fold. A
7332 // reading that only looked at the run's children would find one strip that
7333 // always fits.
7334 assert!(crate::MENU_JS.contains("tablist"), "folds a strip's tabs");
7335
7336 assert!(!crate::MENU_JS.contains("new Function"), "no eval");
7337 }
7338
7339 #[test]
7340 fn the_instant_script_leaves_an_unparseable_value_for_the_route_to_answer() {
7341 // Rewriting it would turn a legible validation error into `Invalid Date`.
7342 assert!(
7343 crate::INSTANT_JS.contains("Number.isNaN"),
7344 "an unparseable value has to be left alone"
7345 );
7346 // It converts on the way out and never writes the box, so the reader keeps
7347 // seeing the time they typed.
7348 assert!(
7349 !crate::INSTANT_JS.contains(".value ="),
7350 "the control is not rewritten"
7351 );
7352 assert!(!crate::INSTANT_JS.contains("new Function"), "no eval");
7353 }
7354
7355 #[test]
7356 fn the_binder_reads_delivery_and_never_a_clock() {
7357 // Rule 1 of wiki `loading-and-progress-standard`, asserted on the file
7358 // itself. A timer advancing the share is the easiest thing in the world to
7359 // add here and it is exactly the confidently-wrong drawing the rule
7360 // forbids: a bar walking forward on a stalled transfer.
7361 assert!(crate::AWAITING_JS.contains("loaded"), "the numerator");
7362 assert!(crate::AWAITING_JS.contains("aria-busy"), "the running flag");
7363 for banned in [
7364 "setInterval",
7365 "setTimeout",
7366 "requestAnimationFrame",
7367 "Date.now",
7368 ] {
7369 assert!(
7370 !crate::AWAITING_JS.contains(banned),
7371 "{banned} would make the share a prediction"
7372 );
7373 }
7374 }
7375
7376 #[test]
7377 fn an_act_hint_is_a_title_and_an_accessible_description() {
7378 // `ca7b5200`. Both, because `title` alone is a hover: a touch reader never
7379 // sees it and several screen readers are configured not to announce it. The
7380 // shipped templates said it 90 times with `title` and reached one audience.
7381 let html = fragment(&Node::Act(
7382 Act::new("Verify library integrity", Action::post("/verify"))
7383 .hint("The result appears in the status line."),
7384 ));
7385
7386 assert!(
7387 html.contains(r#"title="The result appears in the status line.""#),
7388 "{html}"
7389 );
7390 assert!(
7391 html.contains(r#"aria-description="The result appears in the status line.""#),
7392 "{html}"
7393 );
7394 }
7395
7396 #[test]
7397 fn an_act_with_no_hint_says_neither() {
7398 // The member is additive: a control written before it existed emits exactly
7399 // the markup it always did.
7400 let html = fragment(&Node::Act(Act::new("Save", Action::post("/save"))));
7401
7402 assert!(!html.contains("title="), "{html}");
7403 assert!(!html.contains("aria-description="), "{html}");
7404 }
7405
7406 #[test]
7407 fn a_regions_own_name_lands_on_the_region() {
7408 // `aad33ecc`. `Slot::label` is the name a *parent* reads off this region and
7409 // is announced by nothing when it is set on the region itself. This is the
7410 // member that is the region's own.
7411 let html = render(
7412 &Screen::list_detail("Library", false).with(
7413 Slot::new("chrome", RegionKind::Pane)
7414 .named("Tag breadcrumbs")
7415 .with(Node::text("in it")),
7416 ),
7417 );
7418
7419 assert!(html.contains(r#"aria-label="Tag breadcrumbs""#), "{html}");
7420 }
7421
7422 #[test]
7423 fn a_tab_strips_name_lands_on_the_tablist_and_not_twice() {
7424 // The strip is the element carrying `role="tablist"`, so it is the element a
7425 // screen reader announces. A named `div` with no role is announced by
7426 // nothing, so writing it in both places would be one announcement and one
7427 // wasted attribute.
7428 let html = render(
7429 &Screen::list_detail("Library", false).with(
7430 Slot::new("sections", RegionKind::TabGroup)
7431 .named("Library sections")
7432 .showing_one(0)
7433 .frame("Feed", Node::Region(Slot::new("feed", RegionKind::Pane)))
7434 .frame(
7435 "Purchases",
7436 Node::Region(Slot::new("purchases", RegionKind::Pane)),
7437 ),
7438 ),
7439 );
7440
7441 assert_eq!(
7442 html.matches(r#"aria-label="Library sections""#).count(),
7443 1,
7444 "{html}"
7445 );
7446 let strip = html
7447 .find(r#"role="tablist""#)
7448 .expect("a labelled tab group emits a strip");
7449 let name = html
7450 .find(r#"aria-label="Library sections""#)
7451 .expect("the name is emitted");
7452 // On the strip's own tag: the name follows the role within the same element.
7453 assert!(name > strip, "{html}");
7454 }
7455
7456 #[test]
7457 fn a_region_with_no_name_of_its_own_says_nothing() {
7458 let html = render(
7459 &Screen::list_detail("Library", false)
7460 .with(Slot::new("chrome", RegionKind::Pane).with(Node::text("in it"))),
7461 );
7462
7463 assert!(!html.contains("aria-label="), "{html}");
7464 }
7465
7466 #[test]
7467 fn a_named_region_is_still_an_id_target() {
7468 // `Replaces::Region` is what `replaces` was before it was three things, and
7469 // the emission is unchanged: the escape hatch of decision 7, for a route
7470 // the description layer does not serve.
7471 let html = fragment(&Node::act(
7472 "Remove",
7473 Action::post("/api/links/7/delete").replacing("link-list"),
7474 ));
7475 assert!(html.contains("hx-target=\"#link-list\""), "{html}");
7476 }
7477
7478 #[test]
7479 fn an_enclosing_target_is_the_row_and_not_an_id() {
7480 // `5aa43815`. The two measured sites are shared partials called from
7481 // several parents, so there is no id to name and the row has to be found by
7482 // where the act is standing.
7483 let html = fragment(&Node::act(
7484 "Remove",
7485 Action::post("/api/links/7/delete").replacing_enclosing(),
7486 ));
7487 assert!(html.contains("hx-target=\"closest [data-row]\""), "{html}");
7488 // Replaces the row rather than filling it: the answer would otherwise nest
7489 // inside the element it was meant to remove.
7490 assert!(html.contains("hx-swap=\"outerHTML\""), "{html}");
7491 assert!(!html.contains("hx-target=\"#"), "{html}");
7492 }
7493
7494 #[test]
7495 fn both_kinds_of_row_carry_the_hook_an_enclosing_target_finds() {
7496 // One attribute across the two, matching `data-menu="row"`: a screen mixing
7497 // a list and a table needs one selector rather than two that look alike.
7498 let list = fragment(&Node::list([Row::new("One")]));
7499 assert!(list.contains("<li"), "{list}");
7500 assert!(list.contains("data-row"), "{list}");
7501
7502 let table = fragment(&Node::Table {
7503 marks: ::quasi_router::stage::Marks::none(),
7504 columns: vec![Column::new("Name")],
7505 rows: vec![Row::cells([Cell::new("One")])],
7506 more: None,
7507 });
7508 assert!(table.contains("role=\"row\""), "{table}");
7509 assert!(table.contains("data-row"), "{table}");
7510 }
7511
7512 #[test]
7513 fn the_row_hook_survives_a_class_prefix() {
7514 // Why it is an attribute and not a class, which is `data-act`'s reason: the
7515 // selector is built where `Emit` is not in hand.
7516 let emit = crate::Emit {
7517 class_prefix: "qs-",
7518 ..crate::Emit::default()
7519 };
7520 let html = Webview::new()
7521 .with_emit(emit)
7522 .fragment(&Node::list([Row::new("One")]));
7523 assert!(html.contains("data-row"), "{html}");
7524 assert!(!html.contains("qs-data-row"), "{html}");
7525 }
7526
7527 #[test]
7528 fn an_act_contained_by_nothing_names_no_target_at_all() {
7529 // `f4cc9c6c`. Fifteen shipped acts fire and leave the surface they sit on
7530 // stale. htmx has no request-side spelling for that, so it is a named hook
7531 // the host acts on, the way `data-saves` is.
7532 let html = fragment(&Node::act(
7533 "Disconnect Stripe",
7534 Action::post("/api/stripe/disconnect").invalidating(),
7535 ));
7536 assert!(html.contains("data-replaces=\"everything\""), "{html}");
7537 assert!(!html.contains("hx-target"), "{html}");
7538 }
7539
7540 #[test]
7541 fn a_copying_act_carries_its_value_and_asks_nothing() {
7542 // `c3e145e0`. Seven MNW globals across 14 sites are this. The value rides in
7543 // an escaped attribute and `COPY_JS` reads it; the act's action is local, so
7544 // no transport attribute is emitted at all.
7545 let html = fragment(&Node::act("Copy", quasi_router::Action::local()));
7546 assert!(!html.contains("data-copies"), "{html}");
7547
7548 let act =
7549 quasi_router::Act::new("Copy", quasi_router::Action::local()).copying("mnw_live_abc123");
7550 let html = fragment(&Node::Act(act));
7551 assert!(html.contains("data-copies=\"mnw_live_abc123\""), "{html}");
7552 assert!(!html.contains("hx-get"), "{html}");
7553 assert!(!html.contains("hx-post"), "{html}");
7554 }
7555
7556 #[test]
7557 fn a_copied_value_is_escaped_and_never_a_program() {
7558 // `FILL_JS`' rule, and the reason this is a script reading an attribute
7559 // rather than an emitted _hyperscript program: the value is app text.
7560 let act =
7561 quasi_router::Act::new("Copy", quasi_router::Action::local()).copying(r#""; alert(1); ""#);
7562 let html = fragment(&Node::Act(act));
7563 assert!(!html.contains("alert(1);\""), "{html}");
7564 assert!(html.contains("&quot;"), "{html}");
7565 }
7566
7567 #[test]
7568 fn the_copy_script_reads_the_hook_the_emitter_writes() {
7569 assert!(crate::COPY_JS.contains("data-copies"), "the value hook");
7570 // Delegated, so a control arriving in a swap is covered.
7571 assert!(crate::COPY_JS.contains("addEventListener"));
7572 // No program is built out of the value.
7573 assert!(!crate::COPY_JS.contains("new Function"), "no eval");
7574 assert!(
7575 !crate::COPY_JS.contains("innerHTML"),
7576 "no markup from a value"
7577 );
7578 // The acknowledgement is deliberately not here: that is `033c722f`. Tested
7579 // as "it relabels nothing", not as "the word is absent" -- the file explains
7580 // in a comment why it does not do this, and that comment should stay.
7581 assert!(
7582 !crate::COPY_JS.contains("textContent"),
7583 "no temporary label"
7584 );
7585 assert!(!crate::COPY_JS.contains("setTimeout"), "no revert timer");
7586 }
7587
7588 #[test]
7589 fn the_shell_links_the_copy_script_and_can_drop_it() {
7590 let with = Webview::new().screen(&Screen::list_detail("A", false));
7591 assert!(with.contains("/static/quasi-copy.js"), "{with}");
7592
7593 let under = Webview::under("/assets").screen(&Screen::list_detail("A", false));
7594 assert!(under.contains("/assets/quasi-copy.js"), "{under}");
7595
7596 // Independent of htmx: what it reads is one attribute on a control.
7597 let bare = Webview::new()
7598 .with_shell(Shell::default().without_htmx())
7599 .screen(&Screen::list_detail("A", false));
7600 assert!(bare.contains("quasi-copy.js"), "{bare}");
7601
7602 let without = Webview::new()
7603 .with_shell(Shell::default().without_copy())
7604 .screen(&Screen::list_detail("A", false));
7605 assert!(!without.contains("quasi-copy.js"), "{without}");
7606 }
7607
7608 #[test]
7609 fn a_control_can_show_a_picture_and_still_say_its_name() {
7610 // `db998898`. The picture goes inside the control, so aiming at the
7611 // thumbnail hits the control -- which is the whole affordance of a picture
7612 // picker, and what the shipped media-picker tile loses by being a region
7613 // with the picture and the act as siblings.
7614 use quasi_router::screen::Image;
7615 let act = quasi_router::Act::new("kick.wav", quasi_router::Action::get("/media/1"))
7616 .showing(Image::new("/m/1.png", "kick.wav"));
7617 let html = fragment(&Node::Act(act));
7618
7619 let control = html
7620 .split_once("<a")
7621 .map(|(_, rest)| rest.split_once("</a>").map_or(rest, |(inner, _)| inner))
7622 .unwrap_or_default();
7623 assert!(control.contains("<img"), "the picture is inside it: {html}");
7624 assert!(control.contains("kick.wav"), "and so is the name: {html}");
7625 }
7626
7627 #[test]
7628 fn a_shown_picture_is_the_same_element_a_region_body_writes() {
7629 // One spelling of `<img>`, which is why it was extracted. A picture that
7630 // drifted between the two would be a thumbnail that lazy-loads in a region
7631 // and not in a control.
7632 use quasi_router::screen::Image;
7633 let picture = Image::new("/m/1.png", "kick.wav").lazy();
7634 let in_body = fragment(&Node::Image(picture.clone()));
7635 let in_control = fragment(&Node::Act(
7636 quasi_router::Act::new("kick.wav", quasi_router::Action::get("/media/1")).showing(picture),
7637 ));
7638 let tag = |html: &str| {
7639 let at = html.find("<img").expect("an img");
7640 html[at..].split_once('>').expect("it closes").0.to_owned()
7641 };
7642 assert_eq!(tag(&in_body), tag(&in_control), "{in_body} / {in_control}");
7643 assert!(tag(&in_body).contains("loading=\"lazy\""));
7644 }
7645
7646 #[test]
7647 fn a_control_showing_nothing_is_exactly_what_it_was() {
7648 // Additive: a description written before the member emits the same bytes.
7649 let plain = fragment(&Node::act("Save", quasi_router::Action::post("/save")));
7650 assert!(!plain.contains("<img"), "{plain}");
7651 }
7652
7653 /// Every region carries the container an anchored menu lands in, and
7654 /// `anchored_target` names it -- so a route answering `Anchor::Region`
7655 /// retargets at something the document has.
7656 #[test]
7657 fn an_anchored_menu_is_aimed_at_the_region_it_names() {
7658 use quasi_http::Serves;
7659
7660 let screen = Screen::list_detail("Files", false)
7661 .with(Slot::new("browser", RegionKind::Pane).with(Node::text("rows")));
7662 let html = render(&screen);
7663
7664 assert!(
7665 html.contains("id=\"browser-anchored\" data-menu=\"anchored\" hidden></div>"),
7666 "{html}"
7667 );
7668 assert_eq!(
7669 Webview::new().anchored_target(&quasi_router::Anchor::Region("browser".into())),
7670 Some("browser-anchored".to_owned())
7671 );
7672 }
7673
7674 /// A control gets a container only when the description named it, which is what
7675 /// keeps `Act::id` additive: an unnamed control emits exactly what it did.
7676 #[test]
7677 fn only_a_named_control_carries_an_anchor_container() {
7678 use quasi_http::Serves;
7679
7680 let named = render(
7681 &Screen::list_detail("Files", false).with(
7682 Slot::new("bar", RegionKind::Band)
7683 .with(Node::Act(Act::new("Sort", Action::get("/sort")).id("sort"))),
7684 ),
7685 );
7686 assert!(named.contains("id=\"sort\""), "{named}");
7687 assert!(
7688 named.contains("id=\"sort-anchored\" data-menu=\"anchored\" hidden></div>"),
7689 "{named}"
7690 );
7691
7692 let bare = render(&Screen::list_detail("Files", false).with(
7693 Slot::new("bar", RegionKind::Band).with(Node::Act(Act::new("Sort", Action::get("/sort")))),
7694 ));
7695 assert!(!bare.contains("sort-anchored"), "{bare}");
7696 assert_eq!(
7697 Webview::new().anchored_target(&quasi_router::Anchor::Control("sort".into())),
7698 Some("sort-anchored".to_owned())
7699 );
7700 }
7701
7702 /// The selection's container is fixed and emitted only for a screen that holds
7703 /// a selection, so every other document is untouched by the member.
7704 #[test]
7705 fn the_selection_container_arrives_with_the_selection() {
7706 use quasi_http::Serves;
7707
7708 let plain = render(&Screen::list_detail("Files", false));
7709 assert!(!plain.contains("quasi-selection-anchored"), "{plain}");
7710
7711 let selecting = render(&Screen::list_detail("Files", false).selecting("chosen"));
7712 assert!(
7713 selecting.contains("id=\"quasi-selection-anchored\""),
7714 "{selecting}"
7715 );
7716 assert_eq!(
7717 Webview::new().anchored_target(&quasi_router::Anchor::Selection),
7718 Some("quasi-selection-anchored".to_owned())
7719 );
7720 }
7721
7722 /// An anchor naming something no document can carry degrades rather than
7723 /// panicking: `hyperscript::handle` refuses the name, no container was emitted
7724 /// for it, and the answer is sent unaimed. That is the bargain `Suggestions`
7725 /// already strikes.
7726 #[test]
7727 fn an_anchor_that_cannot_be_an_id_is_aimed_at_nothing() {
7728 use quasi_http::Serves;
7729
7730 assert_eq!(
7731 Webview::new().anchored_target(&quasi_router::Anchor::Region("has space".into())),
7732 None
7733 );
7734 // And the region that could not have one emitted none, so the two halves
7735 // agree about what the document contains.
7736 let html =
7737 render(&Screen::list_detail("Files", false).with(Slot::new("has space", RegionKind::Pane)));
7738 assert!(!html.contains("data-menu=\"anchored\""), "{html}");
7739 }
7740
7741 /// A bespoke region is a place and nothing else (decision 4), so its container
7742 /// is a sibling rather than a child -- and the region between its tags is still
7743 /// exactly what the host put there.
7744 #[test]
7745 fn a_bespoke_regions_container_sits_outside_it() {
7746 let html =
7747 render(&Screen::list_detail("Files", false).with(Slot::handover("player", "media-player")));
7748 assert!(
7749 html.contains("data-bespoke=\"media-player\"></div>"),
7750 "{html}"
7751 );
7752 assert!(
7753 html.contains("</div><div class=\"anchored\" id=\"player-anchored\""),
7754 "{html}"
7755 );
7756 }
7757
7758 /// `19d7602d`. The runs arrived classified and this renderer writes a span per
7759 /// class and no highlighter of its own. A plain run gets no span at all, which
7760 /// is most of a file.
7761 #[test]
7762 fn a_code_block_writes_one_span_per_classified_run_and_none_for_plain() {
7763 use quasi_router::screen::Lexeme;
7764
7765 let html = Webview::new().fragment(&Node::Code {
7766 runs: vec![
7767 Lexeme::new("fn", layout::Syntax::Keyword),
7768 Lexeme::plain(" main() { "),
7769 Lexeme::new("\"hi\"", layout::Syntax::String),
7770 Lexeme::plain(" }"),
7771 ],
7772 language: Some("rust".to_owned()),
7773 inline: false,
7774 });
7775
7776 assert!(html.contains("<pre"), "{html}");
7777 assert!(html.contains("data-language=\"rust\""), "{html}");
7778 assert!(
7779 html.contains("<span class=\"lex-keyword\">fn</span>"),
7780 "{html}"
7781 );
7782 assert!(
7783 html.contains("<span class=\"lex-string\">&quot;hi&quot;</span>")
7784 || html.contains("<span class=\"lex-string\">\"hi\"</span>"),
7785 "{html}"
7786 );
7787 // Two classified runs, two spans. The plain ones are bare text.
7788 assert_eq!(html.matches("<span class=\"lex-").count(), 2, "{html}");
7789 assert!(html.contains(" main() { "), "{html}");
7790 }
7791
7792 /// Concatenating the runs gives the source back, which is what `Lexeme::text`
7793 /// guarantees and what makes a code view usable at all.
7794 #[test]
7795 fn a_code_block_loses_no_character_of_its_source() {
7796 use quasi_router::screen::Lexeme;
7797
7798 let source = " let x = 1;\n // done\n";
7799 let html = Webview::new().fragment(&Node::Code {
7800 runs: vec![
7801 Lexeme::plain(" let x = "),
7802 Lexeme::new("1", layout::Syntax::Constant),
7803 Lexeme::plain(";\n "),
7804 Lexeme::new("// done", layout::Syntax::Comment),
7805 Lexeme::plain("\n"),
7806 ],
7807 language: None,
7808 inline: false,
7809 });
7810 let stripped = html
7811 .replace("<span class=\"lex-constant\">", "")
7812 .replace("<span class=\"lex-comment\">", "")
7813 .replace("</span>", "");
7814 let body = stripped
7815 .split_once('>')
7816 .expect("an opening tag")
7817 .1
7818 .rsplit_once("</pre>")
7819 .expect("a closing tag")
7820 .0;
7821 assert_eq!(body, source, "{html}");
7822 }
7823
7824 /// An inline literal is a leaf and comes out as one, which is the containment
7825 /// half of why `inline` is a flag rather than a second member.
7826 #[test]
7827 fn an_inline_literal_is_a_code_element_and_not_a_block() {
7828 use quasi_router::screen::Lexeme;
7829
7830 let html = Webview::new().fragment(&Node::Code {
7831 runs: vec![Lexeme::plain("git clone https://example.com/r.git")],
7832 language: None,
7833 inline: true,
7834 });
7835 assert!(html.contains("<code"), "{html}");
7836 assert!(!html.contains("<pre"), "{html}");
7837 }
7838
7839 /// A diff line says which side it is on, and an ordinary table says nothing,
7840 /// which is what keeps every list in the tree from reading as a diff.
7841 #[test]
7842 fn only_a_diff_row_carries_a_change_and_an_ordinary_row_carries_none() {
7843 use quasi_router::screen::Column;
7844
7845 let diff = Webview::new().fragment(&Node::Table {
7846 marks: ::quasi_router::stage::Marks::none(),
7847 columns: vec![Column::new("line")],
7848 rows: vec![
7849 Row::cells(["+ added"]).changed(layout::Change::Added),
7850 Row::cells(["- gone"]).changed(layout::Change::Removed),
7851 Row::cells([" same"]).changed(layout::Change::Context),
7852 ],
7853 more: None,
7854 });
7855 assert!(diff.contains("data-change=\"added\""), "{diff}");
7856 assert!(diff.contains("data-change=\"removed\""), "{diff}");
7857 assert!(diff.contains("data-change=\"context\""), "{diff}");
7858 // The tone rides beside it, so a host that has never heard of a diff still
7859 // gets the success and danger it already styles.
7860 assert!(diff.contains("data-tone=\"success\""), "{diff}");
7861 assert!(diff.contains("data-tone=\"danger\""), "{diff}");
7862
7863 let plain = Webview::new().fragment(&Node::Table {
7864 marks: ::quasi_router::stage::Marks::none(),
7865 columns: vec![Column::new("name")],
7866 rows: vec![Row::cells(["kick.wav"])],
7867 more: None,
7868 });
7869 assert!(!plain.contains("data-change"), "{plain}");
7870 }
7871
7872 /// A row's identity reaches the document whether or not the row is tickable.
7873 ///
7874 /// `Row::identified` says what the app calls this row, and a host that cannot
7875 /// read it back has been told nothing. MNW's file view is the measured
7876 /// consumer: its rows are lines and `#L42` is a line's identity.
7877 #[test]
7878 fn a_row_that_names_itself_says_so() {
7879 let html = fragment(&Node::Table {
7880 marks: ::quasi_router::stage::Marks::none(),
7881 columns: vec![Column::new("Line").width(layout::Width::Fill)],
7882 rows: vec![Row::cells([Cell::new("fn main() {}")]).identified("L1")],
7883 more: None,
7884 });
7885
7886 assert!(html.contains("data-value=\"L1\""), "{html}");
7887 // Not an id: a row identity is unique within its table, and two tables of
7888 // the same things on one screen is an ordinary description.
7889 assert!(!html.contains("id=\"L1\""), "{html}");
7890 }
7891
7892 /// A row that carries a document address reaches it as an `id`.
7893 ///
7894 /// The other half of the test above, and the member that fixes what it
7895 /// documents. `Row::address` is the one row member promising uniqueness in
7896 /// the document rather than in the table, so it is the one that earns an id.
7897 /// MNW's file view links every line number to `#L42`; before this member, the
7898 /// address it pointed at was in no document.
7899 #[test]
7900 fn a_row_that_carries_an_address_is_reachable_at_it() {
7901 let html = fragment(&Node::Table {
7902 marks: ::quasi_router::stage::Marks::none(),
7903 columns: vec![Column::new("Line").width(layout::Width::Fill)],
7904 rows: vec![Row::cells([Cell::new("fn main() {}")]).addressed("L1")],
7905 more: None,
7906 });
7907
7908 assert!(html.contains("id=\"L1\""), "{html}");
7909 // The address is not the value. A row can carry both, and a row carrying
7910 // only an address contributes nothing to a selection.
7911 assert!(!html.contains("data-value"), "{html}");
7912
7913 // A list row says it the same way.
7914 let list = fragment(&Node::Table {
7915 marks: ::quasi_router::stage::Marks::none(),
7916 columns: Vec::new(),
7917 rows: vec![Row::new("fn main() {}").addressed("L1")],
7918 more: None,
7919 });
7920 assert!(list.contains("id=\"L1\""), "{list}");
7921
7922 // And the escaping is the document's, not the description's.
7923 let hostile = fragment(&Node::Table {
7924 marks: ::quasi_router::stage::Marks::none(),
7925 columns: Vec::new(),
7926 rows: vec![Row::new("x").addressed("a\"onload=\"x")],
7927 more: None,
7928 });
7929 assert!(!hostile.contains("onload=\"x\""), "{hostile}");
7930 }
7931