Skip to main content

max / quasi

20.2 KB · 563 lines History Blame Raw
1 //! The same screen, declared.
2 //!
3 //! [`crate::staging`] writes MNW's `library_contacts` pane by hand, against a
4 //! `Data` seam invented for the spike. This is the screen as the server
5 //! actually holds it: a verbatim port of the three `declare!` blocks in
6 //! `MNW/server/src/quasi/library_contacts.rs`, against the bench's own rows.
7 //!
8 //! Two things it has that the hand-written copy does not, and both are the
9 //! point. Every table is placed behind a guard, so the branch case the spike's
10 //! module doc calls its real constraint is exercised here rather than assumed
11 //! away. And a shape reaches another shape by `include`, so the residual has to
12 //! compose rather than being one flat program.
13 //!
14 //! Kept verbatim rather than tidied. A bench measuring a shape written for the
15 //! bench measures the bench.
16
17 use quasi_declare::declare;
18 use quasi_router::Node;
19 use quasi_router::screen::Tag;
20
21 use crate::fixture::{Buyer, Rows, Shared};
22
23 /// The region the tab nav targets, as the server names it.
24 const REGION: &str = "tab-content";
25
26 /// This screen's own nest, which the revoke control addresses under.
27 const PATH: &str = "/library/tabs/contacts";
28
29 declare! {
30 /// Everything inside the tab pane.
31 #[staged]
32 pub(crate) shape pane(buyers: &[Buyer], shared: &[Shared]) -> Node;
33
34 region REGION as Pane {
35 empty "No contacts yet." when buyers.is_empty() and shared.is_empty();
36
37 section "Your Buyers ({buyers.len()})" unless buyers.is_empty();
38 text "Buyers who opted to share their email with you at purchase time."
39 unless buyers.is_empty();
40 include buyers_table(buyers) unless buyers.is_empty();
41
42 section "Shared With" unless shared.is_empty();
43 text "You've shared your email with these creators. You can revoke sharing at any time."
44 unless shared.is_empty();
45 include shared_table(shared) unless shared.is_empty();
46 }
47 }
48
49 declare! {
50 /// The buyers who shared an email.
51 #[staged]
52 pub(crate) shape buyers_table(buyers: &[Buyer]) -> Node;
53
54 table {
55 column "Username" {
56 width Content;
57 priority Essential;
58 }
59 column "Email" {
60 width Fill;
61 priority Essential;
62 }
63 column "Purchases" {
64 width Content;
65 }
66 column "Total Spent" {
67 width Content;
68 }
69 column "Last Purchase" {
70 width Content;
71 priority Optional;
72 }
73
74 for buyer in buyers.iter() {
75 cells {
76 cell buyer.username.clone() {
77 activate to get "/u/{buyer.username}" navigating;
78 }
79 cell buyer.email.clone() {
80 activate to external "mailto:{buyer.email}";
81 }
82 cell buyer.purchases.clone();
83 cell buyer.spent.clone();
84 cell buyer.last_purchase.clone();
85 }
86 }
87 }
88 }
89
90 declare! {
91 /// The same table with the guarded cell holding text rather than a token.
92 ///
93 /// Refused rather than compiled, and the refusal is the point. A cell whose
94 /// content is one piece of text makes its column write `cell-value`, so a
95 /// guard on it varies the block's class AND the block's contents. Those are
96 /// two places and a branch is one, so the derivation says so instead of
97 /// compiling the half it can see and serving a row with a stray class.
98 #[staged]
99 pub(crate) shape steered_table(buyers: &[Buyer]) -> Node;
100
101 table {
102 column "Username" {
103 width Content;
104 }
105 column "Note" {
106 width Content;
107 }
108
109 for buyer in buyers.iter() {
110 cells {
111 cell at "Username" buyer.username.clone();
112 cell at "Note" buyer.spent.clone() unless buyer.spent.is_empty();
113 }
114 }
115 }
116 }
117
118 declare! {
119 /// A table whose last column is answered by only some rows.
120 ///
121 /// The case MNW's repository listing is, and the one that says why a cell's
122 /// placement has two halves. A column emits its `<div>` for every row
123 /// whether or not a cell answers it, so a guarded cell's branch covers what
124 /// the cell WROTE and not the block around it. Covering the block deletes a
125 /// column the row still draws, and every row after it shifts one place
126 /// left.
127 #[staged]
128 pub(crate) shape sparse_table(buyers: &[Buyer]) -> Node;
129
130 table {
131 column "Username" {
132 width Content;
133 }
134 column "Note" {
135 width Content;
136 }
137
138 for buyer in buyers.iter() {
139 cells {
140 cell at "Username" buyer.username.clone();
141 // A token rather than the text itself, and that is the whole of
142 // why this one compiles. A cell holding one piece of text makes
143 // its column write `cell-value`, so guarding it would vary the
144 // block's class as well as its contents -- two places, and a
145 // branch is one. `quasi-webview` refuses that case by name; see
146 // the test below.
147 cell at "Note" "" unless buyer.spent.is_empty() {
148 token Tag::badge(buyer.spent.clone());
149 }
150 }
151 }
152 }
153 }
154
155 declare! {
156 /// The creators this reader has shared an email with.
157 #[staged]
158 pub(crate) shape shared_table(shared: &[Shared]) -> Node;
159
160 table {
161 column "Creator" {
162 width Fill;
163 priority Essential;
164 }
165 column "" {
166 width Content;
167 priority Essential;
168 }
169
170 for creator in shared.iter() {
171 cells {
172 cell creator.name.clone() {
173 activate to get "/u/{creator.username}" navigating;
174 }
175 cell "" {
176 act "Revoke" to delete "{PATH}/revoke/{creator.seller_id}" awaiting {
177 confirm "Revoke contact sharing with {creator.username}?";
178 tone Danger;
179 }
180 }
181 }
182 }
183 }
184 }
185
186 /// The pane, built from real rows the way a request builds it.
187 #[must_use]
188 pub fn describe(rows: &Rows) -> Node {
189 pane(&rows.buyers, &rows.shared)
190 }
191
192 #[cfg(test)]
193 mod tests {
194 use quasi_http::Serves as _;
195 use quasi_webview::Webview;
196
197 use super::*;
198 use crate::fixture::SIZES;
199
200 /// The port is faithful to the server's screen, which is not the same
201 /// thing as agreeing with [`crate::staging`].
202 ///
203 /// The hand-written copy is the impoverished one. It was written for the
204 /// spike against a `Data` seam and dropped four things the declaration
205 /// carries, each asserted below so that the difference is a record rather
206 /// than a surprise the next reader has to rediscover:
207 ///
208 /// - `navigating` on a profile link, which drops the htmx swap because a
209 /// profile is a whole document rather than a region.
210 /// - `confirm`, `tone Danger` and `awaiting` on the Revoke control.
211 ///
212 /// Everything the acceptance rule protects does agree: every address, the
213 /// actions and their methods, and the heading structure. That is checked
214 /// here, and the bytes deliberately are not.
215 #[test]
216 fn the_declared_pane_carries_what_the_hand_written_one_dropped() {
217 let webview = Webview::new();
218 let rows = Rows::new(5);
219 let hand = webview.fragment(&crate::fixture::pane(&rows));
220 let real = webview.fragment(&describe(&rows));
221
222 // What the declaration says and the copy could not.
223 assert!(real.contains(r#"data-tone="danger""#), "{real}");
224 assert!(real.contains("hx-confirm="), "{real}");
225 assert!(real.contains(r#"data-awaiting="indeterminate""#), "{real}");
226 assert!(!hand.contains("hx-confirm="), "{hand}");
227
228 // `navigating`: the anchor is the whole of it, so no swap is asked for.
229 assert!(!real.contains(r#"hx-get="/u/buyer0000""#), "{real}");
230 assert!(hand.contains(r#"hx-get="/u/buyer0000""#), "{hand}");
231
232 // What must survive regardless, which is the acceptance rule.
233 for size in SIZES {
234 let rows = Rows::new(size);
235 let real = webview.fragment(&describe(&rows));
236 for i in 0..size {
237 assert!(real.contains(&format!(r#"href="/u/buyer{i:04}""#)), "{i}");
238 assert!(
239 real.contains(&format!(r#"href="mailto:buyer{i:04}@example.com""#)),
240 "{i}"
241 );
242 assert!(
243 real.contains(&format!(
244 r#"hx-delete="/library/tabs/contacts/revoke/{:08}""#,
245 i * 31 + 7
246 )),
247 "{i}"
248 );
249 }
250 assert_eq!(
251 real.matches("table-row").count(),
252 size * 2,
253 "at {size} rows"
254 );
255 assert!(real.contains(&format!("Your Buyers ({size})")), "at {size}");
256 }
257 }
258
259 /// The guards the hand-written copy has no way to carry.
260 #[test]
261 fn no_rows_places_the_empty_line_and_neither_table() {
262 let html = Webview::new().fragment(&describe(&Rows::new(0)));
263
264 assert!(html.contains("No contacts yet."), "{html}");
265 assert!(!html.contains("Your Buyers"), "{html}");
266 assert!(!html.contains("Shared With"), "{html}");
267 }
268 }
269
270 #[cfg(test)]
271 mod staged_tests {
272 use quasi_http::Serves as _;
273 use quasi_router::stage::{Plan, read_sentinel};
274 use quasi_webview::Webview;
275
276 use super::*;
277
278 #[test]
279 fn the_staged_twin_renders_the_screen_with_sentinels_where_values_go() {
280 let html = Webview::new().fragment(&pane_staged(&Plan::full(1)));
281
282 // The literals are the renderer's own, so the markup is the real
283 // screen's and not something the macro decided.
284 assert!(html.contains(r#"id="tab-content""#), "{html}");
285 assert!(html.contains("Your Buyers ("), "{html}");
286 assert!(html.contains(r#"class="table-head""#), "{html}");
287 assert!(html.contains("Last Purchase"), "{html}");
288 assert!(html.contains("Revoke"), "{html}");
289
290 // The consts stayed real: a value that is the same for every request
291 // belongs in the literal rather than in a hole.
292 assert!(html.contains("/library/tabs/contacts/revoke/"), "{html}");
293
294 // Every per-request value became a sentinel, addresses included.
295 let mut holes = Vec::new();
296 let mut rest = html.as_str();
297 while let Some(at) = quasi_router::stage::find_sentinel(rest) {
298 if let Some(hole) = read_sentinel(&rest[at..]) {
299 holes.push(hole);
300 rest = &rest[at + quasi_router::stage::SENTINEL_LEN..];
301 } else {
302 rest = &rest[at + 3..];
303 }
304 }
305 assert!(!holes.is_empty(), "no sentinels at all: {html}");
306
307 // The two tables were reached by two includes, so their holes are in
308 // different scopes and nothing fills one from the other.
309 let scopes: std::collections::BTreeSet<u32> = holes.iter().map(|(s, _)| *s).collect();
310 assert!(
311 scopes.len() >= 3,
312 "expected root and two tables: {scopes:?}"
313 );
314 }
315
316 /// A guard no longer takes anything out of the staged render, and that is
317 /// the point rather than a regression.
318 ///
319 /// The twin used to answer each guard from the plan, so a derivation could
320 /// turn one off and measure what went missing. It now places every guarded
321 /// member and marks the run instead, which is why one render answers every
322 /// guard: what a guard controls is written down rather than deduced from a
323 /// gap. `Plan::empty` therefore draws exactly what `Plan::full` draws, and
324 /// the branch is in the marks.
325 #[test]
326 fn a_guard_places_its_emission_and_marks_it_instead() {
327 let webview = Webview::new();
328 let full = webview.fragment(&pane_staged(&Plan::full(1)));
329 let none = webview.fragment(&pane_staged(&Plan::empty()));
330
331 assert!(full.contains("Your Buyers ("), "{full}");
332 assert_eq!(
333 full.matches("table-head").count(),
334 none.matches("table-head").count(),
335 "a guard no longer decides what the twin draws"
336 );
337
338 // And the branch it used to make by deletion is in the residual.
339 let residual = quasi_webview::stage::derive(&webview, pane_staged);
340 assert!(
341 residual
342 .ops()
343 .iter()
344 .any(|op| matches!(op, quasi_router::stage::Op::Branch(_))),
345 "{:#?}",
346 residual.ops()
347 );
348 }
349
350 /// A loop runs as many times as the plan says, in the scope it is read at.
351 #[test]
352 fn one_loop_grows_without_moving_the_other() {
353 let webview = Webview::new();
354 let one = webview.fragment(&pane_staged(&Plan::full(1)));
355 let two = webview.fragment(&pane_staged(&Plan::full(2)));
356
357 assert_eq!(one.matches("table-row").count(), 2);
358 assert_eq!(two.matches("table-row").count(), 4);
359 }
360 }
361
362 #[cfg(test)]
363 mod residual_tests {
364 use quasi_http::Serves as _;
365 use quasi_router::stage::{Op, Plan, Residual};
366 use quasi_webview::Webview;
367
368 use super::*;
369
370 /// Replay a residual with sentinels still in it, which is what the staged
371 /// twin would have rendered at this many rows.
372 fn replay(ops: &[Op], rows: usize, out: &mut String) {
373 for op in ops {
374 match op {
375 Op::Lit(text) => out.push_str(text),
376 Op::Hole { scope, id } => {
377 out.push_str(&quasi_router::stage::sentinel_at(*scope, *id));
378 }
379 Op::Branch(body) => replay(body, rows, out),
380 // The base render is the guard-passing side, which is arm 0.
381 Op::Arms(arms) => replay(&arms[0], rows, out),
382 Op::Loop(body) => {
383 for _ in 0..rows {
384 replay(body, rows, out);
385 }
386 }
387 }
388 }
389 }
390
391 fn residual() -> Residual {
392 quasi_webview::stage::derive(&Webview::new(), pane_staged)
393 }
394
395 /// The derivation found structure, rather than one flat run of markup.
396 /// The case a branch cannot hold is refused where it is derived.
397 ///
398 /// On the build machine, where a wrong residual is still cheap. The filler
399 /// would not have caught it: it would have served every row the class the
400 /// staged render happened to have.
401 #[test]
402 #[should_panic(expected = "two places at once")]
403 fn a_guarded_cell_that_steers_its_column_is_refused() {
404 let _ = quasi_webview::stage::derive(&Webview::new(), steered_table_staged);
405 }
406
407 /// A guarded cell empties its column rather than removing it.
408 ///
409 /// The bug this caught, stated as a test: the filled residual has to equal
410 /// the renderer's own output for a row that answers the column and one that
411 /// does not, and the two differ by the contents of a `<div>` that is
412 /// present either way.
413 #[test]
414 fn a_guarded_cell_leaves_its_column_standing() {
415 let webview = Webview::new();
416 let residual = quasi_webview::stage::derive(&Webview::new(), sparse_table_staged);
417 let rows = vec![
418 Buyer {
419 username: "ada".into(),
420 email: String::new(),
421 purchases: String::new(),
422 spent: "$4".into(),
423 last_purchase: String::new(),
424 },
425 Buyer {
426 username: "bea".into(),
427 email: String::new(),
428 purchases: String::new(),
429 spent: String::new(),
430 last_purchase: String::new(),
431 },
432 ];
433 assert_eq!(
434 webview.fragment(&sparse_table(&rows)),
435 sparse_table_serve(&residual, &rows),
436 );
437
438 // And the column is still there in both rows, which is the property the
439 // whole split is for.
440 let filled = sparse_table_serve(&residual, &rows);
441 assert_eq!(filled.matches("col-Note").count(), 3, "{filled}");
442 }
443
444 #[test]
445 fn the_residual_has_a_branch_and_a_loop_in_it() {
446 let residual = residual();
447
448 fn count(ops: &[Op], branches: &mut usize, loops: &mut usize, holes: &mut usize) {
449 for op in ops {
450 match op {
451 Op::Lit(_) => {}
452 Op::Hole { .. } => *holes += 1,
453 Op::Branch(body) => {
454 *branches += 1;
455 count(body, branches, loops, holes);
456 }
457 Op::Arms(arms) => {
458 *branches += 1;
459 for arm in arms.iter() {
460 count(arm, branches, loops, holes);
461 }
462 }
463 Op::Loop(body) => {
464 *loops += 1;
465 count(body, branches, loops, holes);
466 }
467 }
468 }
469 }
470
471 let (mut branches, mut loops, mut holes) = (0, 0, 0);
472 count(residual.ops(), &mut branches, &mut loops, &mut holes);
473
474 // Seven guards in the pane and two loops, one per table.
475 assert_eq!(loops, 2, "{:#?}", residual.ops());
476 assert!(branches >= 6, "branches {branches}: {:#?}", residual.ops());
477 assert!(holes > 0, "no holes at all");
478 }
479
480 /// The residual is the renderer's own output with the repeats marked, so
481 /// replaying it at any row count has to give the twin's render back.
482 #[test]
483 fn replaying_the_residual_reproduces_the_staged_render() {
484 let webview = Webview::new();
485 let residual = residual();
486
487 for rows in [1, 2, 5, 25] {
488 let mut replayed = String::new();
489 replay(residual.ops(), rows, &mut replayed);
490 assert_eq!(
491 webview.fragment(&pane_staged(&Plan::full(rows))),
492 replayed,
493 "the residual and the renderer disagree at {rows} rows"
494 );
495 }
496 }
497 }
498
499 #[cfg(test)]
500 mod filled_tests {
501 use quasi_http::Serves as _;
502 use quasi_router::stage::Residual;
503 use quasi_webview::Webview;
504
505 use super::*;
506 use crate::fixture::SIZES;
507
508 fn residual() -> Residual {
509 quasi_webview::stage::derive(&Webview::new(), pane_staged)
510 }
511
512 /// The measurement this whole path exists for, stated as a test.
513 ///
514 /// Filling the residual builds no `Node`, and what comes out is what the
515 /// renderer produces from the tree. Byte for byte here, which is stronger
516 /// than the acceptance rule asks for: the rule allows markup to move,
517 /// because an emitter may optimise, and this one does not optimise yet.
518 #[test]
519 fn a_filled_residual_is_what_the_renderer_would_have_produced() {
520 let webview = Webview::new();
521 let residual = residual();
522
523 for size in SIZES {
524 let rows = Rows::new(size);
525 assert_eq!(
526 webview.fragment(&describe(&rows)),
527 pane_serve(&residual, &rows.buyers, &rows.shared),
528 "the staged path and the renderer disagree at {size} rows"
529 );
530 }
531 }
532
533 /// The branch, which is what the spike could not carry at all.
534 #[test]
535 fn the_empty_screen_goes_through_the_same_residual() {
536 let webview = Webview::new();
537 let residual = residual();
538 let rows = Rows::new(0);
539
540 assert_eq!(
541 webview.fragment(&describe(&rows)),
542 pane_serve(&residual, &rows.buyers, &rows.shared),
543 );
544 }
545
546 /// A value that would break the markup is escaped on the way in, through
547 /// the renderer's own escaper rather than a second one.
548 #[test]
549 fn a_hostile_value_is_escaped_the_way_the_renderer_escapes_it() {
550 let webview = Webview::new();
551 let residual = residual();
552
553 let mut rows = Rows::new(3);
554 rows.buyers[1].username = r#"<script>alert("x")</script>"#.into();
555 rows.shared[0].name = "Bobby & <b>Tables</b>".into();
556
557 let filled = pane_serve(&residual, &rows.buyers, &rows.shared);
558 assert_eq!(webview.fragment(&describe(&rows)), filled);
559 assert!(!filled.contains("<script>"), "{filled}");
560 assert!(filled.contains("&amp;"), "{filled}");
561 }
562 }
563