Skip to main content

max / quasi

7.1 KB · 224 lines History Blame Raw
1 //! A dispatch, derived and filled.
2 //!
3 //! The other half of what `Op::Arms` is for, and the one the refusal was
4 //! written about. A dispatch's cases replace each other at one position, so
5 //! there is no insertion to measure and no deletion to put back: the staged
6 //! twin read `plan.arm(id)`, which answered zero, and the residual held that
7 //! one case as a literal with the others simply gone. MNW's forum settings pane
8 //! compiled to "You haven't joined any forum communities yet." and would have
9 //! served that to every member.
10 //!
11 //! Now the twin is told how many cases there are, the derivation renders once
12 //! per case, and the arms are what they disagree over.
13 //!
14 //! MNW's feed is the screen that wanted it: a price cell is a badge when an
15 //! item is free and text when it is not, which is one question with two shapes
16 //! rather than two questions.
17
18 // The fixture data below is built by this module's tests and by nothing else:
19 // the bench itself measures the shapes, not the rows behind them.
20 #![allow(dead_code)]
21 use makeover_layout as layout;
22 use quasi_declare::declare;
23 use quasi_router::screen::Tag;
24
25 /// One item, priced or free.
26 pub(crate) struct Item {
27 pub name: String,
28 pub price: String,
29 pub free: bool,
30 }
31
32 /// What the price cell reads, which is nothing when the item is free.
33 fn price(item: &Item) -> String {
34 if item.free {
35 String::new()
36 } else {
37 item.price.clone()
38 }
39 }
40
41 pub(crate) fn items(free: &[bool]) -> Vec<Item> {
42 free.iter()
43 .enumerate()
44 .map(|(n, free)| Item {
45 name: format!("item{n}"),
46 price: format!("${n}.00"),
47 free: *free,
48 })
49 .collect()
50 }
51
52 declare! {
53 /// The listing, whose price cell has two shapes.
54 #[staged]
55 pub(crate) shape listing(items: &[Item]) -> Node;
56
57 table {
58 column "Name" {
59 width Fill;
60 }
61 column "Price" {
62 width Content;
63 }
64
65 for item in items.iter() {
66 include row(item);
67 }
68 }
69 }
70
71 declare! {
72 /// The same listing with the dispatch written inline, and a cell after it.
73 ///
74 /// MNW's feed is this shape rather than the one below: the dispatch sits in
75 /// the loop body of the table's own shape, with cells either side of it and
76 /// an `activate` on the row. The version below puts the row in a shape of
77 /// its own, which gives the dispatch a scope to itself and a position at
78 /// the end -- so it never exercised what follows an arm.
79 #[staged]
80 pub(crate) shape inline_listing(items: &[Item]) -> Node;
81
82 table {
83 column "Name" {
84 width Fill;
85 }
86 column "Price" {
87 width Content;
88 }
89 column "Date" {
90 width Content;
91 }
92
93 for item in items.iter() {
94 cells {
95 cell at "Name" item.name.clone();
96
97 given item.free {
98 true -> cell at "Price" "" {
99 token Tag::badge("Free").tone(layout::Tone::Success);
100 }
101 otherwise -> cell at "Price" price(item);
102 }
103
104 cell at "Date" item.name.clone();
105
106 activate to get "/i/{item.name}" navigating;
107 }
108 }
109 }
110 }
111
112 declare! {
113 /// One row. The price cell is a dispatch, not a pair of guards.
114 ///
115 /// Said as two guarded cells it would be one question written twice --
116 /// wiki `quasi-declare-form` section 24's first rule -- and the derivation
117 /// varies guards one at a time, so it would render a Price column holding
118 /// two cells, which the screen never produces.
119 #[staged]
120 pub(crate) shape row(item: &Item) -> Row;
121
122 cells {
123 cell at "Name" item.name.clone();
124
125 given item.free {
126 true -> cell at "Price" "" {
127 token Tag::badge("Free").tone(layout::Tone::Success);
128 }
129 otherwise -> cell at "Price" price(item);
130 }
131 }
132 }
133
134 #[cfg(test)]
135 mod tests {
136 use quasi_http::Serves as _;
137 use quasi_router::stage::{Op, Residual};
138 use quasi_webview::Webview;
139
140 use super::*;
141
142 fn residual() -> Residual {
143 quasi_webview::stage::derive(&Webview::new(), listing_staged)
144 }
145
146 /// The dispatch is arms, and both cases are in the residual.
147 ///
148 /// The old failure stated as a test: exactly one case reached the residual
149 /// and the rest were gone, so a residual holding only one is the bug.
150 /// A dispatch with a cell after it, which is the feed's shape.
151 #[test]
152 fn an_arm_is_followed_by_the_cells_written_after_it() {
153 let webview = Webview::new();
154 let residual = quasi_webview::stage::derive(&Webview::new(), inline_listing_staged);
155 let rows = items(&[true, false, true]);
156 assert_eq!(
157 webview.fragment(&inline_listing(&rows)),
158 inline_listing_serve(&residual, &rows),
159 );
160 }
161
162 #[test]
163 fn both_cases_reach_the_residual() {
164 let residual = residual();
165
166 fn arms(ops: &[Op]) -> Option<usize> {
167 ops.iter().find_map(|op| match op {
168 Op::Arms(arms) => Some(arms.len()),
169 Op::Branch(body) | Op::Loop(body) => arms(body),
170 Op::Lit(_) | Op::Hole { .. } => None,
171 })
172 }
173
174 assert_eq!(arms(residual.ops()), Some(2), "{:#?}", residual.ops());
175 }
176
177 /// Filled, it is what the renderer builds, for every mix of the two cases.
178 #[test]
179 fn a_filled_listing_is_what_the_renderer_would_have_produced() {
180 let webview = Webview::new();
181 let residual = residual();
182
183 for mix in [
184 vec![],
185 vec![false],
186 vec![true],
187 vec![false, true],
188 vec![true, false],
189 vec![true, true, false],
190 ] {
191 let items = items(&mix);
192 assert_eq!(
193 webview.fragment(&listing(&items)),
194 listing_serve(&residual, &items),
195 "{mix:?}"
196 );
197 }
198 }
199
200 /// And the two cases really are different markup, so the test above is
201 /// checking something.
202 #[test]
203 fn a_free_item_and_a_priced_one_draw_differently() {
204 let residual = residual();
205 let free = listing_serve(&residual, &items(&[true]));
206 let priced = listing_serve(&residual, &items(&[false]));
207
208 // The price cell specifically: the name cell carries `cell-value` in
209 // both, which is the point of checking the column rather than the row.
210 fn price_cell(html: &str) -> &str {
211 // `class="cell ` and not just the column name: the heading row
212 // carries the same column token.
213 let at = html.find("class=\"cell col-Price").expect("a price cell");
214 let rest = &html[at..];
215 &rest[..rest.find("</div>").map_or(rest.len(), |to| to + 6)]
216 }
217
218 assert!(price_cell(&free).contains("badge"), "{free}");
219 assert!(!price_cell(&free).contains("cell-value"), "{free}");
220 assert!(price_cell(&priced).contains("cell-value"), "{priced}");
221 assert!(!price_cell(&priced).contains("badge"), "{priced}");
222 }
223 }
224