//! The two feed surfaces, described. //! //! `830b1661`, and the first described paging anywhere on this server: //! `grep -rn 'Rest' src/quasi/` returned one hit before this module and it was //! the string "Restore". //! //! # Why the two go together //! //! `partials/tabs/library_feed.html` (the panel behind `/library/tabs/feed`) //! and `pages/feed.html` (the page at `/feed`) drew the same thing: the same //! `Vec` in the same five columns, from the same query, with the //! same numbered strip under it. Two handlers, two templates, one table. //! Converting one and leaving the other would leave that table described in one //! place and spelled in markup in the other, which is the divergence a //! conversion exists to end. //! //! So the table is described once, here, and both surfaces call it. What //! differs between them is one thing and it is stated as one thing: where a //! page control goes. The panel's paging swaps the panel //! ([`Action::replacing`]); the page's paging replaces the document //! ([`Action::navigating`]). //! //! # The window stays server-side //! //! [`Rest::jumps`] is explicit that windowing is the description's call rather //! than the renderer's, and `super::super::routes::pages::public::pagination::build_pagination_range` //! already windows to five around the reader. That function is untouched: what //! arrives here is the pages being offered, in reading order, and each is given //! its own address. A renderer that built page 5's address out of `forward` and //! `back` would be reconstructing the `?page=` grammar this retires. //! //! # What the row is //! //! A whole row is the link, which is what the markup said with `` wrapping five spans. [`Row::activate`] is that, //! and it carries [`Action::navigating`] because an item page is a page: a bare //! `Action::get` emits an `href` *and* an `hx-get` with no target, and htmx //! puts the whole document inside the row that was pressed. That is //! quasicoherent `00ee7af5`, met here for the second time on this server. //! //! # The two-line name cell //! //! Name over creator, one cell, which the template drew as a nested `
` //! with two spans. [`Cell::part`] is the general form for exactly this and //! needs no member: the cell is an inline run holding two leaves, the first of //! which [`Cell::activate`] turns into the row's link text. //! //! # What is restated, and it is one line //! //! "Showing 1-20 of 400 items" is prose above the table, and every number in it //! is also in the [`Rest`] under the table. That is a duplication and it is //! deliberate: the webview renderer draws the numbered strip *instead of* its //! position readout (`rest_strip_html`, "a control arguing with itself"), so a //! reader given a strip is told which page they are on and never how many items //! there are. Dropping the line would be a visible loss to buy tidiness in a //! layer the reader cannot see. A renderer that printed the total beside a //! strip would let this go, and that is a `quasi-webview` question rather than //! this server's. //! //! # The page owns its document //! //! `b5cbb646`. `pages/feed.html` is gone and so is `landing`'s Askama route: //! [`screen`] describes the whole page and [`renderer`] draws the document it //! sits in. The site header is not described and is not going to be: it is one //! element in the assembly layer ([`crate::shell::site_header`]), handed to the //! shell as `body_first` here and included by 64 templates there. The ruling //! and its evidence are in wiki `mnw-server-conversion-plan`, dated 2026-08-31. //! //! One behaviour changed with the route. `ValidatedQuery` refused //! `?page=abc` with a 400; [`screen`] parses what it can and falls back to page //! one, which is what a reader who mangled a URL wants and what every other //! described screen already does with its carried values. //! //! The six other `page=` sites are Newer/Older or Previous/Next only and want //! no `jumps`: `pages/git/{commits,explore,issues,file_log,notes}.html` and //! `partials/admin_user_entries.html`. Named here so a later pass does not //! "fix" them into strips they never had. //! //! [`Action::replacing`]: quasi_router::Action::replacing //! [`Action::navigating`]: quasi_router::Action::navigating //! [`Row::activate`]: quasi_router::screen::Row::activate //! [`Cell::part`]: quasi_router::screen::Cell::part use makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::{Jump, Rest, Tag}; use quasi_router::{Action, Document, Node, RegionKind, RouteError, Slot}; use quasi_webview::Webview; use crate::constants; use crate::types::DiscoverItem; /// The region the library panel's answer lands in. /// /// The id `super::library_tabs` draws its Feed frame from, so a page control /// swapping this swaps the panel and nothing around it. pub const LIBRARY_REGION: &str = "library-feed"; /// The region the public page's body sits in. /// /// Public so the pressed-screen table and the skip link name it rather than /// transcribe it. pub const PAGE_REGION: &str = "feed"; /// This screen's name, the marker a tab strip reads. pub const SCREEN: &str = "feed"; /// The address this screen answers, and the one the Askama route gave up. pub const PATH: &str = "/feed"; /// How wide the page runs. `pages/feed.html` said this as /// `class="{{ shell::measure(Wide) }}"`; it is a described property now. const MEASURE: layout::Measure = layout::Measure::Wide; /// The address the library panel is read from. const LIBRARY_ROUTE: &str = "/library/tabs/feed"; /// One page of a feed, as both handlers have already computed it. /// /// Every field here is what the two templates were handed, under the names they /// were handed them under. What owns the values is [`Loaded`], and the /// arithmetic that produces them is [`load`]: both handlers used to carry a /// verbatim copy of it, which is one clamp, one `i64` widening and two /// saturating labels duplicated three lines apart. pub struct Page<'a> { /// The rows, in the order they read. pub items: &'a [DiscoverItem], /// How many there are altogether. pub total_items: u32, /// Which page this is, counting from one. pub current_page: u32, /// How many pages there are. pub total_pages: u32, /// The pages the strip offers, already windowed by the handler. pub pagination_range: &'a [u32], /// The first row's position in the whole set, counting from one. pub showing_start: u32, /// The last row's position in the whole set. pub showing_end: u32, } /// One page the strip offers, and whether it is the one being read. /// /// `here` is carried per row rather than worked out by comparing each page /// against the current one. `quasi_router::Jump::here` has the reason: a strip /// is a loop, and a residual holds one compiled body per loop, so "exactly one /// row differs" is not a property of the body when the difference is a /// comparison the body does not make. pub struct Offered { /// Which page, counting from one. pub page: usize, /// Whether it is the one being read. pub here: bool, } impl Page<'_> { /// Where this page starts, which is what a `Rest` counts from. /// /// Suppliers rather than expressions because the declared form has no /// arithmetic, and these are the same sums [`load`] makes. fn offset(&self) -> usize { (self.current_page as usize - 1) * constants::FEED_PAGE_SIZE as usize } fn per(&self) -> usize { constants::FEED_PAGE_SIZE as usize } fn total(&self) -> usize { self.total_items as usize } fn previous(&self) -> u32 { self.current_page.saturating_sub(1) } fn next(&self) -> u32 { self.current_page + 1 } fn has_previous(&self) -> bool { self.current_page > 1 } fn has_next(&self) -> bool { self.current_page < self.total_pages } /// The pages the strip offers, marked. The window is the handler's, which /// is `build_pagination_range`; nothing here windows anything. fn offered(&self) -> Vec { self.pagination_range .iter() .map(|at| Offered { page: *at as usize, here: *at == self.current_page, }) .collect() } } /// The library's Feed panel, in its region, for the tab route to answer with. #[must_use] pub fn library_fragment(page: &Page<'_>) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane); for node in panel_body(page) { slot = slot.with(node); } Webview::new().fragment(&Node::Region(slot)) } /// One page of a reader's feed, loaded. Owns what [`Page`] borrows. pub struct Loaded { items: Vec, total_items: u32, current_page: u32, total_pages: u32, pagination_range: Vec, showing_start: u32, showing_end: u32, } impl Loaded { /// What was loaded, as the description reads it. #[must_use] pub fn page(&self) -> Page<'_> { Page { items: &self.items, total_items: self.total_items, current_page: self.current_page, total_pages: self.total_pages, pagination_range: &self.pagination_range, showing_start: self.showing_start, showing_end: self.showing_end, } } } /// Read one page of a reader's feed. /// /// The clamp, the `i64` widening before the multiply and the saturating /// "showing" labels are three overflow fixes, moved here verbatim rather than /// re-derived. They lived in `routes::pages::public::feed` and in /// `landing::library_tab_feed` as byte-identical copies; there is one now, so /// the library panel and the page cannot page differently. /// /// Async, so the panel awaits it and the described screen reaches it through /// [`super::Viewer::block_on`]. pub async fn load( db: &sqlx::PgPool, user: crate::db::UserId, page: Option, ) -> crate::error::Result { // Clamp the upper bound too (matches admin/git pagination); the i64 // widening below already prevents the overflow panic, but an unbounded page // is a pointless huge offset (Run #2 UX MINOR). let page = page.unwrap_or(1).clamp(1, 1_000_000_000); // Widen to i64 BEFORE multiplying, `(page - 1) * FEED_PAGE_SIZE` in u32 // overflows for a large `?page=` (garbage offset in release, panic in debug). let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64; let total_items = crate::db::follows::count_followed_feed_items(db, user).await? as u32; let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1); let db_items = crate::db::follows::get_followed_feed_items( db, user, constants::FEED_PAGE_SIZE as i64, offset, ) .await?; let items: Vec = db_items.into_iter().map(DiscoverItem::from).collect(); // Compute the "showing X-Y" labels in i64 (saturating) to avoid the u32 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`. let showing_start = if total_items == 0 { 0 } else { offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32 }; let showing_end = offset .saturating_add(constants::FEED_PAGE_SIZE as i64) .min(total_items as i64) .clamp(0, u32::MAX as i64) as u32; Ok(Loaded { items, total_items, current_page: page, total_pages, pagination_range: crate::routes::pages::public::pagination::build_pagination_range( page, total_pages, ), showing_start, showing_end, }) } /// The one read this page makes, for the mount that serves it from a residual. /// /// Answers `Loaded` rather than `Page`, because a `Page` borrows it: the mount /// holds this and takes the borrow twice, once to state the document and once /// to fill the markup, from the same read. pub(crate) fn reading( viewer: &super::Viewer, carried: &super::Carried, ) -> Result { let asked = carried .asked("page") .and_then(|value| value.trim().parse::().ok()); viewer .block_on(load(&viewer.app.db, viewer.reader()?.id, asked)) .map_err(|_| RouteError::internal("your feed could not be read")) } declare! { /// The whole document: the title, the measure, the body. /// /// The body arrives as a list of members and is spread into the pane one at /// a time, which is the same loop both callers wrote by hand: a fill has no /// region of its own, so nothing can place it whole. pub(crate) shape page_screen(page: &Page<'_>) -> Screen; screen single "Feed - Makenotwork" { measured MEASURE; // `padded-page feed-page`, which is what `pages/feed.html:4` rendered. // Composed rather than written out: `Document::classed` replaces, so a // screen naming only its own token would drop its measure (`2790e5c4`). documented Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"])); summarised "Items from the users, projects and tags you follow."; include page_region(page); } } declare! { /// The page's one region, split out so it can be staged. #[staged] pub(crate) shape page_region(page: &Page<'_>) -> Slot; region PAGE_REGION as Pane { page "Your Feed"; include each page_body(page); } } /// The document this screen is drawn in. /// /// The head, the tail and the token meta come off /// [`super::Viewer::document_shell`]. What is added here is what every page on /// this site opens with: the skip link, and the site header /// ([`crate::shell::site_header`]) which is markup in the assembly layer rather /// than anything a screen describes. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } /// Which of the two surfaces is being drawn. /// /// The only thing that differs between them, so it is one value rather than two /// copies of the body. See the module header. /// What going to `page` calls on the library panel: a swap of the panel, in /// place, leaving the rest of the library where it is. /// /// The page surface's answer is not here. It is written in `page_table`'s own /// `more` body, because that surface is served from a residual and a supplier /// handing over a whole `Action` is markup the derivation cannot see into. See /// the note on `panel_table`. fn panel_address(page: u32) -> Action { Action::get(format!("{LIBRARY_ROUTE}?page={page}")) .awaiting() .replacing(LIBRARY_REGION) } declare! { /// The library panel's contents, in order. /// /// One of a pair with [`page_body`], and the pair is what the surface split /// costs. Everything the two surfaces say the same way is said once -- /// the empty state, the count, every row and every cell. What they cannot /// share is the pager: a page control on the panel swaps the panel in place /// and one on the page navigates, which is different markup rather than a /// different address, so it cannot be a value either surface hands over. /// See `panel_table`. shape panel_body(page: &Page<'_>) -> Vec; include empty() when page.items.is_empty(); text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items" unless page.items.is_empty(); include panel_table(page) unless page.items.is_empty(); } declare! { /// The public page's contents, in order. See [`panel_body`]. #[staged] pub(crate) shape page_body(page: &Page<'_>) -> Vec; include empty() when page.items.is_empty(); text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items" unless page.items.is_empty(); include page_table(page) unless page.items.is_empty(); } declare! { /// Nothing followed yet. /// /// The two templates spelled this differently -- the panel wrote its own /// three paragraphs and a button, the page called /// `ui::empty_state_with_action` -- and said the same thing with the same /// way out. One spelling now, which is one of the things converting both /// surfaces together buys. #[constant] shape empty() -> Node; empty "Nothing here yet. Follow users, projects, or tags to see their items here." { offering "Browse Discover" to get "/discover" navigating; } } declare! { /// The five columns, and the rows under them, for the library panel. /// /// # Why there are two of these /// /// The column list is written twice, here and in [`page_table`], and that /// is the price of the surface split rather than an oversight. What forced /// it: a page control on the panel is /// `Action::get(..).awaiting().replacing(LIBRARY_REGION)` and one on the /// page is `Action::get(..).navigating()`, which is different MARKUP, not a /// different address. A residual holds one markup per position, and the /// page surface is served from one. /// /// Saying the difference as guards on the action's modifiers was refused on /// reading: three guards that have to agree is what wiki /// `quasi-declare-form` section 24 calls one question written three times, /// and a derivation varies them one at a time. /// /// **The two lists are held together by a test rather than by hope.** /// `the_two_surfaces_draw_the_same_columns` renders both and compares the /// heading rows, so a column added to one and forgotten in the other fails /// there. Everything below the headings -- every row, every cell -- is /// [`row`], said once. shape panel_table(page: &Page<'_>) -> Node; table { column "Type" { width Content; } column "Name" { width Fill; priority Essential; } column "Tag" { width Content; } column "Price" { width Content; } column "Date" { width Content; } for item in page.items.iter() { include row(item); } more panel_rest(page) when page.total_pages over 1; } } declare! { /// The same five columns and rows, for the public page. See [`panel_table`]. /// /// The pager is described rather than supplied, which is what puts this /// surface on the residual seam: a `Rest` handed over whole has no /// sentinel, and everything a described one carries is a number or an /// address. The directions are written back, then the strip, then forward, /// because that is the order they draw in and `quasi-declare` holds this to /// it. #[staged] pub(crate) shape page_table(page: &Page<'_>) -> Node; table { column "Type" { width Content; } column "Name" { width Fill; priority Essential; } column "Tag" { width Content; } column "Price" { width Content; } column "Date" { width Content; } for item in page.items.iter() { include row(item); } more Rest::page(page.offset(), page.per()).of(page.total()) { back Action::get("{PATH}?page={page.previous()}").navigating() when page.has_previous(); // The strip, one control per page the handler windowed to. Which // one the reader is on is a readout rather than a control, which is // two markups at one position and is why `Op::Arms` had to exist. for offered in page.offered().iter() { jumping Jump::new( offered.page, Action::get("{PATH}?page={offered.page}").navigating() ) { here when offered.here; } } forward Action::get("{PATH}?page={page.next()}").navigating() when page.has_next(); } when page.total_pages over 1; } } /// What the price cell reads, which is nothing when the item is free. /// /// A free item says so with a token instead, and the two are one cell rather /// than two guarded ones: a cell that is sometimes a word and sometimes a badge /// is still one cell. fn price(item: &DiscoverItem) -> String { if item.is_free { String::new() } else { item.price.clone() } } declare! { /// One item. /// /// The cells name their columns rather than counting to them, because the /// headings are in [`table`] and this is a different shape: position is only /// checkable when both halves are in front of you, and here they never are. /// The names must match [`table`]'s `column` strings exactly, since a name /// no column carries is dropped rather than reported. #[staged] shape row(item: &DiscoverItem) -> Row; cells { cell at "Type" "" { token Tag::badge(item.item_type.clone()); } // The name is the link text and the creator rides under it in the same // cell, which is what `Cell::activate` picks: the first text part. cell at "Name" item.name.clone() { text item.creator.clone(); } cell at "Tag" item.primary_tag.clone(); // One question with two shapes rather than two questions. A free item // says so with a badge and a priced one reads its price, and the cell // carries `cell-value` in the second case and not in the first -- so // the two are different markup at one position, which is a dispatch. // // Said as two guarded cells it would be one question written twice // (wiki `quasi-declare-form` section 24, rule one) and a derivation // varies guards one at a time, so it would render a Price column // holding two cells. quasicoherent `cbb63155`. given item.is_free { true -> cell at "Price" "" { token Tag::badge("Free").tone(layout::Tone::Success); } otherwise -> cell at "Price" price(item); } cell at "Date" item.date.clone(); activate to get "/i/{item.id}" navigating; } } /// What the reader has not been shown, and every way to ask for it. /// /// The panel's, and the panel's alone. A supplier is the right shape here /// because this surface is not on the residual seam: it builds nodes per /// request, so handing the renderer a whole `Rest` costs nothing. The page /// surface describes its pager instead, in `page_table`. /// /// The table asks for this only when there is more than one page, which is what /// `{% if total_pages > 1 %}` said: a set that arrived whole has no rest, and a /// pager drawn over one would be one control and the number 1. R9 means this is /// still called on a single-page feed, and the answer is thrown away. fn panel_rest(page: &Page<'_>) -> Rest { let mut rest = Rest::page(page.offset(), page.per()).of(page.total()); if page.has_previous() { rest = rest.back(panel_address(page.previous())); } if page.has_next() { rest = rest.forward(panel_address(page.next())); } // Which page the reader is on is carried per jump rather than compared // against the paging inside each renderer. `Jump::here`'s reason is // `Choice::chosen`'s: a strip is a loop, and a residual holds one compiled // body per loop. for offered in page.offered() { let jumping = quasi_router::screen::Jump::new(offered.page, panel_address(offered.page as u32)); rest = rest.jumping(if offered.here { jumping.here() } else { jumping }); } rest } /// Two items, one priced and one free, as the tests draw them. /// /// Module-level rather than inside `mod tests` because `quasi::residuals` needs /// them too, and the price cell's two shapes are exactly what its filling test /// is crossing. Test-only. #[cfg(test)] pub(crate) fn sample_items() -> Vec { vec![tests::item("itm_1", false), tests::item("itm_2", true)] } #[cfg(test)] mod tests { use super::*; pub(super) fn item(id: &str, free: bool) -> DiscoverItem { DiscoverItem { id: id.to_string(), name: format!("Item {id}"), creator: "acreator".to_string(), project: "aproject".to_string(), item_type: "sample-pack".to_string(), primary_tag: "drums".to_string(), price: "$4.00".to_string(), is_free: free, sales: 0, date: "2026-08-30".to_string(), ai_tier: String::new(), match_label: None, starts_fuzzy_block: false, } } fn page(total_pages: u32) -> (Vec, Vec) { ( vec![item("itm_1", false), item("itm_2", true)], (1..=total_pages).collect::>(), ) } fn library(current: u32, total_pages: u32) -> String { let (items, range) = page(total_pages); library_fragment(&Page { items: &items, total_items: 40, current_page: current, total_pages, pagination_range: &range, showing_start: 1, showing_end: 20, }) } /// The whole document the page answers with, minus the viewer-dependent /// half of the shell (the header and the token meta, which need a session). /// Everything the screen itself describes is here. fn public(current: u32, total_pages: u32) -> String { use quasi_axum::Serves as _; let (items, range) = page(total_pages); Webview::new().screen(&page_screen(&Page { items: &items, total_items: 40, current_page: current, total_pages, pagination_range: &range, showing_start: 1, showing_end: 20, })) } /// Both surfaces draw the same table out of the same description. The /// columns and the rows are the half that must not differ. #[test] fn the_two_surfaces_draw_one_table() { let panel = library(1, 2); let page = public(1, 2); for html in [&panel, &page] { assert!(html.contains("Item itm_1"), "{html}"); assert!(html.contains("acreator"), "{html}"); assert!(html.contains("drums"), "{html}"); assert!(html.contains("$4.00"), "{html}"); // The free badge, which both templates drew as a toned badge. assert!(html.contains(">Free<"), "{html}"); assert!(html.contains(r#"data-tone="success""#), "{html}"); } } /// A row is the link, and it goes to the item rather than into the row. #[test] fn a_row_navigates_to_its_item() { let html = library(1, 2); assert!(html.contains(r#"href="/i/itm_1""#), "{html}"); // `00ee7af5`: a bare `Action::get` would emit an `hx-get` beside the // href and htmx would swap the item page into the row. assert!(!html.contains(r#"hx-get="/i/itm_1""#), "{html}"); } /// The panel's page controls swap the panel; the page's replace the /// document. This is the one thing that differs between the surfaces and it /// is the thing worth asserting twice. #[test] fn each_surface_pages_the_way_it_is_read() { let panel = library(2, 4); assert!( panel.contains(r#"hx-get="/library/tabs/feed?page=3""#), "{panel}" ); assert!(panel.contains(r##"hx-target="#library-feed""##), "{panel}"); let page = public(2, 4); assert!(page.contains(r#"href="/feed?page=3""#), "{page}"); assert!(!page.contains("hx-get=\"/feed"), "{page}"); assert!(!page.contains("hx-target="), "{page}"); } /// The strip is the window the handler chose, each page with its own /// address, and the page the reader is on is text rather than a control. #[test] fn the_strip_offers_the_pages_the_handler_windowed() { let html = library(2, 4); for page in [1u32, 3, 4] { assert!( html.contains(&format!("/library/tabs/feed?page={page}")), "{html}" ); } // Page 2 is where the reader is: marked, and not a control that reloads // the page it is on. assert!(html.contains(r#"aria-current="page""#), "{html}"); assert!( !html.contains(r#"hx-get="/library/tabs/feed?page=2""#), "{html}" ); } /// A first page offers no way back and a last page no way forward, and the /// renderer draws neither rather than drawing one it has to disable. /// /// Ruled by Max 2026-09-08, against the disabled control that stood here: a /// Prev that cannot go back is a control that answers nothing. See /// `quasi-webview`'s `rest_html`. #[test] fn the_ends_of_the_set_say_so() { let first = library(1, 4); assert!(!first.contains("rest-previous"), "{first}"); assert!(first.contains("rest-next"), "{first}"); let last = library(4, 4); assert!(last.contains("rest-previous"), "{last}"); assert!(!last.contains("rest-next"), "{last}"); assert!(!first.contains("disabled>"), "{first}"); assert!(!last.contains("disabled>"), "{last}"); } /// One page is no rest at all, which is what `{% if total_pages > 1 %}` /// said. #[test] fn a_single_page_feed_has_no_pager() { let html = library(1, 1); assert!(!html.contains("rest"), "{html}"); assert!(!html.contains("page="), "{html}"); } /// The count line, which the strip does not state. See the module header: /// it is the one number a reader would otherwise lose. #[test] fn the_reader_is_told_how_much_there_is() { assert!(library(1, 2).contains("Showing 1-20 of 40 items")); assert!(public(1, 2).contains("Showing 1-20 of 40 items")); } /// Nothing followed yet, said once for both surfaces, with the same way /// out. #[test] fn an_empty_feed_offers_discover() { let empty = public_of(&Page { items: &[], total_items: 0, current_page: 1, total_pages: 0, pagination_range: &[], showing_start: 0, showing_end: 0, }); assert!(empty.contains("Nothing here yet"), "{empty}"); assert!(empty.contains(r#"href="/discover""#), "{empty}"); // No table and no pager over nothing. assert!(!empty.contains(") -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(page)) } /// The parity `2790e5c4` asks for, on the screen it was written against: /// the measure class and the page's own identity token, in that order, on /// ``. #[test] fn the_document_carries_the_class_the_template_carried() { assert_eq!( page_screen(&Page { items: &[], total_items: 0, current_page: 1, total_pages: 0, pagination_range: &[], showing_start: 0, showing_end: 0, }) .document .body_class .as_deref(), Some("padded-page feed-page") ); assert!(public(1, 2).contains("class=\"padded-page feed-page\"")); } /// `736f45a5`. The template carried no indicator and neither does the /// screen: the listing waits on nothing a reader presses. #[test] fn the_page_spells_no_spinner() { let rendered = public(1, 2); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!( !rendered.contains(spelling), "{spelling} survives in {rendered}" ); } } /// The title `pages/feed.html` drew as `

`, drawn by /// the description instead. The class moved with it, which is what /// `style.css` had to be retargeted for. #[test] fn the_page_names_itself() { let html = public(1, 2); assert!( html.contains("

Your Feed

"), "{html}" ); assert!(html.contains("Feed - Makenotwork"), "{html}"); assert!(!html.contains("page-title"), "{html}"); } /// No `?page=` grammar survives in a template: each surface builds its own /// addresses in one place, and neither is a template's. #[test] fn the_page_grammar_lives_in_one_place_per_surface() { assert!( panel_address(3) .destination .route() .is_some_and(|route| route == "/library/tabs/feed?page=3") ); // The page surface's is in `page_table`'s own `more` body, so it is read // out of the markup rather than out of a function. let html = public(3, 8); assert!(html.contains("href=\"/feed?page=2\""), "{html}"); assert!(html.contains("href=\"/feed?page=4\""), "{html}"); } /// The two surfaces draw the same columns, which is what holds the split /// tables together. /// /// `panel_table` and `page_table` repeat the column list, because their /// pagers are different markup and a residual holds one. Nothing in the /// compiler pairs the two lists, so this does: a column added to one and /// forgotten in the other changes one heading row and not the other. #[test] fn the_two_surfaces_draw_the_same_columns() { use quasi_axum::Serves as _; fn headings(html: &str) -> Vec<&str> { html.match_indices("columnheader") .map(|(at, _)| { let rest = &html[at..]; let from = rest.find('>').map_or(0, |at| at + 1); let to = rest[from..].find('<').map_or(0, |at| at + from); &rest[from..to] }) .collect() } let (items, range) = page(4); let held = Page { items: &items, total_items: 40, current_page: 1, total_pages: 4, pagination_range: &range, showing_start: 1, showing_end: 20, }; let panel = Webview::new().fragment(&panel_table(&held)); let public = Webview::new().fragment(&page_table(&held)); assert_eq!(headings(&panel), headings(&public)); assert_eq!(headings(&panel).len(), 5, "{panel}"); } }