Skip to main content

max / quasi

10.9 KB · 331 lines History Blame Raw
1 //! A described pager, derived and filled.
2 //!
3 //! The second shape here written for a feature rather than ported from a
4 //! screen, and it exists for the same reason [`crate::marked`] does: the
5 //! feature is what makes a paged screen derivable at all.
6 //!
7 //! `more rest(loaded)` was a hole in a slot that takes a `Rest`, which has no
8 //! sentinel, and staging through the constructor did not help because the
9 //! supplier takes a struct. So a paged table could not reach the seam however
10 //! it was written. Said as a description the pager is a `Rest` settled by its
11 //! own body -- one guard per direction -- and everything it carries is a number
12 //! or an address, which are the two things a residual already holds.
13 //!
14 //! MNW's `/git` is the screen this is drawn from, down to its pager rendering
15 //! `Show more` rather than a page count: `Rest::page` with no total leaves
16 //! `pages_total` unsaid, so the only request-varying bytes are the two
17 //! addresses.
18
19 // The fixture data below is built by this module's tests and by nothing else:
20 // the bench itself measures the shapes, not the rows behind them.
21 #![allow(dead_code)]
22 use quasi_declare::declare;
23 use quasi_router::{Action, Jump, Rest};
24
25 /// How many rows a page holds. A `const`, so the residual bakes it.
26 const PER: usize = 20;
27
28 /// One page of repositories, as the screen reads it.
29 pub(crate) struct Loaded {
30 pub names: Vec<String>,
31 pub page: usize,
32 pub has_more: bool,
33 }
34
35 impl Loaded {
36 pub(crate) fn new(rows: usize, page: usize, has_more: bool) -> Self {
37 Self {
38 names: (0..rows).map(|n| format!("repo{n}")).collect(),
39 page,
40 has_more,
41 }
42 }
43
44 /// Where this page starts. A supplier because the form has no arithmetic.
45 fn offset(&self) -> usize {
46 (self.page - 1) * PER
47 }
48
49 fn previous(&self) -> usize {
50 self.page - 1
51 }
52
53 fn next(&self) -> usize {
54 self.page + 1
55 }
56 }
57
58 declare! {
59 /// The listing, with the pager that says what it has not shown.
60 #[staged]
61 pub(crate) shape listing(loaded: &Loaded) -> Node;
62
63 table {
64 column "Repository" {
65 width Fill;
66 }
67
68 for name in loaded.names.iter() {
69 cells {
70 cell name.clone();
71 }
72 }
73
74 // One page of one is the whole listing, and the pager is what says so.
75 // The two directions are guarded separately because they are separate
76 // facts: a middle page offers both, the first only forward, the last
77 // only back.
78 more Rest::page(loaded.offset(), PER) {
79 back Action::get("/git?page={loaded.previous()}").navigating()
80 when loaded.page over 1;
81 forward Action::get("/git?page={loaded.next()}").navigating()
82 when loaded.has_more;
83 } when loaded.page over 1 or loaded.has_more;
84 }
85 }
86
87 #[cfg(test)]
88 mod tests {
89 use quasi_http::Serves as _;
90 use quasi_router::stage::{Op, Plan, Residual};
91 use quasi_webview::Webview;
92
93 use super::*;
94
95 fn residual() -> Residual {
96 quasi_webview::stage::derive(&Webview::new(), listing_staged)
97 }
98
99 /// The pager is a branch, and each direction is a branch inside it.
100 #[test]
101 fn the_pager_and_both_its_directions_are_branches() {
102 let residual = residual();
103
104 fn deepest(ops: &[Op], depth: usize) -> usize {
105 ops.iter()
106 .map(|op| match op {
107 Op::Lit(_) | Op::Hole { .. } => depth,
108 Op::Branch(body) => deepest(body, depth + 1),
109 Op::Arms(arms) => arms
110 .iter()
111 .map(|arm| deepest(arm, depth + 1))
112 .max()
113 .unwrap_or(depth),
114 Op::Loop(body) => deepest(body, depth),
115 })
116 .max()
117 .unwrap_or(depth)
118 }
119
120 assert!(
121 deepest(residual.ops(), 0) >= 2,
122 "the pager holds its directions: {:#?}",
123 residual.ops()
124 );
125 }
126
127 #[test]
128 fn replaying_the_residual_reproduces_the_staged_render() {
129 let webview = Webview::new();
130 let residual = residual();
131
132 fn replay(ops: &[Op], rows: usize, out: &mut String) {
133 for op in ops {
134 match op {
135 Op::Lit(text) => out.push_str(text),
136 Op::Hole { scope, id } => {
137 out.push_str(&quasi_router::stage::sentinel_at(*scope, *id));
138 }
139 Op::Branch(body) => replay(body, rows, out),
140 Op::Arms(arms) => replay(&arms[0], rows, out),
141 Op::Loop(body) => {
142 for _ in 0..rows {
143 replay(body, rows, out);
144 }
145 }
146 }
147 }
148 }
149
150 for rows in [1, 2, 5] {
151 let mut replayed = String::new();
152 replay(residual.ops(), rows, &mut replayed);
153 assert_eq!(
154 webview.fragment(&listing_staged(&Plan::full(rows))),
155 replayed,
156 "the residual and the renderer disagree at {rows} rows"
157 );
158 }
159 }
160
161 /// Every page shape a pager has: the first, a middle one, the last, and the
162 /// one that is the whole listing and draws no pager at all.
163 #[test]
164 fn a_filled_listing_is_what_the_renderer_would_have_produced() {
165 let webview = Webview::new();
166 let residual = residual();
167
168 for (page, has_more) in [(1, true), (2, true), (3, false), (1, false)] {
169 for rows in [0, 3] {
170 let loaded = Loaded::new(rows, page, has_more);
171 assert_eq!(
172 webview.fragment(&listing(&loaded)),
173 listing_serve(&residual, &loaded),
174 "page {page}, has_more {has_more}, {rows} rows"
175 );
176 }
177 }
178 }
179 }
180
181 /// One page a strip offers, and whether it is the one being read.
182 pub(crate) struct Offered {
183 pub page: usize,
184 pub here: bool,
185 }
186
187 /// A windowed set, whose strip marks the page being read.
188 pub(crate) struct Windowed {
189 pub page: usize,
190 pub offered: Vec<Offered>,
191 }
192
193 impl Windowed {
194 /// A set of `pages`, the reader on `page`, every page offered.
195 pub(crate) fn new(page: usize, pages: usize) -> Self {
196 Self {
197 page,
198 offered: (1..=pages)
199 .map(|at| Offered {
200 page: at,
201 here: at == page,
202 })
203 .collect(),
204 }
205 }
206
207 fn offset(&self) -> usize {
208 (self.page - 1) * PER
209 }
210 }
211
212 declare! {
213 /// The same listing, paged with numbers rather than with two directions.
214 ///
215 /// This shape does NOT derive, and that is what it is here to hold. The
216 /// strip marks the page a reader is on by drawing a readout where every
217 /// other page is a control, which is a substitution rather than a gap --
218 /// `here` places nothing that turning it off would delete. See
219 /// `strip_tests` below.
220 #[staged]
221 pub(crate) shape windowed(loaded: &Windowed) -> Node;
222
223 table {
224 column "Page" {
225 width Fill;
226 }
227
228 more Rest::page(loaded.offset(), PER) {
229 for offered in loaded.offered.iter() {
230 jumping Jump::new(
231 offered.page,
232 Action::get("/git?page={offered.page}").navigating()
233 ) {
234 here when offered.here;
235 }
236 }
237 }
238 }
239 }
240
241 #[cfg(test)]
242 mod strip_tests {
243 use quasi_http::Serves as _;
244 use quasi_router::stage::{Op, Residual};
245 use quasi_webview::Webview;
246
247 use super::*;
248
249 fn residual() -> Residual {
250 quasi_webview::stage::derive(&Webview::new(), windowed_staged)
251 }
252
253 /// The strip is arms, and each arm holds what that markup needs.
254 ///
255 /// The shape `Op::Arms` exists for. The page a reader is on is a readout
256 /// and every other page is a control, which is two markups at one position
257 /// rather than markup that is there or is not -- so there is nothing for a
258 /// branch to measure. Note what the two arms carry: the readout has the
259 /// page number and no address, the control has both. Neither is a subset of
260 /// the other, which is why the holes are numbered in the level around them.
261 #[test]
262 fn the_strip_marks_its_page_with_arms() {
263 let residual = residual();
264
265 fn arms(ops: &[Op]) -> Option<&[std::borrow::Cow<'static, [Op]>]> {
266 ops.iter().find_map(|op| match op {
267 Op::Arms(arms) => Some(&**arms),
268 Op::Branch(body) | Op::Loop(body) => arms(body),
269 Op::Lit(_) | Op::Hole { .. } => None,
270 })
271 }
272
273 let found = arms(residual.ops()).expect("the strip derived arms");
274 assert_eq!(found.len(), 2, "{found:#?}");
275
276 fn holes(ops: &[Op]) -> Vec<u16> {
277 ops.iter()
278 .filter_map(|op| match op {
279 Op::Hole { id, .. } => Some(*id),
280 _ => None,
281 })
282 .collect()
283 }
284 let (here, elsewhere) = (holes(&found[0]), holes(&found[1]));
285 assert_eq!(here.len(), 1, "the readout is the page number: {here:?}");
286 assert_eq!(
287 elsewhere.len(),
288 2,
289 "the control is an address and a number: {elsewhere:?}"
290 );
291 assert!(
292 elsewhere.contains(&here[0]),
293 "both arms say which page they are: {here:?} {elsewhere:?}"
294 );
295 }
296
297 /// Every page of a four-page set, so the marked arm is each of them in turn.
298 #[test]
299 fn a_filled_strip_is_what_the_renderer_would_have_produced() {
300 let webview = Webview::new();
301 let residual = residual();
302
303 for page in 1..=4 {
304 let loaded = Windowed::new(page, 4);
305 assert_eq!(
306 webview.fragment(&windowed(&loaded)),
307 windowed_serve(&residual, &loaded),
308 "page {page} of 4"
309 );
310 }
311 }
312
313 /// The page numbers are filled rather than baked.
314 ///
315 /// A residual that had taken one arm for every row would serve the first
316 /// page's strip to every reader, which is what happened before `Op::Arms`.
317 #[test]
318 fn each_page_gets_its_own_strip() {
319 let residual = residual();
320 let first = windowed_serve(&residual, &Windowed::new(1, 4));
321 let third = windowed_serve(&residual, &Windowed::new(3, 4));
322
323 assert_ne!(first, third);
324 assert!(first.contains("aria-current=\"page\">1</span>"), "{first}");
325 assert!(third.contains("aria-current=\"page\">3</span>"), "{third}");
326 // And the page a reader is on is the only one that is not a control.
327 assert_eq!(third.matches("aria-current").count(), 1, "{third}");
328 assert_eq!(third.matches("rest-page\" href").count(), 3, "{third}");
329 }
330 }
331