Skip to main content

max / makenotwork

34.6 KB · 940 lines History Blame Raw
1 //! The two feed surfaces, described.
2 //!
3 //! `830b1661`, and the first described paging anywhere on this server:
4 //! `grep -rn 'Rest' src/quasi/` returned one hit before this module and it was
5 //! the string "Restore".
6 //!
7 //! # Why the two go together
8 //!
9 //! `partials/tabs/library_feed.html` (the panel behind `/library/tabs/feed`)
10 //! and `pages/feed.html` (the page at `/feed`) drew the same thing: the same
11 //! `Vec<DiscoverItem>` in the same five columns, from the same query, with the
12 //! same numbered strip under it. Two handlers, two templates, one table.
13 //! Converting one and leaving the other would leave that table described in one
14 //! place and spelled in markup in the other, which is the divergence a
15 //! conversion exists to end.
16 //!
17 //! So the table is described once, here, and both surfaces call it. What
18 //! differs between them is one thing and it is stated as one thing: where a
19 //! page control goes. The panel's paging swaps the panel
20 //! ([`Action::replacing`]); the page's paging replaces the document
21 //! ([`Action::navigating`]).
22 //!
23 //! # The window stays server-side
24 //!
25 //! [`Rest::jumps`] is explicit that windowing is the description's call rather
26 //! than the renderer's, and `super::super::routes::pages::public::pagination::build_pagination_range`
27 //! already windows to five around the reader. That function is untouched: what
28 //! arrives here is the pages being offered, in reading order, and each is given
29 //! its own address. A renderer that built page 5's address out of `forward` and
30 //! `back` would be reconstructing the `?page=` grammar this retires.
31 //!
32 //! # What the row is
33 //!
34 //! A whole row is the link, which is what the markup said with `<a
35 //! class="feed-table-row">` wrapping five spans. [`Row::activate`] is that,
36 //! and it carries [`Action::navigating`] because an item page is a page: a bare
37 //! `Action::get` emits an `href` *and* an `hx-get` with no target, and htmx
38 //! puts the whole document inside the row that was pressed. That is
39 //! quasicoherent `00ee7af5`, met here for the second time on this server.
40 //!
41 //! # The two-line name cell
42 //!
43 //! Name over creator, one cell, which the template drew as a nested `<div>`
44 //! with two spans. [`Cell::part`] is the general form for exactly this and
45 //! needs no member: the cell is an inline run holding two leaves, the first of
46 //! which [`Cell::activate`] turns into the row's link text.
47 //!
48 //! # What is restated, and it is one line
49 //!
50 //! "Showing 1-20 of 400 items" is prose above the table, and every number in it
51 //! is also in the [`Rest`] under the table. That is a duplication and it is
52 //! deliberate: the webview renderer draws the numbered strip *instead of* its
53 //! position readout (`rest_strip_html`, "a control arguing with itself"), so a
54 //! reader given a strip is told which page they are on and never how many items
55 //! there are. Dropping the line would be a visible loss to buy tidiness in a
56 //! layer the reader cannot see. A renderer that printed the total beside a
57 //! strip would let this go, and that is a `quasi-webview` question rather than
58 //! this server's.
59 //!
60 //! # The page owns its document
61 //!
62 //! `b5cbb646`. `pages/feed.html` is gone and so is `landing`'s Askama route:
63 //! [`screen`] describes the whole page and [`renderer`] draws the document it
64 //! sits in. The site header is not described and is not going to be: it is one
65 //! element in the assembly layer ([`crate::shell::site_header`]), handed to the
66 //! shell as `body_first` here and included by 64 templates there. The ruling
67 //! and its evidence are in wiki `mnw-server-conversion-plan`, dated 2026-08-31.
68 //!
69 //! One behaviour changed with the route. `ValidatedQuery<FeedQuery>` refused
70 //! `?page=abc` with a 400; [`screen`] parses what it can and falls back to page
71 //! one, which is what a reader who mangled a URL wants and what every other
72 //! described screen already does with its carried values.
73 //!
74 //! The six other `page=` sites are Newer/Older or Previous/Next only and want
75 //! no `jumps`: `pages/git/{commits,explore,issues,file_log,notes}.html` and
76 //! `partials/admin_user_entries.html`. Named here so a later pass does not
77 //! "fix" them into strips they never had.
78 //!
79 //! [`Action::replacing`]: quasi_router::Action::replacing
80 //! [`Action::navigating`]: quasi_router::Action::navigating
81 //! [`Row::activate`]: quasi_router::screen::Row::activate
82 //! [`Cell::part`]: quasi_router::screen::Cell::part
83
84 use makeover_layout as layout;
85 use quasi_declare::declare;
86 use quasi_router::screen::{Jump, Rest, Tag};
87 use quasi_router::{Action, Document, Node, RegionKind, RouteError, Slot};
88 use quasi_webview::Webview;
89
90 use crate::constants;
91 use crate::types::DiscoverItem;
92
93 /// The region the library panel's answer lands in.
94 ///
95 /// The id `super::library_tabs` draws its Feed frame from, so a page control
96 /// swapping this swaps the panel and nothing around it.
97 pub const LIBRARY_REGION: &str = "library-feed";
98
99 /// The region the public page's body sits in.
100 ///
101 /// Public so the pressed-screen table and the skip link name it rather than
102 /// transcribe it.
103 pub const PAGE_REGION: &str = "feed";
104
105 /// This screen's name, the marker a tab strip reads.
106 pub const SCREEN: &str = "feed";
107
108 /// The address this screen answers, and the one the Askama route gave up.
109 pub const PATH: &str = "/feed";
110
111 /// How wide the page runs. `pages/feed.html` said this as
112 /// `class="{{ shell::measure(Wide) }}"`; it is a described property now.
113 const MEASURE: layout::Measure = layout::Measure::Wide;
114
115 /// The address the library panel is read from.
116 const LIBRARY_ROUTE: &str = "/library/tabs/feed";
117
118 /// One page of a feed, as both handlers have already computed it.
119 ///
120 /// Every field here is what the two templates were handed, under the names they
121 /// were handed them under. What owns the values is [`Loaded`], and the
122 /// arithmetic that produces them is [`load`]: both handlers used to carry a
123 /// verbatim copy of it, which is one clamp, one `i64` widening and two
124 /// saturating labels duplicated three lines apart.
125 pub struct Page<'a> {
126 /// The rows, in the order they read.
127 pub items: &'a [DiscoverItem],
128 /// How many there are altogether.
129 pub total_items: u32,
130 /// Which page this is, counting from one.
131 pub current_page: u32,
132 /// How many pages there are.
133 pub total_pages: u32,
134 /// The pages the strip offers, already windowed by the handler.
135 pub pagination_range: &'a [u32],
136 /// The first row's position in the whole set, counting from one.
137 pub showing_start: u32,
138 /// The last row's position in the whole set.
139 pub showing_end: u32,
140 }
141
142 /// One page the strip offers, and whether it is the one being read.
143 ///
144 /// `here` is carried per row rather than worked out by comparing each page
145 /// against the current one. `quasi_router::Jump::here` has the reason: a strip
146 /// is a loop, and a residual holds one compiled body per loop, so "exactly one
147 /// row differs" is not a property of the body when the difference is a
148 /// comparison the body does not make.
149 pub struct Offered {
150 /// Which page, counting from one.
151 pub page: usize,
152 /// Whether it is the one being read.
153 pub here: bool,
154 }
155
156 impl Page<'_> {
157 /// Where this page starts, which is what a `Rest` counts from.
158 ///
159 /// Suppliers rather than expressions because the declared form has no
160 /// arithmetic, and these are the same sums [`load`] makes.
161 fn offset(&self) -> usize {
162 (self.current_page as usize - 1) * constants::FEED_PAGE_SIZE as usize
163 }
164
165 fn per(&self) -> usize {
166 constants::FEED_PAGE_SIZE as usize
167 }
168
169 fn total(&self) -> usize {
170 self.total_items as usize
171 }
172
173 fn previous(&self) -> u32 {
174 self.current_page.saturating_sub(1)
175 }
176
177 fn next(&self) -> u32 {
178 self.current_page + 1
179 }
180
181 fn has_previous(&self) -> bool {
182 self.current_page > 1
183 }
184
185 fn has_next(&self) -> bool {
186 self.current_page < self.total_pages
187 }
188
189 /// The pages the strip offers, marked. The window is the handler's, which
190 /// is `build_pagination_range`; nothing here windows anything.
191 fn offered(&self) -> Vec<Offered> {
192 self.pagination_range
193 .iter()
194 .map(|at| Offered {
195 page: *at as usize,
196 here: *at == self.current_page,
197 })
198 .collect()
199 }
200 }
201
202 /// The library's Feed panel, in its region, for the tab route to answer with.
203 #[must_use]
204 pub fn library_fragment(page: &Page<'_>) -> String {
205 use quasi_axum::Serves as _;
206
207 let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane);
208 for node in panel_body(page) {
209 slot = slot.with(node);
210 }
211 Webview::new().fragment(&Node::Region(slot))
212 }
213
214 /// One page of a reader's feed, loaded. Owns what [`Page`] borrows.
215 pub struct Loaded {
216 items: Vec<DiscoverItem>,
217 total_items: u32,
218 current_page: u32,
219 total_pages: u32,
220 pagination_range: Vec<u32>,
221 showing_start: u32,
222 showing_end: u32,
223 }
224
225 impl Loaded {
226 /// What was loaded, as the description reads it.
227 #[must_use]
228 pub fn page(&self) -> Page<'_> {
229 Page {
230 items: &self.items,
231 total_items: self.total_items,
232 current_page: self.current_page,
233 total_pages: self.total_pages,
234 pagination_range: &self.pagination_range,
235 showing_start: self.showing_start,
236 showing_end: self.showing_end,
237 }
238 }
239 }
240
241 /// Read one page of a reader's feed.
242 ///
243 /// The clamp, the `i64` widening before the multiply and the saturating
244 /// "showing" labels are three overflow fixes, moved here verbatim rather than
245 /// re-derived. They lived in `routes::pages::public::feed` and in
246 /// `landing::library_tab_feed` as byte-identical copies; there is one now, so
247 /// the library panel and the page cannot page differently.
248 ///
249 /// Async, so the panel awaits it and the described screen reaches it through
250 /// [`super::Viewer::block_on`].
251 pub async fn load(
252 db: &sqlx::PgPool,
253 user: crate::db::UserId,
254 page: Option<u32>,
255 ) -> crate::error::Result<Loaded> {
256 // Clamp the upper bound too (matches admin/git pagination); the i64
257 // widening below already prevents the overflow panic, but an unbounded page
258 // is a pointless huge offset (Run #2 UX MINOR).
259 let page = page.unwrap_or(1).clamp(1, 1_000_000_000);
260 // Widen to i64 BEFORE multiplying, `(page - 1) * FEED_PAGE_SIZE` in u32
261 // overflows for a large `?page=` (garbage offset in release, panic in debug).
262 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
263
264 let total_items = crate::db::follows::count_followed_feed_items(db, user).await? as u32;
265 let total_pages =
266 (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
267
268 let db_items = crate::db::follows::get_followed_feed_items(
269 db,
270 user,
271 constants::FEED_PAGE_SIZE as i64,
272 offset,
273 )
274 .await?;
275 let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
276
277 // Compute the "showing X-Y" labels in i64 (saturating) to avoid the u32
278 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`.
279 let showing_start = if total_items == 0 {
280 0
281 } else {
282 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
283 };
284 let showing_end = offset
285 .saturating_add(constants::FEED_PAGE_SIZE as i64)
286 .min(total_items as i64)
287 .clamp(0, u32::MAX as i64) as u32;
288
289 Ok(Loaded {
290 items,
291 total_items,
292 current_page: page,
293 total_pages,
294 pagination_range: crate::routes::pages::public::pagination::build_pagination_range(
295 page,
296 total_pages,
297 ),
298 showing_start,
299 showing_end,
300 })
301 }
302
303 /// The one read this page makes, for the mount that serves it from a residual.
304 ///
305 /// Answers `Loaded` rather than `Page`, because a `Page` borrows it: the mount
306 /// holds this and takes the borrow twice, once to state the document and once
307 /// to fill the markup, from the same read.
308 pub(crate) fn reading(
309 viewer: &super::Viewer,
310 carried: &super::Carried,
311 ) -> Result<Loaded, RouteError> {
312 let asked = carried
313 .asked("page")
314 .and_then(|value| value.trim().parse::<u32>().ok());
315 viewer
316 .block_on(load(&viewer.app.db, viewer.reader()?.id, asked))
317 .map_err(|_| RouteError::internal("your feed could not be read"))
318 }
319
320 declare! {
321 /// The whole document: the title, the measure, the body.
322 ///
323 /// The body arrives as a list of members and is spread into the pane one at
324 /// a time, which is the same loop both callers wrote by hand: a fill has no
325 /// region of its own, so nothing can place it whole.
326 pub(crate) shape page_screen(page: &Page<'_>) -> Screen;
327
328 screen single "Feed - Makenotwork" {
329 measured MEASURE;
330 // `padded-page feed-page`, which is what `pages/feed.html:4` rendered.
331 // Composed rather than written out: `Document::classed` replaces, so a
332 // screen naming only its own token would drop its measure (`2790e5c4`).
333 documented Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"]));
334 summarised "Items from the users, projects and tags you follow.";
335
336 include page_region(page);
337 }
338 }
339
340 declare! {
341 /// The page's one region, split out so it can be staged.
342 #[staged]
343 pub(crate) shape page_region(page: &Page<'_>) -> Slot;
344
345 region PAGE_REGION as Pane {
346 page "Your Feed";
347 include each page_body(page);
348 }
349 }
350
351 /// The document this screen is drawn in.
352 ///
353 /// The head, the tail and the token meta come off
354 /// [`super::Viewer::document_shell`]. What is added here is what every page on
355 /// this site opens with: the skip link, and the site header
356 /// ([`crate::shell::site_header`]) which is markup in the assembly layer rather
357 /// than anything a screen describes.
358 #[must_use]
359 pub fn renderer(viewer: &super::Viewer) -> Webview {
360 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
361 "{}{}",
362 crate::shell::skip_link(PAGE_REGION),
363 crate::shell::site_header(viewer.user.as_ref()),
364 )))
365 }
366
367 /// Which of the two surfaces is being drawn.
368 ///
369 /// The only thing that differs between them, so it is one value rather than two
370 /// copies of the body. See the module header.
371 /// What going to `page` calls on the library panel: a swap of the panel, in
372 /// place, leaving the rest of the library where it is.
373 ///
374 /// The page surface's answer is not here. It is written in `page_table`'s own
375 /// `more` body, because that surface is served from a residual and a supplier
376 /// handing over a whole `Action` is markup the derivation cannot see into. See
377 /// the note on `panel_table`.
378 fn panel_address(page: u32) -> Action {
379 Action::get(format!("{LIBRARY_ROUTE}?page={page}"))
380 .awaiting()
381 .replacing(LIBRARY_REGION)
382 }
383
384 declare! {
385 /// The library panel's contents, in order.
386 ///
387 /// One of a pair with [`page_body`], and the pair is what the surface split
388 /// costs. Everything the two surfaces say the same way is said once --
389 /// the empty state, the count, every row and every cell. What they cannot
390 /// share is the pager: a page control on the panel swaps the panel in place
391 /// and one on the page navigates, which is different markup rather than a
392 /// different address, so it cannot be a value either surface hands over.
393 /// See `panel_table`.
394 shape panel_body(page: &Page<'_>) -> Vec<Node>;
395
396 include empty() when page.items.is_empty();
397
398 text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items"
399 unless page.items.is_empty();
400 include panel_table(page) unless page.items.is_empty();
401 }
402
403 declare! {
404 /// The public page's contents, in order. See [`panel_body`].
405 #[staged]
406 pub(crate) shape page_body(page: &Page<'_>) -> Vec<Node>;
407
408 include empty() when page.items.is_empty();
409
410 text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items"
411 unless page.items.is_empty();
412 include page_table(page) unless page.items.is_empty();
413 }
414
415 declare! {
416 /// Nothing followed yet.
417 ///
418 /// The two templates spelled this differently -- the panel wrote its own
419 /// three paragraphs and a button, the page called
420 /// `ui::empty_state_with_action` -- and said the same thing with the same
421 /// way out. One spelling now, which is one of the things converting both
422 /// surfaces together buys.
423 #[constant]
424 shape empty() -> Node;
425
426 empty "Nothing here yet. Follow users, projects, or tags to see their items here." {
427 offering "Browse Discover" to get "/discover" navigating;
428 }
429 }
430
431 declare! {
432 /// The five columns, and the rows under them, for the library panel.
433 ///
434 /// # Why there are two of these
435 ///
436 /// The column list is written twice, here and in [`page_table`], and that
437 /// is the price of the surface split rather than an oversight. What forced
438 /// it: a page control on the panel is
439 /// `Action::get(..).awaiting().replacing(LIBRARY_REGION)` and one on the
440 /// page is `Action::get(..).navigating()`, which is different MARKUP, not a
441 /// different address. A residual holds one markup per position, and the
442 /// page surface is served from one.
443 ///
444 /// Saying the difference as guards on the action's modifiers was refused on
445 /// reading: three guards that have to agree is what wiki
446 /// `quasi-declare-form` section 24 calls one question written three times,
447 /// and a derivation varies them one at a time.
448 ///
449 /// **The two lists are held together by a test rather than by hope.**
450 /// `the_two_surfaces_draw_the_same_columns` renders both and compares the
451 /// heading rows, so a column added to one and forgotten in the other fails
452 /// there. Everything below the headings -- every row, every cell -- is
453 /// [`row`], said once.
454 shape panel_table(page: &Page<'_>) -> Node;
455
456 table {
457 column "Type" {
458 width Content;
459 }
460 column "Name" {
461 width Fill;
462 priority Essential;
463 }
464 column "Tag" {
465 width Content;
466 }
467 column "Price" {
468 width Content;
469 }
470 column "Date" {
471 width Content;
472 }
473
474 for item in page.items.iter() {
475 include row(item);
476 }
477
478 more panel_rest(page) when page.total_pages over 1;
479 }
480 }
481
482 declare! {
483 /// The same five columns and rows, for the public page. See [`panel_table`].
484 ///
485 /// The pager is described rather than supplied, which is what puts this
486 /// surface on the residual seam: a `Rest` handed over whole has no
487 /// sentinel, and everything a described one carries is a number or an
488 /// address. The directions are written back, then the strip, then forward,
489 /// because that is the order they draw in and `quasi-declare` holds this to
490 /// it.
491 #[staged]
492 pub(crate) shape page_table(page: &Page<'_>) -> Node;
493
494 table {
495 column "Type" {
496 width Content;
497 }
498 column "Name" {
499 width Fill;
500 priority Essential;
501 }
502 column "Tag" {
503 width Content;
504 }
505 column "Price" {
506 width Content;
507 }
508 column "Date" {
509 width Content;
510 }
511
512 for item in page.items.iter() {
513 include row(item);
514 }
515
516 more Rest::page(page.offset(), page.per()).of(page.total()) {
517 back Action::get("{PATH}?page={page.previous()}").navigating()
518 when page.has_previous();
519 // The strip, one control per page the handler windowed to. Which
520 // one the reader is on is a readout rather than a control, which is
521 // two markups at one position and is why `Op::Arms` had to exist.
522 for offered in page.offered().iter() {
523 jumping Jump::new(
524 offered.page,
525 Action::get("{PATH}?page={offered.page}").navigating()
526 ) {
527 here when offered.here;
528 }
529 }
530 forward Action::get("{PATH}?page={page.next()}").navigating()
531 when page.has_next();
532 } when page.total_pages over 1;
533 }
534 }
535
536 /// What the price cell reads, which is nothing when the item is free.
537 ///
538 /// A free item says so with a token instead, and the two are one cell rather
539 /// than two guarded ones: a cell that is sometimes a word and sometimes a badge
540 /// is still one cell.
541 fn price(item: &DiscoverItem) -> String {
542 if item.is_free {
543 String::new()
544 } else {
545 item.price.clone()
546 }
547 }
548
549 declare! {
550 /// One item.
551 ///
552 /// The cells name their columns rather than counting to them, because the
553 /// headings are in [`table`] and this is a different shape: position is only
554 /// checkable when both halves are in front of you, and here they never are.
555 /// The names must match [`table`]'s `column` strings exactly, since a name
556 /// no column carries is dropped rather than reported.
557 #[staged]
558 shape row(item: &DiscoverItem) -> Row;
559
560 cells {
561 cell at "Type" "" {
562 token Tag::badge(item.item_type.clone());
563 }
564 // The name is the link text and the creator rides under it in the same
565 // cell, which is what `Cell::activate` picks: the first text part.
566 cell at "Name" item.name.clone() {
567 text item.creator.clone();
568 }
569 cell at "Tag" item.primary_tag.clone();
570 // One question with two shapes rather than two questions. A free item
571 // says so with a badge and a priced one reads its price, and the cell
572 // carries `cell-value` in the second case and not in the first -- so
573 // the two are different markup at one position, which is a dispatch.
574 //
575 // Said as two guarded cells it would be one question written twice
576 // (wiki `quasi-declare-form` section 24, rule one) and a derivation
577 // varies guards one at a time, so it would render a Price column
578 // holding two cells. quasicoherent `cbb63155`.
579 given item.is_free {
580 true -> cell at "Price" "" {
581 token Tag::badge("Free").tone(layout::Tone::Success);
582 }
583 otherwise -> cell at "Price" price(item);
584 }
585 cell at "Date" item.date.clone();
586
587 activate to get "/i/{item.id}" navigating;
588 }
589 }
590
591 /// What the reader has not been shown, and every way to ask for it.
592 ///
593 /// The panel's, and the panel's alone. A supplier is the right shape here
594 /// because this surface is not on the residual seam: it builds nodes per
595 /// request, so handing the renderer a whole `Rest` costs nothing. The page
596 /// surface describes its pager instead, in `page_table`.
597 ///
598 /// The table asks for this only when there is more than one page, which is what
599 /// `{% if total_pages > 1 %}` said: a set that arrived whole has no rest, and a
600 /// pager drawn over one would be one control and the number 1. R9 means this is
601 /// still called on a single-page feed, and the answer is thrown away.
602 fn panel_rest(page: &Page<'_>) -> Rest {
603 let mut rest = Rest::page(page.offset(), page.per()).of(page.total());
604
605 if page.has_previous() {
606 rest = rest.back(panel_address(page.previous()));
607 }
608 if page.has_next() {
609 rest = rest.forward(panel_address(page.next()));
610 }
611 // Which page the reader is on is carried per jump rather than compared
612 // against the paging inside each renderer. `Jump::here`'s reason is
613 // `Choice::chosen`'s: a strip is a loop, and a residual holds one compiled
614 // body per loop.
615 for offered in page.offered() {
616 let jumping =
617 quasi_router::screen::Jump::new(offered.page, panel_address(offered.page as u32));
618 rest = rest.jumping(if offered.here {
619 jumping.here()
620 } else {
621 jumping
622 });
623 }
624
625 rest
626 }
627
628 /// Two items, one priced and one free, as the tests draw them.
629 ///
630 /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
631 /// them too, and the price cell's two shapes are exactly what its filling test
632 /// is crossing. Test-only.
633 #[cfg(test)]
634 pub(crate) fn sample_items() -> Vec<DiscoverItem> {
635 vec![tests::item("itm_1", false), tests::item("itm_2", true)]
636 }
637
638 #[cfg(test)]
639 mod tests {
640 use super::*;
641
642 pub(super) fn item(id: &str, free: bool) -> DiscoverItem {
643 DiscoverItem {
644 id: id.to_string(),
645 name: format!("Item {id}"),
646 creator: "acreator".to_string(),
647 project: "aproject".to_string(),
648 item_type: "sample-pack".to_string(),
649 primary_tag: "drums".to_string(),
650 price: "$4.00".to_string(),
651 is_free: free,
652 sales: 0,
653 date: "2026-08-30".to_string(),
654 ai_tier: String::new(),
655 match_label: None,
656 starts_fuzzy_block: false,
657 }
658 }
659
660 fn page(total_pages: u32) -> (Vec<DiscoverItem>, Vec<u32>) {
661 (
662 vec![item("itm_1", false), item("itm_2", true)],
663 (1..=total_pages).collect::<Vec<u32>>(),
664 )
665 }
666
667 fn library(current: u32, total_pages: u32) -> String {
668 let (items, range) = page(total_pages);
669 library_fragment(&Page {
670 items: &items,
671 total_items: 40,
672 current_page: current,
673 total_pages,
674 pagination_range: &range,
675 showing_start: 1,
676 showing_end: 20,
677 })
678 }
679
680 /// The whole document the page answers with, minus the viewer-dependent
681 /// half of the shell (the header and the token meta, which need a session).
682 /// Everything the screen itself describes is here.
683 fn public(current: u32, total_pages: u32) -> String {
684 use quasi_axum::Serves as _;
685
686 let (items, range) = page(total_pages);
687 Webview::new().screen(&page_screen(&Page {
688 items: &items,
689 total_items: 40,
690 current_page: current,
691 total_pages,
692 pagination_range: &range,
693 showing_start: 1,
694 showing_end: 20,
695 }))
696 }
697
698 /// Both surfaces draw the same table out of the same description. The
699 /// columns and the rows are the half that must not differ.
700 #[test]
701 fn the_two_surfaces_draw_one_table() {
702 let panel = library(1, 2);
703 let page = public(1, 2);
704
705 for html in [&panel, &page] {
706 assert!(html.contains("Item itm_1"), "{html}");
707 assert!(html.contains("acreator"), "{html}");
708 assert!(html.contains("drums"), "{html}");
709 assert!(html.contains("$4.00"), "{html}");
710 // The free badge, which both templates drew as a toned badge.
711 assert!(html.contains(">Free<"), "{html}");
712 assert!(html.contains(r#"data-tone="success""#), "{html}");
713 }
714 }
715
716 /// A row is the link, and it goes to the item rather than into the row.
717 #[test]
718 fn a_row_navigates_to_its_item() {
719 let html = library(1, 2);
720
721 assert!(html.contains(r#"href="/i/itm_1""#), "{html}");
722 // `00ee7af5`: a bare `Action::get` would emit an `hx-get` beside the
723 // href and htmx would swap the item page into the row.
724 assert!(!html.contains(r#"hx-get="/i/itm_1""#), "{html}");
725 }
726
727 /// The panel's page controls swap the panel; the page's replace the
728 /// document. This is the one thing that differs between the surfaces and it
729 /// is the thing worth asserting twice.
730 #[test]
731 fn each_surface_pages_the_way_it_is_read() {
732 let panel = library(2, 4);
733 assert!(
734 panel.contains(r#"hx-get="/library/tabs/feed?page=3""#),
735 "{panel}"
736 );
737 assert!(panel.contains(r##"hx-target="#library-feed""##), "{panel}");
738
739 let page = public(2, 4);
740 assert!(page.contains(r#"href="/feed?page=3""#), "{page}");
741 assert!(!page.contains("hx-get=\"/feed"), "{page}");
742 assert!(!page.contains("hx-target="), "{page}");
743 }
744
745 /// The strip is the window the handler chose, each page with its own
746 /// address, and the page the reader is on is text rather than a control.
747 #[test]
748 fn the_strip_offers_the_pages_the_handler_windowed() {
749 let html = library(2, 4);
750
751 for page in [1u32, 3, 4] {
752 assert!(
753 html.contains(&format!("/library/tabs/feed?page={page}")),
754 "{html}"
755 );
756 }
757 // Page 2 is where the reader is: marked, and not a control that reloads
758 // the page it is on.
759 assert!(html.contains(r#"aria-current="page""#), "{html}");
760 assert!(
761 !html.contains(r#"hx-get="/library/tabs/feed?page=2""#),
762 "{html}"
763 );
764 }
765
766 /// A first page offers no way back and a last page no way forward, and the
767 /// renderer draws neither rather than drawing one it has to disable.
768 ///
769 /// Ruled by Max 2026-09-08, against the disabled control that stood here: a
770 /// Prev that cannot go back is a control that answers nothing. See
771 /// `quasi-webview`'s `rest_html`.
772 #[test]
773 fn the_ends_of_the_set_say_so() {
774 let first = library(1, 4);
775 assert!(!first.contains("rest-previous"), "{first}");
776 assert!(first.contains("rest-next"), "{first}");
777
778 let last = library(4, 4);
779 assert!(last.contains("rest-previous"), "{last}");
780 assert!(!last.contains("rest-next"), "{last}");
781
782 assert!(!first.contains("disabled>"), "{first}");
783 assert!(!last.contains("disabled>"), "{last}");
784 }
785
786 /// One page is no rest at all, which is what `{% if total_pages > 1 %}`
787 /// said.
788 #[test]
789 fn a_single_page_feed_has_no_pager() {
790 let html = library(1, 1);
791
792 assert!(!html.contains("rest"), "{html}");
793 assert!(!html.contains("page="), "{html}");
794 }
795
796 /// The count line, which the strip does not state. See the module header:
797 /// it is the one number a reader would otherwise lose.
798 #[test]
799 fn the_reader_is_told_how_much_there_is() {
800 assert!(library(1, 2).contains("Showing 1-20 of 40 items"));
801 assert!(public(1, 2).contains("Showing 1-20 of 40 items"));
802 }
803
804 /// Nothing followed yet, said once for both surfaces, with the same way
805 /// out.
806 #[test]
807 fn an_empty_feed_offers_discover() {
808 let empty = public_of(&Page {
809 items: &[],
810 total_items: 0,
811 current_page: 1,
812 total_pages: 0,
813 pagination_range: &[],
814 showing_start: 0,
815 showing_end: 0,
816 });
817
818 assert!(empty.contains("Nothing here yet"), "{empty}");
819 assert!(empty.contains(r#"href="/discover""#), "{empty}");
820 // No table and no pager over nothing.
821 assert!(!empty.contains("<table"), "{empty}");
822 }
823
824 /// One page, rendered as the document the reader gets.
825 fn public_of(page: &Page<'_>) -> String {
826 use quasi_axum::Serves as _;
827
828 Webview::new().screen(&page_screen(page))
829 }
830
831 /// The parity `2790e5c4` asks for, on the screen it was written against:
832 /// the measure class and the page's own identity token, in that order, on
833 /// `<body>`.
834 #[test]
835 fn the_document_carries_the_class_the_template_carried() {
836 assert_eq!(
837 page_screen(&Page {
838 items: &[],
839 total_items: 0,
840 current_page: 1,
841 total_pages: 0,
842 pagination_range: &[],
843 showing_start: 0,
844 showing_end: 0,
845 })
846 .document
847 .body_class
848 .as_deref(),
849 Some("padded-page feed-page")
850 );
851 assert!(public(1, 2).contains("class=\"padded-page feed-page\""));
852 }
853
854 /// `736f45a5`. The template carried no indicator and neither does the
855 /// screen: the listing waits on nothing a reader presses.
856 #[test]
857 fn the_page_spells_no_spinner() {
858 let rendered = public(1, 2);
859
860 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
861 assert!(
862 !rendered.contains(spelling),
863 "{spelling} survives in {rendered}"
864 );
865 }
866 }
867
868 /// The title `pages/feed.html` drew as `<h1 class="page-title">`, drawn by
869 /// the description instead. The class moved with it, which is what
870 /// `style.css` had to be retargeted for.
871 #[test]
872 fn the_page_names_itself() {
873 let html = public(1, 2);
874
875 assert!(
876 html.contains("<h1 class=\"heading\">Your Feed</h1>"),
877 "{html}"
878 );
879 assert!(html.contains("<title>Feed - Makenotwork</title>"), "{html}");
880 assert!(!html.contains("page-title"), "{html}");
881 }
882
883 /// No `?page=` grammar survives in a template: each surface builds its own
884 /// addresses in one place, and neither is a template's.
885 #[test]
886 fn the_page_grammar_lives_in_one_place_per_surface() {
887 assert!(
888 panel_address(3)
889 .destination
890 .route()
891 .is_some_and(|route| route == "/library/tabs/feed?page=3")
892 );
893
894 // The page surface's is in `page_table`'s own `more` body, so it is read
895 // out of the markup rather than out of a function.
896 let html = public(3, 8);
897 assert!(html.contains("href=\"/feed?page=2\""), "{html}");
898 assert!(html.contains("href=\"/feed?page=4\""), "{html}");
899 }
900
901 /// The two surfaces draw the same columns, which is what holds the split
902 /// tables together.
903 ///
904 /// `panel_table` and `page_table` repeat the column list, because their
905 /// pagers are different markup and a residual holds one. Nothing in the
906 /// compiler pairs the two lists, so this does: a column added to one and
907 /// forgotten in the other changes one heading row and not the other.
908 #[test]
909 fn the_two_surfaces_draw_the_same_columns() {
910 use quasi_axum::Serves as _;
911
912 fn headings(html: &str) -> Vec<&str> {
913 html.match_indices("columnheader")
914 .map(|(at, _)| {
915 let rest = &html[at..];
916 let from = rest.find('>').map_or(0, |at| at + 1);
917 let to = rest[from..].find('<').map_or(0, |at| at + from);
918 &rest[from..to]
919 })
920 .collect()
921 }
922
923 let (items, range) = page(4);
924 let held = Page {
925 items: &items,
926 total_items: 40,
927 current_page: 1,
928 total_pages: 4,
929 pagination_range: &range,
930 showing_start: 1,
931 showing_end: 20,
932 };
933 let panel = Webview::new().fragment(&panel_table(&held));
934 let public = Webview::new().fragment(&page_table(&held));
935
936 assert_eq!(headings(&panel), headings(&public));
937 assert_eq!(headings(&panel).len(), 5, "{panel}");
938 }
939 }
940