max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
17 files changed,
+680 insertions,
-283 deletions
| @@ -685,7 +685,9 @@ | |||
| 685 | 685 | /// | |
| 686 | 686 | /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here | |
| 687 | 687 | /// against makeover's `table-row`, which is the same build-time grid story | |
| 688 | - | /// goingson is on. | |
| 688 | + | /// goingson is on. `.table-head { display }` is the same divergence on the | |
| 689 | + | /// heading row: the feed's five column tracks have to be stated on both arms | |
| 690 | + | /// or the headings stop lining up with the cells under them. | |
| 689 | 691 | /// | |
| 690 | 692 | /// Do not restate a state makeover already names: tabs carry `chosen`, in the | |
| 691 | 693 | /// templates and in `frontend/src/core/tabs.ts`, rather than a local | |
| @@ -719,6 +721,7 @@ | |||
| 719 | 721 | ("tab", "color"), | |
| 720 | 722 | ("tab", "cursor"), | |
| 721 | 723 | ("table-row", "display"), | |
| 724 | + | ("table-head", "display"), | |
| 722 | 725 | ]; | |
| 723 | 726 | ||
| 724 | 727 | /// Bare element rules that reach a generated class and have been read and kept. |
| @@ -628,6 +628,38 @@ | |||
| 628 | 628 | Self(self.0.nest_service(path, guarded)) | |
| 629 | 629 | } | |
| 630 | 630 | ||
| 631 | + | /// Mount a self-routing service at ONE exact address. | |
| 632 | + | /// | |
| 633 | + | /// [`nest_service`](Self::nest_service)'s sibling, for a described screen | |
| 634 | + | /// that owns a whole document rather than a panel. A nest claims | |
| 635 | + | /// `{path}/{*tail}`, and matchit refuses that beside a parameterised route | |
| 636 | + | /// already registered under the same prefix: `/feed` is a described screen | |
| 637 | + | /// and `/feed/{user_id}` is that user's RSS. An exact route claims neither, | |
| 638 | + | /// and a document screen has no sub-paths to serve. | |
| 639 | + | /// | |
| 640 | + | /// Same Auto posture, same wrapping order, and deliberately no posture | |
| 641 | + | /// argument, for the reasons stated on `nest_service`. | |
| 642 | + | #[must_use] | |
| 643 | + | pub fn route_service<T>(self, path: &str, service: T) -> Self | |
| 644 | + | where | |
| 645 | + | T: tower::Service<Request, Error = std::convert::Infallible> | |
| 646 | + | + Clone | |
| 647 | + | + Send | |
| 648 | + | + Sync | |
| 649 | + | + 'static, | |
| 650 | + | T::Response: IntoResponse + 'static, | |
| 651 | + | T::Future: Send + 'static, | |
| 652 | + | { | |
| 653 | + | record_route(path, CsrfPosture::Auto); | |
| 654 | + | let guarded = tower::ServiceBuilder::new() | |
| 655 | + | .layer(from_fn(|req: Request, next: Next| async move { | |
| 656 | + | let path = req.uri().path().to_string(); | |
| 657 | + | validate_auto(req, next, &path).await | |
| 658 | + | })) | |
| 659 | + | .service(service); | |
| 660 | + | Self(self.0.route_service(path, guarded)) | |
| 661 | + | } | |
| 662 | + | ||
| 631 | 663 | #[must_use] | |
| 632 | 664 | pub fn layer<L>(self, layer: L) -> Self | |
| 633 | 665 | where |
| @@ -600,6 +600,15 @@ | |||
| 600 | 600 | .fold(csrf_routes, |routes, (path, described)| { | |
| 601 | 601 | routes.nest_service(path, described) | |
| 602 | 602 | }); | |
| 603 | + | // The screens that own their whole document. Same envelope and the same | |
| 604 | + | // factory; what differs is a session gate in front, so an address a person | |
| 605 | + | // can type answers the branded 401 rather than the adapter's bare 403. | |
| 606 | + | // See `quasi::document_mounts`. | |
| 607 | + | let csrf_routes = quasi::document_mounts(state) | |
| 608 | + | .into_iter() | |
| 609 | + | .fold(csrf_routes, |routes, (path, described)| { | |
| 610 | + | routes.route_service(path, described) | |
| 611 | + | }); | |
| 603 | 612 | // The public half of the description layer. Same envelope, different | |
| 604 | 613 | // factory: these screens resolve no session. See `quasi::public_mounts`. | |
| 605 | 614 | let csrf_routes = quasi::public_mounts(state) |
| @@ -160,6 +160,72 @@ | |||
| 160 | 160 | ) | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | + | /// The site header, as every page on this site carries it. | |
| 164 | + | /// | |
| 165 | + | /// Called by `partials/site_header.html` for the templated pages and handed to | |
| 166 | + | /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own | |
| 167 | + | /// document, so the nav is written once. [`body_last`]'s argument one element | |
| 168 | + | /// up. | |
| 169 | + | /// | |
| 170 | + | /// Not a `Chrome`. `Chrome::nav` is emitted before `<main>` and a | |
| 171 | + | /// `Chrome::panel` after `</main>`, and this header's mobile menu is a checkbox | |
| 172 | + | /// styling its siblings (`style.css`, `.nav-toggle-checkbox:checked ~ .header-search` | |
| 173 | + | /// and `~ nav`), so the two halves have to be one element. Wiki note | |
| 174 | + | /// `mnw-server-conversion-plan`, the 2026-08-31 ruling. | |
| 175 | + | /// | |
| 176 | + | /// Nothing a reader wrote is emitted here: the only per-request values are | |
| 177 | + | /// `user.is_admin` and the session's CSRF token, and the token is escaped | |
| 178 | + | /// anyway rather than trusted for being ours. | |
| 179 | + | #[must_use] | |
| 180 | + | pub fn site_header(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> String { | |
| 181 | + | let mut out = String::with_capacity(1600); | |
| 182 | + | out.push_str(concat!( | |
| 183 | + | "<header class=\"site-header\" role=\"banner\">", | |
| 184 | + | "<a href=\"/\" class=\"site-logo\" aria-label=\"Makenotwork - Go to homepage\">", | |
| 185 | + | "Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</a>", | |
| 186 | + | "<input type=\"checkbox\" id=\"nav-toggle\" class=\"nav-toggle-checkbox\" aria-hidden=\"true\">", | |
| 187 | + | "<label for=\"nav-toggle\" class=\"nav-toggle-label\" aria-label=\"Toggle navigation menu\">", | |
| 188 | + | "<span></span><span></span><span></span></label>", | |
| 189 | + | "<form action=\"/discover\" method=\"get\" class=\"header-search\" role=\"search\">", | |
| 190 | + | "<input type=\"search\" name=\"q\" id=\"header-search-input\" ", | |
| 191 | + | "placeholder=\"Search... (Cmd+K)\" autocomplete=\"off\" ", | |
| 192 | + | "aria-label=\"Search items and projects\"></form>", | |
| 193 | + | "<nav aria-label=\"Main navigation\"><div class=\"nav-links\">", | |
| 194 | + | "<a href=\"/discover\">Discover</a>", | |
| 195 | + | )); | |
| 196 | + | if let Some(user) = user { | |
| 197 | + | out.push_str(concat!( | |
| 198 | + | "<a href=\"/library\">Library</a>", | |
| 199 | + | "<a href=\"/dashboard\">Dashboard</a>", | |
| 200 | + | "<a href=\"/cart\" id=\"nav-cart-link\" class=\"hidden\">Cart", | |
| 201 | + | "<span id=\"cart-badge\" class=\"cart-badge-count\"></span></a>", | |
| 202 | + | )); | |
| 203 | + | if user.is_admin { | |
| 204 | + | out.push_str("<a href=\"/admin/waitlist\">Admin</a>"); | |
| 205 | + | } | |
| 206 | + | out.push_str("<form action=\"/logout\" method=\"post\" class=\"nav-form\">"); | |
| 207 | + | if let Some(token) = csrf { | |
| 208 | + | out.push_str("<input type=\"hidden\" name=\"_csrf\" value=\""); | |
| 209 | + | out.push_str(&crate::helpers::escape_html(token)); | |
| 210 | + | out.push_str("\">"); | |
| 211 | + | } | |
| 212 | + | out.push_str(concat!( | |
| 213 | + | "<button type=\"submit\" class=\"btn--link\" aria-label=\"Log out\">Log Out</button>", | |
| 214 | + | "</form>", | |
| 215 | + | )); | |
| 216 | + | } else { | |
| 217 | + | out.push_str(concat!( | |
| 218 | + | "<a href=\"/use-cases\">Use Cases</a>", | |
| 219 | + | "<a href=\"/docs\">Docs</a>", | |
| 220 | + | "<a href=\"/fan-plus\">Fan+</a>", | |
| 221 | + | "<a href=\"/login\">Login</a>", | |
| 222 | + | "<a href=\"/join\">Join</a>", | |
| 223 | + | )); | |
| 224 | + | } | |
| 225 | + | out.push_str("</div></nav></header>"); | |
| 226 | + | out | |
| 227 | + | } | |
| 228 | + | ||
| 163 | 229 | /// `<!doctype>` through the head's contents, without `</head>`. | |
| 164 | 230 | /// | |
| 165 | 231 | /// Called from `base.html`, which appends the title and the per-page head and | |
| @@ -308,6 +374,75 @@ | |||
| 308 | 374 | ); | |
| 309 | 375 | } | |
| 310 | 376 | ||
| 377 | + | /// A reader with no session is offered the two ways in and no way out. | |
| 378 | + | #[test] | |
| 379 | + | fn the_signed_out_header_offers_the_ways_in() { | |
| 380 | + | let html = site_header(None, None); | |
| 381 | + | ||
| 382 | + | assert!(html.contains("href=\"/login\""), "{html}"); | |
| 383 | + | assert!(html.contains("href=\"/join\""), "{html}"); | |
| 384 | + | assert!(html.contains("href=\"/discover\""), "{html}"); | |
| 385 | + | assert!(!html.contains("/logout"), "{html}"); | |
| 386 | + | assert!(!html.contains("/dashboard"), "{html}"); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | /// A signed-in reader gets the account nav, the logout form and the token | |
| 390 | + | /// that form needs. A logout with no `_csrf` is a logout the envelope | |
| 391 | + | /// refuses. | |
| 392 | + | #[test] | |
| 393 | + | fn the_signed_in_header_carries_the_logout_form_and_its_token() { | |
| 394 | + | let html = site_header(Some(&a_user(false)), Some("tok&en")); | |
| 395 | + | ||
| 396 | + | assert!(html.contains("action=\"/logout\""), "{html}"); | |
| 397 | + | assert!( | |
| 398 | + | html.contains("name=\"_csrf\" value=\"tok&en\""), | |
| 399 | + | "{html}" | |
| 400 | + | ); | |
| 401 | + | assert!(html.contains("href=\"/library\""), "{html}"); | |
| 402 | + | assert!(html.contains("href=\"/dashboard\""), "{html}"); | |
| 403 | + | assert!(!html.contains("href=\"/login\""), "{html}"); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | /// The admin link is the one thing in the nav that is a permission rather | |
| 407 | + | /// than a session. | |
| 408 | + | #[test] | |
| 409 | + | fn only_an_admin_is_offered_the_admin_link() { | |
| 410 | + | assert!(site_header(Some(&a_user(true)), Some("t")).contains("/admin/waitlist")); | |
| 411 | + | assert!(!site_header(Some(&a_user(false)), Some("t")).contains("/admin/waitlist")); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | /// The mobile menu is a checkbox styling its siblings, which is the whole | |
| 415 | + | /// reason this header is one element rather than a `Chrome`. The three have | |
| 416 | + | /// to stay siblings inside `<header>`, in this order. | |
| 417 | + | #[test] | |
| 418 | + | fn the_disclosure_stays_a_sibling_of_what_it_reveals() { | |
| 419 | + | let html = site_header(None, None); | |
| 420 | + | ||
| 421 | + | let toggle = html.find("nav-toggle-checkbox").expect("the checkbox"); | |
| 422 | + | let search = html.find("header-search").expect("the search form"); | |
| 423 | + | let nav = html.find("<nav").expect("the nav"); | |
| 424 | + | assert!(toggle < search && search < nav, "{html}"); | |
| 425 | + | assert!(html.ends_with("</header>"), "{html}"); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | fn a_user(is_admin: bool) -> crate::auth::SessionUser { | |
| 429 | + | crate::auth::SessionUser { | |
| 430 | + | id: crate::db::UserId::new(), | |
| 431 | + | username: crate::db::Username::from_trusted("areader".to_string()), | |
| 432 | + | email: "areader@example.com".to_string(), | |
| 433 | + | display_name: None, | |
| 434 | + | can_create_projects: false, | |
| 435 | + | suspended: false, | |
| 436 | + | is_admin, | |
| 437 | + | is_fan_plus: false, | |
| 438 | + | creator_tier: None, | |
| 439 | + | deactivated: false, | |
| 440 | + | is_sandbox: false, | |
| 441 | + | settlement_currency: crate::currency::SettlementCurrency::Usd, | |
| 442 | + | conversion_preference: crate::currency::ConversionChoice::AtCheckout, | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 311 | 446 | #[test] | |
| 312 | 447 | fn no_template_still_writes_a_layout_class_by_hand() { | |
| 313 | 448 | // The done-condition, checked rather than remembered: the layout axis |
| @@ -406,7 +406,6 @@ | |||
| 406 | 406 | .export-page .container { max-width: 800px; margin: 0 auto; } | |
| 407 | 407 | ||
| 408 | 408 | .fan-plus-page .container, | |
| 409 | - | .feed-page .container, | |
| 410 | 409 | .creators-page .container { max-width: 900px; margin: 0 auto; } | |
| 411 | 410 | ||
| 412 | 411 | /* READING COLUMN, the 680px measure every long-form surface uses. Narrower | |
| @@ -3539,12 +3538,28 @@ | |||
| 3539 | 3538 | .purchase-page h2 { font-size: var(--text-head); } | |
| 3540 | 3539 | } | |
| 3541 | 3540 | ||
| 3542 | - | /* Feed page (templates/pages/feed.html). Compact tabular layout of | |
| 3543 | - | items from followed users / projects / tags. */ | |
| 3544 | - | .feed-page .page-title { font-size: var(--text-head); margin-bottom: var(--gap-section); } | |
| 3545 | - | .feed-page .feed-meta { font-size: var(--text-fine); opacity: 0.6; margin-bottom: var(--gap-section); } | |
| 3541 | + | /* Feed page (src/quasi/feeds.rs). Compact tabular layout of items from | |
| 3542 | + | followed users / projects / tags. | |
| 3546 | 3543 | ||
| 3547 | - | .feed-page .table-header { | |
| 3544 | + | The page is described rather than templated as of 2026-08-31, so the | |
| 3545 | + | selectors below name what quasi and makeover emit. It has no .container: | |
| 3546 | + | a described document is <body> > <main> > the screen's region, so the 900px | |
| 3547 | + | measure the container carried sits on <main> here. */ | |
| 3548 | + | .feed-page main { max-width: 900px; margin: 0 auto; padding: var(--gap-page); } | |
| 3549 | + | ||
| 3550 | + | .feed-page main h1 { font-size: var(--text-head); margin-bottom: var(--gap-section); } | |
| 3551 | + | ||
| 3552 | + | /* The "Showing X-Y of N items" line, which the description states as a plain | |
| 3553 | + | text node above the table. */ | |
| 3554 | + | .feed-page .pane > p.text { | |
| 3555 | + | font-size: var(--text-fine); | |
| 3556 | + | opacity: 0.6; | |
| 3557 | + | margin-bottom: var(--gap-section); | |
| 3558 | + | } | |
| 3559 | + | ||
| 3560 | + | .feed-page .table-head { | |
| 3561 | + | /* respec-ok: the same five-column grid the rows carry, on the heading row | |
| 3562 | + | that has to line up with them. */ | |
| 3548 | 3563 | display: grid; | |
| 3549 | 3564 | grid-template-columns: 50px 1fr 100px 70px 70px; | |
| 3550 | 3565 | gap: var(--gap-peer); | |
| @@ -3556,11 +3571,8 @@ | |||
| 3556 | 3571 | letter-spacing: 0.03em; | |
| 3557 | 3572 | } | |
| 3558 | 3573 | ||
| 3559 | - | .feed-page .col-right { text-align: right; } | |
| 3560 | - | ||
| 3561 | - | .feed-page .results-table { | |
| 3574 | + | .feed-page .table { | |
| 3562 | 3575 | border: 1px solid var(--border); | |
| 3563 | - | border-top: none; | |
| 3564 | 3576 | } | |
| 3565 | 3577 | ||
| 3566 | 3578 | .feed-page .table-row { | |
| @@ -3582,20 +3594,25 @@ | |||
| 3582 | 3594 | .feed-page .table-row:last-child { border-bottom: none; } | |
| 3583 | 3595 | .feed-page .table-row:hover { background: var(--surface-sunken); } | |
| 3584 | 3596 | ||
| 3585 | - | .feed-page .item-name-cell { | |
| 3597 | + | /* The two-line name cell: `Cell::part` puts the creator under the name in one | |
| 3598 | + | cell, and each part is emitted as its own `.cell-value`. */ | |
| 3599 | + | .feed-page .col-Name { | |
| 3586 | 3600 | display: flex; | |
| 3587 | 3601 | flex-direction: column; | |
| 3588 | 3602 | gap: var(--gap-bound); | |
| 3589 | 3603 | min-width: 0; | |
| 3590 | 3604 | } | |
| 3591 | 3605 | ||
| 3592 | - | .feed-page .item-name { | |
| 3606 | + | .feed-page .table-row .col-Name .cell-value:first-child { | |
| 3593 | 3607 | white-space: nowrap; | |
| 3594 | 3608 | overflow: hidden; | |
| 3595 | 3609 | text-overflow: ellipsis; | |
| 3596 | 3610 | } | |
| 3597 | 3611 | ||
| 3598 | - | .feed-page .item-creator { font-size: var(--text-fine); opacity: 0.5; } | |
| 3612 | + | .feed-page .table-row .col-Name .cell-value:last-child { | |
| 3613 | + | font-size: var(--text-fine); | |
| 3614 | + | opacity: 0.5; | |
| 3615 | + | } | |
| 3599 | 3616 | ||
| 3600 | 3617 | /* The feed's badges shout a little: caps and tracking. The sunken fill that | |
| 3601 | 3618 | used to come with them went with the filled-badge form in B2. */ | |
| @@ -3604,49 +3621,19 @@ | |||
| 3604 | 3621 | letter-spacing: 0.03em; | |
| 3605 | 3622 | } | |
| 3606 | 3623 | ||
| 3607 | - | .feed-page .empty-state { | |
| 3608 | - | text-align: center; | |
| 3609 | - | padding: var(--gap-page) var(--gap-pane); | |
| 3610 | - | background: var(--surface-overlay); | |
| 3611 | - | border: 1px solid var(--border); | |
| 3612 | - | } | |
| 3613 | - | ||
| 3614 | - | .feed-page .empty-state h2 { font-size: var(--text-subhead); margin-bottom: var(--gap-peer); } | |
| 3615 | - | .feed-page .empty-state p { opacity: 0.7; margin-bottom: var(--gap-pane); } | |
| 3616 | - | ||
| 3617 | - | .feed-page .pagination { | |
| 3618 | - | display: flex; | |
| 3619 | - | gap: var(--gap-bound); | |
| 3620 | - | justify-content: center; | |
| 3621 | - | margin-top: var(--gap-section); | |
| 3622 | - | } | |
| 3623 | - | ||
| 3624 | - | .feed-page .pagination a, | |
| 3625 | - | .feed-page .pagination span { | |
| 3626 | - | padding: var(--gap-peer) var(--gap-section); | |
| 3627 | - | font-size: var(--text-note); | |
| 3628 | - | text-decoration: none; | |
| 3629 | - | color: var(--content); | |
| 3630 | - | border: 1px solid var(--border); | |
| 3631 | - | } | |
| 3632 | - | ||
| 3633 | - | .feed-page .pagination span.current { | |
| 3634 | - | background: var(--primary-dark); | |
| 3635 | - | color: var(--primary-light); | |
| 3636 | - | border-color: var(--primary-dark); | |
| 3637 | - | } | |
| 3638 | - | ||
| 3639 | - | .feed-page .pagination a:hover { background: var(--surface-sunken); } | |
| 3624 | + | /* The empty state and the numbered strip carry no page-specific rules any | |
| 3625 | + | more: `Node::empty` and `Rest` emit their own classes, styled once for every | |
| 3626 | + | described screen rather than again per page. */ | |
| 3640 | 3627 | ||
| 3641 | 3628 | @media (max-width: 599px) { | |
| 3642 | - | .feed-page .table-header, | |
| 3629 | + | .feed-page main { padding: var(--gap-section); } | |
| 3630 | + | .feed-page .table-head, | |
| 3643 | 3631 | .feed-page .table-row { grid-template-columns: 1fr 70px; } | |
| 3644 | - | .feed-page .table-header span:nth-child(1), | |
| 3645 | - | .feed-page .table-row .badge:first-child, | |
| 3646 | - | .feed-page .table-header span:nth-child(3), | |
| 3647 | - | .feed-page .table-header span:nth-child(4), | |
| 3648 | - | .feed-page .table-row span:nth-child(3), | |
| 3649 | - | .feed-page .table-row span:nth-child(3), | |
| 3632 | + | /* Name and Date survive the narrow viewport, which is what the five-column | |
| 3633 | + | header dropped to before the conversion. */ | |
| 3634 | + | .feed-page .col-Type, | |
| 3635 | + | .feed-page .col-Tag, | |
| 3636 | + | .feed-page .col-Price { display: none; } | |
| 3650 | 3637 | } | |
| 3651 | 3638 | ||
| 3652 | 3639 | /* (Stripe Connect disclaimer page rules defined in the canonical |
| @@ -57,14 +57,19 @@ | |||
| 57 | 57 | //! strip would let this go, and that is a `quasi-webview` question rather than | |
| 58 | 58 | //! this server's. | |
| 59 | 59 | //! | |
| 60 | - | //! # What this does not convert | |
| 60 | + | //! # The page owns its document | |
| 61 | 61 | //! | |
| 62 | - | //! `pages/feed.html` keeps its Askama shell: `base.html`, `site_header.html`, | |
| 63 | - | //! the page title, and the `body_attrs` that sets the Wide measure. Nothing | |
| 64 | - | //! described on this server has ever owned a public document, and the site | |
| 65 | - | //! header is not describable today. The page's *body* is this module and the | |
| 66 | - | //! template is the shell around it. Splitting the shell off is filed rather | |
| 67 | - | //! than widened into here, on `830b1661`'s own instruction. | |
| 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. | |
| 68 | 73 | //! | |
| 69 | 74 | //! The six other `page=` sites are Newer/Older or Previous/Next only and want | |
| 70 | 75 | //! no `jumps`: `pages/git/{commits,explore,issues,file_log,notes}.html` and | |
| @@ -78,7 +83,9 @@ | |||
| 78 | 83 | ||
| 79 | 84 | use makeover_layout as layout; | |
| 80 | 85 | use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Tag}; | |
| 81 | - | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 86 | + | use quasi_router::{ | |
| 87 | + | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 88 | + | }; | |
| 82 | 89 | use quasi_webview::Webview; | |
| 83 | 90 | ||
| 84 | 91 | use crate::constants; | |
| @@ -91,21 +98,31 @@ | |||
| 91 | 98 | pub const LIBRARY_REGION: &str = "library-feed"; | |
| 92 | 99 | ||
| 93 | 100 | /// The region the public page's body sits in. | |
| 94 | - | const PAGE_REGION: &str = "feed"; | |
| 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; | |
| 95 | 115 | ||
| 96 | 116 | /// The address the library panel is read from. | |
| 97 | 117 | const LIBRARY_ROUTE: &str = "/library/tabs/feed"; | |
| 98 | 118 | ||
| 99 | - | /// The address the public page is read from. | |
| 100 | - | const PAGE_ROUTE: &str = "/feed"; | |
| 101 | - | ||
| 102 | 119 | /// One page of a feed, as both handlers have already computed it. | |
| 103 | 120 | /// | |
| 104 | 121 | /// Every field here is what the two templates were handed, under the names they | |
| 105 | - | /// were handed them under. The arithmetic stays in the handlers: both clamp | |
| 106 | - | /// `?page=`, widen to `i64` before multiplying, and saturate the "showing" | |
| 107 | - | /// labels, and that is three overflow fixes this conversion has no business | |
| 108 | - | /// re-deriving. | |
| 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. | |
| 109 | 126 | pub struct Page<'a> { | |
| 110 | 127 | /// The rows, in the order they read. | |
| 111 | 128 | pub items: &'a [DiscoverItem], | |
| @@ -135,16 +152,139 @@ | |||
| 135 | 152 | Webview::new().fragment(&Node::Region(slot)) | |
| 136 | 153 | } | |
| 137 | 154 | ||
| 138 | - | /// The public page's body, for `pages/feed.html` to drop into its shell. | |
| 139 | - | #[must_use] | |
| 140 | - | pub fn page_body(page: &Page<'_>) -> String { | |
| 141 | - | use quasi_axum::Serves as _; | |
| 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 | + | } | |
| 142 | 165 | ||
| 143 | - | let mut slot = Slot::new(PAGE_REGION, RegionKind::Pane); | |
| 144 | - | for node in body(page, Surface::Page) { | |
| 145 | - | slot = slot.with(node); | |
| 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 | + | } | |
| 146 | 179 | } | |
| 147 | - | Webview::new().fragment(&Node::Region(slot)) | |
| 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.user.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(Some(&viewer.user), Some(&viewer.csrf)), | |
| 287 | + | ))) | |
| 148 | 288 | } | |
| 149 | 289 | ||
| 150 | 290 | /// Which of the two surfaces is being drawn. | |
| @@ -166,7 +306,7 @@ | |||
| 166 | 306 | Self::Panel => Action::get(format!("{LIBRARY_ROUTE}?page={page}")) | |
| 167 | 307 | .awaiting() | |
| 168 | 308 | .replacing(LIBRARY_REGION), | |
| 169 | - | Self::Page => Action::get(format!("{PAGE_ROUTE}?page={page}")).navigating(), | |
| 309 | + | Self::Page => Action::get(format!("{PATH}?page={page}")).navigating(), | |
| 170 | 310 | } | |
| 171 | 311 | } | |
| 172 | 312 | } | |
| @@ -308,9 +448,14 @@ | |||
| 308 | 448 | }) | |
| 309 | 449 | } | |
| 310 | 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. | |
| 311 | 454 | fn public(current: u32, total_pages: u32) -> String { | |
| 455 | + | use quasi_axum::Serves as _; | |
| 456 | + | ||
| 312 | 457 | let (items, range) = page(total_pages); | |
| 313 | - | page_body(&Page { | |
| 458 | + | Webview::new().screen(&page_screen(&Page { | |
| 314 | 459 | items: &items, | |
| 315 | 460 | total_items: 40, | |
| 316 | 461 | current_page: current, | |
| @@ -318,7 +463,7 @@ | |||
| 318 | 463 | pagination_range: &range, | |
| 319 | 464 | showing_start: 1, | |
| 320 | 465 | showing_end: 20, | |
| 321 | - | }) | |
| 466 | + | })) | |
| 322 | 467 | } | |
| 323 | 468 | ||
| 324 | 469 | /// Both surfaces draw the same table out of the same description. The | |
| @@ -425,7 +570,7 @@ | |||
| 425 | 570 | /// out. | |
| 426 | 571 | #[test] | |
| 427 | 572 | fn an_empty_feed_offers_discover() { | |
| 428 | - | let empty = page_body(&Page { | |
| 573 | + | let empty = public_of(&Page { | |
| 429 | 574 | items: &[], | |
| 430 | 575 | total_items: 0, | |
| 431 | 576 | current_page: 1, | |
| @@ -441,6 +586,51 @@ | |||
| 441 | 586 | assert!(!empty.contains("<table"), "{empty}"); | |
| 442 | 587 | } | |
| 443 | 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 | + | /// The title `pages/feed.html` drew as `<h1 class="page-title">`, drawn by | |
| 620 | + | /// the description instead. The class moved with it, which is what | |
| 621 | + | /// `style.css` had to be retargeted for. | |
| 622 | + | #[test] | |
| 623 | + | fn the_page_names_itself() { | |
| 624 | + | let html = public(1, 2); | |
| 625 | + | ||
| 626 | + | assert!( | |
| 627 | + | html.contains("<h1 class=\"heading\">Your Feed</h1>"), | |
| 628 | + | "{html}" | |
| 629 | + | ); | |
| 630 | + | assert!(html.contains("<title>Feed - Makenotwork</title>"), "{html}"); | |
| 631 | + | assert!(!html.contains("page-title"), "{html}"); | |
| 632 | + | } | |
| 633 | + | ||
| 444 | 634 | /// No `?page=` grammar survives in a template: both addresses are built | |
| 445 | 635 | /// here, from one function, on the surface that reads them. | |
| 446 | 636 | #[test] |
| @@ -172,6 +172,32 @@ | |||
| 172 | 172 | .sending("X-CSRF-Token", &self.csrf) | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | + | /// The shell a described screen that owns its whole DOCUMENT is drawn in. | |
| 176 | + | /// | |
| 177 | + | /// [`shell`](Self::shell) is right for a fragment landing inside an Askama | |
| 178 | + | /// page, which already has the head, the tail and the token meta. A | |
| 179 | + | /// document owes all three itself: [`crate::shell::described`] is the same | |
| 180 | + | /// builder `base.html` renders through, [`crate::shell::body_last`] is the | |
| 181 | + | /// toast container and the classic shims, and the token meta is what the | |
| 182 | + | /// pre-module scripts read. | |
| 183 | + | /// | |
| 184 | + | /// The meta is not redundant with `Shell::sending`. That covers htmx, and | |
| 185 | + | /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`, | |
| 186 | + | /// `static/passkey.js` and `static/project-sections.js` all read | |
| 187 | + | /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it | |
| 188 | + | /// holds no session. | |
| 189 | + | #[must_use] | |
| 190 | + | pub fn document_shell(&self) -> quasi_webview::Shell { | |
| 191 | + | crate::shell::described() | |
| 192 | + | .sending("X-CSRF-Token", &self.csrf) | |
| 193 | + | .with_body_last(crate::shell::body_last()) | |
| 194 | + | .with_chrome(crate::quasi::shortcuts::chrome()) | |
| 195 | + | .with_head(format!( | |
| 196 | + | "<meta name=\"csrf-token\" content=\"{}\">", | |
| 197 | + | crate::helpers::escape_html(&self.csrf) | |
| 198 | + | )) | |
| 199 | + | } | |
| 200 | + | ||
| 175 | 201 | /// Everything the handler drew, for the renderer to mount. | |
| 176 | 202 | pub fn drawn(&self) -> std::collections::HashMap<String, String> { | |
| 177 | 203 | self.fills.lock().map(|f| f.clone()).unwrap_or_default() | |
| @@ -264,6 +290,20 @@ | |||
| 264 | 290 | forum_memberships::SETTINGS_PATH, | |
| 265 | 291 | ]; | |
| 266 | 292 | ||
| 293 | + | /// Every described screen that owns its whole document, with the address it | |
| 294 | + | /// answers. | |
| 295 | + | /// | |
| 296 | + | /// Its own list rather than an entry in [`PATHS`], for the same reason | |
| 297 | + | /// [`public_mounts`] keeps its own: what a nest answers with decides what a | |
| 298 | + | /// test can assert about it. A panel screen answers `Response::Fragment` and | |
| 299 | + | /// names the region it changed, which `tests/workflows/described_screens.rs` | |
| 300 | + | /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document | |
| 301 | + | /// answers `Outcome::Screen`, which sets no such header and is not a defect. | |
| 302 | + | /// | |
| 303 | + | /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen | |
| 304 | + | /// registers no mutating route, so it has nothing to skip here either. | |
| 305 | + | pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH]; | |
| 306 | + | ||
| 267 | 307 | /// Every screen's switch name, in the same order as [`PATHS`]. | |
| 268 | 308 | /// | |
| 269 | 309 | /// Test-only: the switches themselves are read from each screen's own `SCREEN` | |
| @@ -352,6 +392,75 @@ | |||
| 352 | 392 | ] | |
| 353 | 393 | } | |
| 354 | 394 | ||
| 395 | + | /// Every described screen that owns its whole document, mounted. | |
| 396 | + | /// | |
| 397 | + | /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`]. | |
| 398 | + | pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { | |
| 399 | + | vec![( | |
| 400 | + | feeds::PATH, | |
| 401 | + | document_mount(app, feeds::PATH, feeds::screen, feeds::renderer), | |
| 402 | + | )] | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches. | |
| 406 | + | /// | |
| 407 | + | /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses | |
| 408 | + | /// with `denied` and quasi-axum answers that as a bare 403 with no body: right | |
| 409 | + | /// for a panel fetched by a page that already checked, wrong for an address a | |
| 410 | + | /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup` | |
| 411 | + | /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front | |
| 412 | + | /// and answers whatever that refuses with, which renders the branded 401 with | |
| 413 | + | /// its way back in. It is the same call the `AuthUser` extractor makes, so the | |
| 414 | + | /// two paths cannot disagree about who is signed in. | |
| 415 | + | /// | |
| 416 | + | /// It costs one extra session read on this nest: the gate resolves the session | |
| 417 | + | /// and the factory resolves it again. Stated rather than optimised, because the | |
| 418 | + | /// alternative is a viewer whose `user` is optional, which is the signed-out | |
| 419 | + | /// question the feed conversion deliberately did not answer. | |
| 420 | + | /// | |
| 421 | + | /// # The address is registered whole, not as `/` | |
| 422 | + | /// | |
| 423 | + | /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix | |
| 424 | + | /// before the adapter sees the request. A document screen is mounted with | |
| 425 | + | /// `CsrfRouter::route_service` instead (see there for why), which strips | |
| 426 | + | /// nothing, so the router inside answers the address the reader typed. | |
| 427 | + | fn document_mount( | |
| 428 | + | app: &AppState, | |
| 429 | + | path: &'static str, | |
| 430 | + | screen: Screen, | |
| 431 | + | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 432 | + | ) -> axum::Router { | |
| 433 | + | let router = quasi_router::Router::<Viewer>::new().get(path, screen); | |
| 434 | + | quasi_axum::Adapter::per_viewer(router, viewer_factory(app.clone()), move |viewer, _, _| { | |
| 435 | + | renderer(viewer) | |
| 436 | + | }) | |
| 437 | + | .into_router() | |
| 438 | + | .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in)) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | /// The gate in front of every document nest: a reader or a branded refusal. | |
| 442 | + | async fn signed_in( | |
| 443 | + | axum::extract::State(app): axum::extract::State<AppState>, | |
| 444 | + | request: axum::extract::Request, | |
| 445 | + | next: axum::middleware::Next, | |
| 446 | + | ) -> axum::response::Response { | |
| 447 | + | use axum::response::IntoResponse as _; | |
| 448 | + | ||
| 449 | + | let Some(session) = request | |
| 450 | + | .extensions() | |
| 451 | + | .get::<tower_sessions::Session>() | |
| 452 | + | .cloned() | |
| 453 | + | else { | |
| 454 | + | // The session layer runs in front of this. Its absence is wiring. | |
| 455 | + | return crate::error::AppError::Internal(anyhow::anyhow!("no session layer")) | |
| 456 | + | .into_response(); | |
| 457 | + | }; | |
| 458 | + | match crate::auth::authenticate(&session, &app).await { | |
| 459 | + | Ok(_) => next.run(request).await, | |
| 460 | + | Err(refusal) => refusal.into_response(), | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 355 | 464 | /// Every described screen a reader with no session can reach, mounted. | |
| 356 | 465 | /// | |
| 357 | 466 | /// Separate from [`mounts`] because of the factory, not because of the address: | |
| @@ -541,11 +650,29 @@ | |||
| 541 | 650 | ); | |
| 542 | 651 | } | |
| 543 | 652 | ||
| 653 | + | #[test] | |
| 654 | + | fn every_document_screen_is_listed_in_document_paths() { | |
| 655 | + | let source = include_str!("mod.rs"); | |
| 656 | + | let mounted = source | |
| 657 | + | .split_once("pub fn document_mounts(") | |
| 658 | + | .expect("document_mounts exists") | |
| 659 | + | .1 | |
| 660 | + | .split_once("\n}") | |
| 661 | + | .expect("document_mounts ends") | |
| 662 | + | .0; | |
| 663 | + | assert_eq!( | |
| 664 | + | mounted.matches("document_mount(").count(), | |
| 665 | + | DOCUMENT_PATHS.len(), | |
| 666 | + | "document_mounts and DOCUMENT_PATHS describe the same screens" | |
| 667 | + | ); | |
| 668 | + | } | |
| 669 | + | ||
| 544 | 670 | #[test] | |
| 545 | 671 | fn a_path_is_claimed_by_exactly_one_screen() { | |
| 546 | 672 | // Two screens on one address is an axum panic at startup, and the two | |
| 547 | 673 | // forum-memberships screens are the near miss: one module, two paths. | |
| 548 | 674 | let mut seen = PATHS.to_vec(); | |
| 675 | + | seen.extend_from_slice(DOCUMENT_PATHS); | |
| 549 | 676 | seen.sort_unstable(); | |
| 550 | 677 | let before = seen.len(); | |
| 551 | 678 | seen.dedup(); |