Skip to main content

max / makenotwork

24.6 KB · 668 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. [`Cells::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 //! [`Cells::activate`]: quasi_router::screen::Cells::activate
82 //! [`Cell::part`]: quasi_router::screen::Cell::part
83
84 use makeover_layout as layout;
85 use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Tag};
86 use quasi_router::{
87 Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
88 };
89 use quasi_webview::Webview;
90
91 use crate::constants;
92 use crate::types::DiscoverItem;
93
94 /// The region the library panel's answer lands in.
95 ///
96 /// The id `super::library_tabs` draws its Feed frame from, so a page control
97 /// swapping this swaps the panel and nothing around it.
98 pub const LIBRARY_REGION: &str = "library-feed";
99
100 /// The region the public page's body sits in.
101 ///
102 /// Public so the pressed-screen table and the skip link name it rather than
103 /// transcribe it.
104 pub const PAGE_REGION: &str = "feed";
105
106 /// This screen's name, the marker a tab strip reads.
107 pub const SCREEN: &str = "feed";
108
109 /// The address this screen answers, and the one the Askama route gave up.
110 pub const PATH: &str = "/feed";
111
112 /// How wide the page runs. `pages/feed.html` said this as
113 /// `class="{{ shell::measure(Wide) }}"`; it is a described property now.
114 const MEASURE: layout::Measure = layout::Measure::Wide;
115
116 /// The address the library panel is read from.
117 const LIBRARY_ROUTE: &str = "/library/tabs/feed";
118
119 /// One page of a feed, as both handlers have already computed it.
120 ///
121 /// Every field here is what the two templates were handed, under the names they
122 /// were handed them under. What owns the values is [`Loaded`], and the
123 /// arithmetic that produces them is [`load`]: both handlers used to carry a
124 /// verbatim copy of it, which is one clamp, one `i64` widening and two
125 /// saturating labels duplicated three lines apart.
126 pub struct Page<'a> {
127 /// The rows, in the order they read.
128 pub items: &'a [DiscoverItem],
129 /// How many there are altogether.
130 pub total_items: u32,
131 /// Which page this is, counting from one.
132 pub current_page: u32,
133 /// How many pages there are.
134 pub total_pages: u32,
135 /// The pages the strip offers, already windowed by the handler.
136 pub pagination_range: &'a [u32],
137 /// The first row's position in the whole set, counting from one.
138 pub showing_start: u32,
139 /// The last row's position in the whole set.
140 pub showing_end: u32,
141 }
142
143 /// The library's Feed panel, in its region, for the tab route to answer with.
144 #[must_use]
145 pub fn library_fragment(page: &Page<'_>) -> String {
146 use quasi_axum::Serves as _;
147
148 let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane);
149 for node in body(page, Surface::Panel) {
150 slot = slot.with(node);
151 }
152 Webview::new().fragment(&Node::Region(slot))
153 }
154
155 /// One page of a reader's feed, loaded. Owns what [`Page`] borrows.
156 pub struct Loaded {
157 items: Vec<DiscoverItem>,
158 total_items: u32,
159 current_page: u32,
160 total_pages: u32,
161 pagination_range: Vec<u32>,
162 showing_start: u32,
163 showing_end: u32,
164 }
165
166 impl Loaded {
167 /// What was loaded, as the description reads it.
168 #[must_use]
169 pub fn page(&self) -> Page<'_> {
170 Page {
171 items: &self.items,
172 total_items: self.total_items,
173 current_page: self.current_page,
174 total_pages: self.total_pages,
175 pagination_range: &self.pagination_range,
176 showing_start: self.showing_start,
177 showing_end: self.showing_end,
178 }
179 }
180 }
181
182 /// Read one page of a reader's feed.
183 ///
184 /// The clamp, the `i64` widening before the multiply and the saturating
185 /// "showing" labels are three overflow fixes, moved here verbatim rather than
186 /// re-derived. They lived in `routes::pages::public::feed` and in
187 /// `landing::library_tab_feed` as byte-identical copies; there is one now, so
188 /// the library panel and the page cannot page differently.
189 ///
190 /// Async, so the panel awaits it and the described screen reaches it through
191 /// [`super::Viewer::block_on`].
192 pub async fn load(
193 db: &sqlx::PgPool,
194 user: crate::db::UserId,
195 page: Option<u32>,
196 ) -> crate::error::Result<Loaded> {
197 // Clamp the upper bound too (matches admin/git pagination); the i64
198 // widening below already prevents the overflow panic, but an unbounded page
199 // is a pointless huge offset (Run #2 UX MINOR).
200 let page = page.unwrap_or(1).clamp(1, 1_000_000_000);
201 // Widen to i64 BEFORE multiplying, `(page - 1) * FEED_PAGE_SIZE` in u32
202 // overflows for a large `?page=` (garbage offset in release, panic in debug).
203 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
204
205 let total_items = crate::db::follows::count_followed_feed_items(db, user).await? as u32;
206 let total_pages =
207 (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
208
209 let db_items = crate::db::follows::get_followed_feed_items(
210 db,
211 user,
212 constants::FEED_PAGE_SIZE as i64,
213 offset,
214 )
215 .await?;
216 let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
217
218 // Compute the "showing X-Y" labels in i64 (saturating) to avoid the u32
219 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`.
220 let showing_start = if total_items == 0 {
221 0
222 } else {
223 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
224 };
225 let showing_end = offset
226 .saturating_add(constants::FEED_PAGE_SIZE as i64)
227 .min(total_items as i64)
228 .clamp(0, u32::MAX as i64) as u32;
229
230 Ok(Loaded {
231 items,
232 total_items,
233 current_page: page,
234 total_pages,
235 pagination_range: crate::routes::pages::public::pagination::build_pagination_range(
236 page,
237 total_pages,
238 ),
239 showing_start,
240 showing_end,
241 })
242 }
243
244 /// The public feed page, described.
245 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
246 // Moved out of the request rather than borrowed: the signature is quasi's,
247 // so the request arrives owned and nothing else here reads it.
248 let carried = request.carried;
249 let asked = carried
250 .get("page")
251 .and_then(|value| value.trim().parse::<u32>().ok());
252 let loaded = viewer
253 .block_on(load(&viewer.app.db, viewer.reader()?.id, asked))
254 .map_err(|_| RouteError::internal("your feed could not be read"))?;
255 Ok(page_screen(&loaded.page()).into())
256 }
257
258 /// The whole document: the title, the measure, the body.
259 fn page_screen(page: &Page<'_>) -> Described {
260 let mut pane = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Your Feed"));
261 for node in body(page, Surface::Page) {
262 pane = pane.with(node);
263 }
264 Described::single("Feed - Makenotwork")
265 .measured(MEASURE)
266 // `padded-page feed-page`, which is what `pages/feed.html:4` rendered.
267 // Composed rather than written out: `Document::classed` replaces, so a
268 // screen naming only its own token would drop its measure (`2790e5c4`).
269 .documented(Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"])))
270 .summarised("Items from the users, projects and tags you follow.")
271 .with(pane)
272 }
273
274 /// The document this screen is drawn in.
275 ///
276 /// The head, the tail and the token meta come off
277 /// [`super::Viewer::document_shell`]. What is added here is what every page on
278 /// this site opens with: the skip link, and the site header
279 /// ([`crate::shell::site_header`]) which is markup in the assembly layer rather
280 /// than anything a screen describes.
281 #[must_use]
282 pub fn renderer(viewer: &super::Viewer) -> Webview {
283 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
284 "{}{}",
285 crate::shell::skip_link(PAGE_REGION),
286 crate::shell::site_header(viewer.user.as_ref()),
287 )))
288 }
289
290 /// Which of the two surfaces is being drawn.
291 ///
292 /// The only thing that differs between them, so it is one value rather than two
293 /// copies of the body. See the module header.
294 #[derive(Clone, Copy)]
295 enum Surface {
296 /// The library tab panel: a page control swaps the panel.
297 Panel,
298 /// The public page: a page control replaces the document.
299 Page,
300 }
301
302 impl Surface {
303 /// What going to `page` calls, on this surface.
304 fn address(self, page: u32) -> Action {
305 match self {
306 Self::Panel => Action::get(format!("{LIBRARY_ROUTE}?page={page}"))
307 .awaiting()
308 .replacing(LIBRARY_REGION),
309 Self::Page => Action::get(format!("{PATH}?page={page}")).navigating(),
310 }
311 }
312 }
313
314 /// The surface's contents, in order.
315 fn body(page: &Page<'_>, surface: Surface) -> Vec<Node> {
316 if page.items.is_empty() {
317 return vec![empty()];
318 }
319
320 vec![
321 Node::text(format!(
322 "Showing {}-{} of {} items",
323 page.showing_start, page.showing_end, page.total_items
324 )),
325 table(page, surface),
326 ]
327 }
328
329 /// Nothing followed yet.
330 ///
331 /// The two templates spelled this differently -- the panel wrote its own three
332 /// paragraphs and a button, the page called `ui::empty_state_with_action` -- and
333 /// said the same thing with the same way out. One spelling now, which is one of
334 /// the things converting both surfaces together buys.
335 fn empty() -> Node {
336 Node::empty("Nothing here yet. Follow users, projects, or tags to see their items here.")
337 .offering(Act::new(
338 "Browse Discover",
339 Action::get("/discover").navigating(),
340 ))
341 }
342
343 /// The five columns, and the rows under them.
344 fn table(page: &Page<'_>, surface: Surface) -> Node {
345 Node::Table {
346 columns: vec![
347 Column::new("Type").width(layout::Width::Content),
348 Column::new("Name")
349 .width(layout::Width::Fill)
350 .priority(layout::Priority::Essential),
351 Column::new("Tag").width(layout::Width::Content),
352 Column::new("Price").width(layout::Width::Content),
353 Column::new("Date").width(layout::Width::Content),
354 ],
355 rows: page.items.iter().map(row).collect(),
356 more: rest(page, surface),
357 }
358 }
359
360 /// One item.
361 fn row(item: &DiscoverItem) -> Cells {
362 let price = if item.is_free {
363 let mut free = Tag::badge("Free");
364 free.tone = layout::Tone::Success;
365 Cell::new(String::new()).token(free)
366 } else {
367 Cell::new(item.price.clone())
368 };
369
370 Cells::new([
371 Cell::new(String::new()).token(Tag::badge(item.item_type.clone())),
372 // The name is the link text and the creator rides under it in the same
373 // cell, which is what `Cell::activate` picks: the first text part.
374 Cell::new(item.name.clone()).part(Node::text(item.creator.clone())),
375 Cell::new(item.primary_tag.clone()),
376 price,
377 Cell::new(item.date.clone()),
378 ])
379 .activate(Action::get(format!("/i/{}", item.id)).navigating())
380 }
381
382 /// What the reader has not been shown, and every way to ask for it.
383 ///
384 /// `None` on a single-page feed, which is what `{% if total_pages > 1 %}` said:
385 /// a set that arrived whole has no rest, and a pager drawn over one would be
386 /// two disabled buttons and the number 1.
387 fn rest(page: &Page<'_>, surface: Surface) -> Option<Rest> {
388 if page.total_pages <= 1 {
389 return None;
390 }
391
392 let per = constants::FEED_PAGE_SIZE as usize;
393 let from = (page.current_page as usize).saturating_sub(1) * per;
394 let mut rest = Rest::page(from, per).of(page.total_items as usize);
395
396 if page.current_page > 1 {
397 rest = rest.back(surface.address(page.current_page - 1));
398 }
399 if page.current_page < page.total_pages {
400 rest = rest.forward(surface.address(page.current_page + 1));
401 }
402 for jump in page.pagination_range {
403 rest = rest.jumping(*jump as usize, surface.address(*jump));
404 }
405
406 Some(rest)
407 }
408
409 #[cfg(test)]
410 mod tests {
411 use super::*;
412
413 fn item(id: &str, free: bool) -> DiscoverItem {
414 DiscoverItem {
415 id: id.to_string(),
416 name: format!("Item {id}"),
417 creator: "acreator".to_string(),
418 project: "aproject".to_string(),
419 item_type: "sample-pack".to_string(),
420 primary_tag: "drums".to_string(),
421 price: "$4.00".to_string(),
422 is_free: free,
423 sales: 0,
424 date: "2026-08-30".to_string(),
425 ai_tier: String::new(),
426 match_label: None,
427 starts_fuzzy_block: false,
428 }
429 }
430
431 fn page(total_pages: u32) -> (Vec<DiscoverItem>, Vec<u32>) {
432 (
433 vec![item("itm_1", false), item("itm_2", true)],
434 (1..=total_pages).collect::<Vec<u32>>(),
435 )
436 }
437
438 fn library(current: u32, total_pages: u32) -> String {
439 let (items, range) = page(total_pages);
440 library_fragment(&Page {
441 items: &items,
442 total_items: 40,
443 current_page: current,
444 total_pages,
445 pagination_range: &range,
446 showing_start: 1,
447 showing_end: 20,
448 })
449 }
450
451 /// The whole document the page answers with, minus the viewer-dependent
452 /// half of the shell (the header and the token meta, which need a session).
453 /// Everything the screen itself describes is here.
454 fn public(current: u32, total_pages: u32) -> String {
455 use quasi_axum::Serves as _;
456
457 let (items, range) = page(total_pages);
458 Webview::new().screen(&page_screen(&Page {
459 items: &items,
460 total_items: 40,
461 current_page: current,
462 total_pages,
463 pagination_range: &range,
464 showing_start: 1,
465 showing_end: 20,
466 }))
467 }
468
469 /// Both surfaces draw the same table out of the same description. The
470 /// columns and the rows are the half that must not differ.
471 #[test]
472 fn the_two_surfaces_draw_one_table() {
473 let panel = library(1, 2);
474 let page = public(1, 2);
475
476 for html in [&panel, &page] {
477 assert!(html.contains("Item itm_1"), "{html}");
478 assert!(html.contains("acreator"), "{html}");
479 assert!(html.contains("drums"), "{html}");
480 assert!(html.contains("$4.00"), "{html}");
481 // The free badge, which both templates drew as a toned badge.
482 assert!(html.contains(">Free<"), "{html}");
483 assert!(html.contains(r#"data-tone="success""#), "{html}");
484 }
485 }
486
487 /// A row is the link, and it goes to the item rather than into the row.
488 #[test]
489 fn a_row_navigates_to_its_item() {
490 let html = library(1, 2);
491
492 assert!(html.contains(r#"href="/i/itm_1""#), "{html}");
493 // `00ee7af5`: a bare `Action::get` would emit an `hx-get` beside the
494 // href and htmx would swap the item page into the row.
495 assert!(!html.contains(r#"hx-get="/i/itm_1""#), "{html}");
496 }
497
498 /// The panel's page controls swap the panel; the page's replace the
499 /// document. This is the one thing that differs between the surfaces and it
500 /// is the thing worth asserting twice.
501 #[test]
502 fn each_surface_pages_the_way_it_is_read() {
503 let panel = library(2, 4);
504 assert!(
505 panel.contains(r#"hx-get="/library/tabs/feed?page=3""#),
506 "{panel}"
507 );
508 assert!(panel.contains(r##"hx-target="#library-feed""##), "{panel}");
509
510 let page = public(2, 4);
511 assert!(page.contains(r#"href="/feed?page=3""#), "{page}");
512 assert!(!page.contains("hx-get=\"/feed"), "{page}");
513 assert!(!page.contains("hx-target="), "{page}");
514 }
515
516 /// The strip is the window the handler chose, each page with its own
517 /// address, and the page the reader is on is text rather than a control.
518 #[test]
519 fn the_strip_offers_the_pages_the_handler_windowed() {
520 let html = library(2, 4);
521
522 for page in [1u32, 3, 4] {
523 assert!(
524 html.contains(&format!("/library/tabs/feed?page={page}")),
525 "{html}"
526 );
527 }
528 // Page 2 is where the reader is: marked, and not a control that reloads
529 // the page it is on.
530 assert!(html.contains(r#"aria-current="page""#), "{html}");
531 assert!(
532 !html.contains(r#"hx-get="/library/tabs/feed?page=2""#),
533 "{html}"
534 );
535 }
536
537 /// A first page offers no way back and a last page no way forward, and the
538 /// renderer draws each as disabled rather than absent.
539 #[test]
540 fn the_ends_of_the_set_say_so() {
541 let first = library(1, 4);
542 assert!(first.contains("<button type=\"button\""), "{first}");
543 assert!(first.contains("disabled>Prev"), "{first}");
544 assert!(!first.contains("disabled>Next"), "{first}");
545
546 let last = library(4, 4);
547 assert!(last.contains("disabled>Next"), "{last}");
548 assert!(!last.contains("disabled>Prev"), "{last}");
549 }
550
551 /// One page is no rest at all, which is what `{% if total_pages > 1 %}`
552 /// said.
553 #[test]
554 fn a_single_page_feed_has_no_pager() {
555 let html = library(1, 1);
556
557 assert!(!html.contains("rest"), "{html}");
558 assert!(!html.contains("page="), "{html}");
559 }
560
561 /// The count line, which the strip does not state. See the module header:
562 /// it is the one number a reader would otherwise lose.
563 #[test]
564 fn the_reader_is_told_how_much_there_is() {
565 assert!(library(1, 2).contains("Showing 1-20 of 40 items"));
566 assert!(public(1, 2).contains("Showing 1-20 of 40 items"));
567 }
568
569 /// Nothing followed yet, said once for both surfaces, with the same way
570 /// out.
571 #[test]
572 fn an_empty_feed_offers_discover() {
573 let empty = public_of(&Page {
574 items: &[],
575 total_items: 0,
576 current_page: 1,
577 total_pages: 0,
578 pagination_range: &[],
579 showing_start: 0,
580 showing_end: 0,
581 });
582
583 assert!(empty.contains("Nothing here yet"), "{empty}");
584 assert!(empty.contains(r#"href="/discover""#), "{empty}");
585 // No table and no pager over nothing.
586 assert!(!empty.contains("<table"), "{empty}");
587 }
588
589 /// One page, rendered as the document the reader gets.
590 fn public_of(page: &Page<'_>) -> String {
591 use quasi_axum::Serves as _;
592
593 Webview::new().screen(&page_screen(page))
594 }
595
596 /// The parity `2790e5c4` asks for, on the screen it was written against:
597 /// the measure class and the page's own identity token, in that order, on
598 /// `<body>`.
599 #[test]
600 fn the_document_carries_the_class_the_template_carried() {
601 assert_eq!(
602 page_screen(&Page {
603 items: &[],
604 total_items: 0,
605 current_page: 1,
606 total_pages: 0,
607 pagination_range: &[],
608 showing_start: 0,
609 showing_end: 0,
610 })
611 .document
612 .body_class
613 .as_deref(),
614 Some("padded-page feed-page")
615 );
616 assert!(public(1, 2).contains("class=\"padded-page feed-page\""));
617 }
618
619 /// `736f45a5`. The template carried no indicator and neither does the
620 /// screen: the listing waits on nothing a reader presses.
621 #[test]
622 fn the_page_spells_no_spinner() {
623 let rendered = public(1, 2);
624
625 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
626 assert!(
627 !rendered.contains(spelling),
628 "{spelling} survives in {rendered}"
629 );
630 }
631 }
632
633 /// The title `pages/feed.html` drew as `<h1 class="page-title">`, drawn by
634 /// the description instead. The class moved with it, which is what
635 /// `style.css` had to be retargeted for.
636 #[test]
637 fn the_page_names_itself() {
638 let html = public(1, 2);
639
640 assert!(
641 html.contains("<h1 class=\"heading\">Your Feed</h1>"),
642 "{html}"
643 );
644 assert!(html.contains("<title>Feed - Makenotwork</title>"), "{html}");
645 assert!(!html.contains("page-title"), "{html}");
646 }
647
648 /// No `?page=` grammar survives in a template: both addresses are built
649 /// here, from one function, on the surface that reads them.
650 #[test]
651 fn the_page_grammar_lives_in_one_place() {
652 assert!(
653 Surface::Panel
654 .address(3)
655 .destination
656 .route()
657 .is_some_and(|route| route == "/library/tabs/feed?page=3")
658 );
659 assert!(
660 Surface::Page
661 .address(3)
662 .destination
663 .route()
664 .is_some_and(|route| route == "/feed?page=3")
665 );
666 }
667 }
668