Skip to main content

max / quasi

11.8 KB · 320 lines History Blame Raw
1 //! A described chart, derived and filled.
2 //!
3 //! The fourth shape here written for a feature rather than ported from a
4 //! screen, and it exists for [`crate::paged`]'s reason: the feature is what
5 //! makes the screen derivable at all. MNW's `/dashboard/tabs/analytics` drew
6 //! its revenue chart as markup the handler built and the renderer mounted, so
7 //! there was nowhere in a residual for it to go, and it was the one described
8 //! screen left off the seam.
9 //!
10 //! # What had to be true for a chart to compile
11 //!
12 //! The handler computed `revenue / most * 100.0` and put the percentage in the
13 //! markup. A residual holds numbers the description HANDS a renderer and never
14 //! ones a renderer works out from two of them -- `stage::number_at` says so --
15 //! so a chart drawn from a supplied percentage could be described and could not
16 //! be compiled: the derivation would find the arithmetic where it wanted a
17 //! stand-in, and bake one request's bar heights into the template.
18 //!
19 //! So the member carries both integers and neither renderer divides. The
20 //! webview prints `--most` once on the container and `--value` once per bar,
21 //! and the stylesheet is where they become a height. That is what this file
22 //! proves: the two numbers come back as holes, in the right places, at every
23 //! shape the data takes.
24 //!
25 //! # The axis is outside the loop and the bars are inside it
26 //!
27 //! Which is the second thing worth proving. `most` is one fact about the chart
28 //! and a request varies it; each bar's `value` is one per pass of a loop whose
29 //! length a request also varies. A residual holds those as a hole and a loop
30 //! body, and getting them the other way round -- the axis repeated per bar --
31 //! would serve the first reader's maximum to everybody after a request that
32 //! changed it.
33
34 // The fixture data below is built by this module's tests and by nothing else:
35 // the bench itself measures the shapes, not the rows behind them.
36 #![allow(dead_code)]
37 use quasi_declare::declare;
38 use quasi_router::{Bar, Chart};
39
40 /// What the screen read, as the screen needs it.
41 pub(crate) struct Takings {
42 pub bars: Vec<Bucket>,
43 pub most: usize,
44 }
45
46 /// One bucket of revenue: a day, what came in, and how many sales it took.
47 pub(crate) struct Bucket {
48 pub day: String,
49 pub cents: usize,
50 pub money: String,
51 pub sales: String,
52 }
53
54 impl Takings {
55 /// `days` buckets, the last of them the largest.
56 pub(crate) fn new(days: usize) -> Self {
57 let bars: Vec<Bucket> = (0..days)
58 .map(|n| {
59 let cents = (n + 1) * 137;
60 Bucket {
61 day: format!("Mar {}", n + 1),
62 cents,
63 money: format!("${}.{:02}", cents / 100, cents % 100),
64 sales: if n == 0 {
65 "1 sale".to_string()
66 } else {
67 format!("{} sales", n + 1)
68 },
69 }
70 })
71 .collect();
72 let most = bars.iter().map(|bar| bar.cents).max().unwrap_or(0);
73 Self { bars, most }
74 }
75
76 /// The degenerate axis: buckets that all came to nothing.
77 ///
78 /// Sayable on purpose, and the case a percentage could not survive -- the
79 /// old markup divided by it and wrote `--fill: NaN%`.
80 pub(crate) fn flat(days: usize) -> Self {
81 let mut takings = Self::new(days);
82 for bar in &mut takings.bars {
83 bar.cents = 0;
84 }
85 takings.most = 0;
86 takings
87 }
88 }
89
90 declare! {
91 /// Revenue over time, as bars against the largest day.
92 #[staged]
93 pub(crate) shape takings(read: &Takings) -> Node;
94
95 chart Chart::new(read.most) {
96 for bucket in read.bars.iter() {
97 bar Bar::at(bucket.day.clone())
98 .of(bucket.cents)
99 .reading(bucket.money.clone())
100 .note(bucket.sales.clone());
101 }
102 }
103 }
104
105 #[cfg(test)]
106 mod tests {
107 use quasi_http::Serves as _;
108 use quasi_router::stage::{Op, Plan, Residual};
109 use quasi_webview::Webview;
110
111 use super::*;
112
113 fn residual() -> Residual {
114 quasi_webview::stage::derive(&Webview::new(), takings_staged)
115 }
116
117 /// The axis is a hole at the top level and the bars are a loop, which is
118 /// the arrangement the whole member is shaped for. Stated as a shape test
119 /// rather than left to the filling tests below, because the two would still
120 /// agree if the axis were repeated inside the loop -- they only ever fill
121 /// one chart at a time -- and it would be wrong.
122 #[test]
123 fn the_axis_is_outside_the_loop_and_the_bars_are_inside_it() {
124 let residual = residual();
125
126 let outside = residual
127 .ops()
128 .iter()
129 .filter(|op| matches!(op, Op::Hole { .. }))
130 .count();
131 assert_eq!(
132 outside,
133 1,
134 "the axis is the one hole beside the loop: {:#?}",
135 residual.ops()
136 );
137
138 let loops: Vec<&[Op]> = residual
139 .ops()
140 .iter()
141 .filter_map(|op| match op {
142 Op::Loop(body) => Some(&**body),
143 _ => None,
144 })
145 .collect();
146 let [body] = loops.as_slice() else {
147 panic!("one loop, for the bars: {:#?}", residual.ops());
148 };
149 // The place, the magnitude and the two worded facts, which is every
150 // fact a bar carries.
151 assert_eq!(
152 body.iter()
153 .filter(|op| matches!(op, Op::Hole { .. }))
154 .count(),
155 4,
156 "a bar's four holes: {body:#?}"
157 );
158 }
159
160 /// No percentage reaches the template. This is the property the member
161 /// exists for, so it is asserted on the derived bytes rather than inferred
162 /// from the design.
163 #[test]
164 fn the_residual_holds_no_computed_width() {
165 fn literals(ops: &[Op], out: &mut String) {
166 for op in ops {
167 match op {
168 Op::Lit(text) => out.push_str(text),
169 Op::Branch(body) | Op::Loop(body) => literals(body, out),
170 Op::Arms(arms) => {
171 for arm in arms.iter() {
172 literals(arm, out);
173 }
174 }
175 Op::Hole { .. } => {}
176 }
177 }
178 }
179 let mut baked = String::new();
180 literals(residual().ops(), &mut baked);
181 assert!(!baked.contains('%'), "a width was baked in: {baked}");
182 }
183
184 /// One marker for a hole of either kind, so a render and a replay can be
185 /// compared on their literals and their structure.
186 ///
187 /// A chart is the first shape here holding both kinds at once -- a place and
188 /// two readings stand in as text, a magnitude and the axis as numbers -- and
189 /// `Op::Hole` does not record which it was, deliberately: a filler answers
190 /// by position and never needs to know. So a replay cannot reproduce the
191 /// render's exact bytes at a hole, and normalising both sides is what the
192 /// test can honestly ask for. What it is checking is the literals between
193 /// the holes and the shape around them, which is the whole of what a
194 /// residual adds.
195 const HOLE: &str = "\u{0}";
196
197 /// Every stand-in in a render, replaced by [`HOLE`].
198 fn without_stand_ins(render: &str) -> String {
199 let mut out = String::with_capacity(render.len());
200 let mut rest = render;
201 loop {
202 let sentinel = quasi_router::stage::find_sentinel(rest);
203 let number = quasi_router::stage::find_number(rest);
204 let (at, width) = match (sentinel, number) {
205 (Some(s), Some(n)) if s <= n => (s, quasi_router::stage::SENTINEL_LEN),
206 (Some(_), Some(n)) => (n, quasi_router::stage::NUMBER_LEN),
207 (Some(s), None) => (s, quasi_router::stage::SENTINEL_LEN),
208 (None, Some(n)) => (n, quasi_router::stage::NUMBER_LEN),
209 (None, None) => {
210 out.push_str(rest);
211 return out;
212 }
213 };
214 out.push_str(&rest[..at]);
215 out.push_str(HOLE);
216 rest = &rest[at + width..];
217 }
218 }
219
220 #[test]
221 fn replaying_the_residual_reproduces_the_staged_render() {
222 let webview = Webview::new();
223 let residual = residual();
224
225 fn replay(ops: &[Op], bars: usize, out: &mut String) {
226 for op in ops {
227 match op {
228 Op::Lit(text) => out.push_str(text),
229 Op::Hole { .. } => out.push_str(HOLE),
230 Op::Branch(body) => replay(body, bars, out),
231 Op::Arms(arms) => replay(&arms[0], bars, out),
232 Op::Loop(body) => {
233 for _ in 0..bars {
234 replay(body, bars, out);
235 }
236 }
237 }
238 }
239 }
240
241 for bars in [1, 2, 7] {
242 let mut replayed = String::new();
243 replay(residual.ops(), bars, &mut replayed);
244 assert_eq!(
245 without_stand_ins(&webview.fragment(&takings_staged(&Plan::full(bars)))),
246 replayed,
247 "the residual and the renderer disagree at {bars} bars"
248 );
249 }
250 }
251
252 /// The two kinds of stand-in land where they belong: the axis and the
253 /// magnitude as numbers, the place and the two readings as text.
254 ///
255 /// This is what the normalising above gives up, asserted separately rather
256 /// than lost. A magnitude standing in as text would reach the markup as
257 /// `ZQH..HQZ` inside a `--value`, which is not a number and would draw
258 /// nothing.
259 #[test]
260 fn a_magnitude_stands_in_as_a_number_and_a_place_as_text() {
261 let render = Webview::new().fragment(&takings_staged(&Plan::full(1)));
262 for (property, wants_number) in [("--most: ", true), ("--value: ", true)] {
263 let at = render.find(property).expect(property) + property.len();
264 assert_eq!(
265 quasi_router::stage::read_number(&render[at..]).is_some(),
266 wants_number,
267 "{property} in {render}"
268 );
269 }
270 let label =
271 render.find("chart-bar-label\">").expect("the label") + "chart-bar-label\">".len();
272 assert!(
273 quasi_router::stage::read_sentinel(&render[label..]).is_some(),
274 "a place is text: {render}"
275 );
276 }
277
278 /// Byte for byte against the renderer, across every shape the data takes:
279 /// no bars, one, several, and the axis that came to nothing.
280 #[test]
281 fn a_filled_chart_is_what_the_renderer_would_have_produced() {
282 let webview = Webview::new();
283 let residual = residual();
284
285 for days in [0, 1, 2, 7, 31] {
286 let read = Takings::new(days);
287 assert_eq!(
288 webview.fragment(&takings(&read)),
289 takings_serve(&residual, &read),
290 "{days} days"
291 );
292
293 let flat = Takings::flat(days);
294 assert_eq!(
295 webview.fragment(&takings(&flat)),
296 takings_serve(&residual, &flat),
297 "{days} days, all of them nothing"
298 );
299 }
300 }
301
302 /// A magnitude larger than any number a screen counts still fills as
303 /// itself. The numeric stand-in is a sixteen-digit value chosen to be
304 /// bigger than a real count, and revenue in cents is the member most likely
305 /// to reach for that room.
306 #[test]
307 fn a_large_magnitude_is_not_mistaken_for_a_stand_in() {
308 let webview = Webview::new();
309 let residual = residual();
310
311 let mut read = Takings::new(3);
312 read.bars[2].cents = 9_090_905_000_100_002;
313 read.most = read.bars[2].cents;
314 assert_eq!(
315 webview.fragment(&takings(&read)),
316 takings_serve(&residual, &read)
317 );
318 }
319 }
320