"""Unit: the History page's list pager (phase 175, task 02). TODO.md L3 (owner 2026-10-10): "Need pagination on the history page - it gets long fast" — the saved-chat list is now paginated SERVER-side (task 01: ``GET /api/chats`` takes ``?limit=`` / ``?offset=`` and reports the full ``total`` on every response), and this module pins the view's pager — the ``.history-pager`` control under the table card: Prev / "Page N of M" / Next, reusing the ``.history-refresh`` pill language (D3: NO new palette variable, B3; no new spacing token) and walking the pages through the re-entrant load (D4: refresh-keeps-current-page, A5; the anonymous gate untouched, D5; the ≤640px full-width block, D6). This module pins the source-level contract a silent regression would break; the browser behavior (the 30-row page walk, the status lines, the anonymous request log, the mobile pin) is E2E-gated by the phase-175 story suite (``tests/e2e/test_history_pagination.py``, task 03). Pinned: * ``index.html``: ``#history-pager`` sits in ``#view-history`` and AFTER ``#history-table-wrap`` (the pager is under the table card, not in the page-head — a direct child of the ``.history-shell`` container, following the table card), both buttons carry their exact ``aria-label``s, both inline-SVG chevrons carry ``aria-hidden="true"``, the visible "Prev" / "Next" labels ship, and the div ships ``hidden`` (the hidden-when-single-page default the JS flips); * ``styles.css``: the ``.history-pager`` rule carries ``display: flex`` + ``gap: 0.75rem`` (the page's OWN 0.75rem — no new spacing token); ``.history-pager-btn`` is covered by the pill / ``:disabled`` / svg-sizing declarations (the ``.history-refresh`` pill language — pinned by PRESENCE in the declarations, not by a particular CSS authoring choice); the ≤640px block takes the pager buttons full width (the ``.history-refresh`` mobile idiom) and stacks the row (the ``.settings-actions`` idiom); and NO new token: every ``var(--x)`` used in any pager rule is already declared in the file's ``:root`` block (the B3 pin); * ``history.js``: the parameterized fetch URL (the manual ``/api/chats?limit=…&offset=…`` join — the file's house style), the ``total`` read from the JSON body, ``PAGE_SIZE = 25`` (the server's default — A1, no size picker in the UI; the E2E suite catches a drift), the hidden-when-single-page branch (``total <= PAGE_SIZE``), the two ``disabled`` edge lines, the ``Showing – of .`` status-line format (the page-change line AND the first-load line — the settled load announces its own page while the pager is visible, task 03), the refresh path passing the current ``offset`` (A5 — a page-2 refresh never resets to page 1), the local Delete leaving the pager and the offset alone (D4 — the next load corrects it), the pager lookups scoped to ``root``, and the D5 pin: the anonymous branch never touches the pager (it keeps its markup-shipped hidden state and never fetches). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" SHELL_HTML = FRONTEND / "index.html" STYLES_CSS = FRONTEND / "assets" / "styles.css" HISTORY_JS = FRONTEND / "assets" / "history.js" def _read(path: Path) -> str: return path.read_text(encoding="utf-8") def _strip_comments(text: str, kind: str) -> str: """The text without its comments — a comment's tag-like / rule-like prose (an ```` in the markup, a ``/* … */`` between CSS rules) must not leak into the tag / selector walk.""" if kind == "html": return re.sub(r"", "", text, flags=re.S) return re.sub(r"/\*.*?\*/", "", text, flags=re.S) def _tag_span(html: str, open_tag: str, tag: str) -> str: """The substring from an opening to its matching (balanced tag counting — the test_git_sources_card_rhythm.py brace-counting helper, applied to a named tag; comments are stripped first so a comment's tag-like prose can't unbalance the count).""" html = _strip_comments(html, "html") start = html.index(open_tag) depth = 0 for m in re.finditer(rf"<{tag}\b|", html[start:]): if m.group(0) == f"": depth -= 1 if depth == 0: return html[start : start + m.end()] else: depth += 1 raise AssertionError(f"unbalanced {tag}s from {open_tag!r}") def _direct_children_tags(html: str) -> list[str]: """The element tags that open at DEPTH 1 (direct children) of the substring (the leading
is depth 0 — the counted element). Void elements (input/br/img/… — this markup uses non-self-closing ) change no depth.""" VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr"} html = _strip_comments(html, "html") out: list[str] = [] depth = 1 # we are inside the counted div for m in re.finditer(r"]*?>", html): tag = m.group(0) mname = re.match(r""): if depth == 1: out.append(tag) depth += 1 return out def _css_rules(css: str) -> list[tuple[str, str]]: """Every (selector-list, body) pair in the file — top-level AND inside @media blocks alike (the small-regex house style, with a single brace-walk so the ≤640px block's inner rules are pinned too). Selector lists and bodies are whitespace-normalized.""" out: list[tuple[str, str]] = [] def walk(text: str) -> None: i = 0 n = len(text) while i < n: brace = text.find("{", i) if brace == -1: return # the selector list is the slice's NON-comment text (a # /* … */ comment between rules must not leak into it — # it would mask the @media at-rule and pollute the # selector): selector = " ".join( re.sub(r"/\*.*?\*/", " ", text[i:brace], flags=re.S).split() ) depth = 1 j = brace + 1 while j < n and depth: if text[j] == "{": depth += 1 elif text[j] == "}": depth -= 1 j += 1 body = re.sub(r"/\*.*?\*/", " ", text[brace + 1 : j - 1], flags=re.S) if selector.startswith("@media"): walk(body) elif selector: out.append((selector, " ".join(body.split()))) i = j walk(css) return out def _root_block(css: str) -> str: """The file's :root block (balanced-brace slice).""" m = re.search(r"^:root\s*\{", css, re.M) assert m is not None, "the :root block must exist" start = m.end() - 1 depth = 0 for i in range(start, len(css)): if css[i] == "{": depth += 1 elif css[i] == "}": depth -= 1 if depth == 0: return css[start : i + 1] raise AssertionError("unbalanced :root block") def _js() -> str: return _read(HISTORY_JS) def _fn(js: str, name: str) -> str: """The source of the first ``function `` in history.js (brace-balanced — the test_history_page.py house helper; since phase 76 task 03 the functions live inside ``mount(root)``).""" fn = js.find(f"function {name}") assert fn != -1, f"{name} must be defined in history.js" open_idx = js.find("{", fn) depth = 0 for i in range(open_idx, len(js)): c = js[i] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return js[fn : i + 1] raise AssertionError(f"unbalanced braces in {name}") # ---------- index.html: the pager's structure ---------- def test_pager_div_sits_below_the_table_card_in_the_view() -> None: """D3 (the structural pin): #history-pager sits in #view-history and AFTER #history-table-wrap — a direct child of the .history-shell container, following the table card (the pager is under the table, not in the page-head). If a future refactor moves the pager to the page-head, this pin names it.""" html = _read(SHELL_HTML) view = _tag_span( html, '
view.index(wrap_open), ( "the pager must sit AFTER the table card (under it, not in " "the page-head)" ) # A direct child of the .history-shell container, after the wrap: shell_open = '
' assert shell_open in view, "the .history-shell container must be in the view" shell = _tag_span(view, shell_open, "div")[len(shell_open):] children = _direct_children_tags(shell) pagers = [t for t in children if 'id="history-pager"' in t] wraps = [t for t in children if 'id="history-table-wrap"' in t] assert len(pagers) == 1, ( f"exactly ONE #history-pager direct child of the shell: {children}" ) assert len(wraps) == 1, "exactly ONE #history-table-wrap direct child" assert children.index(pagers[0]) > children.index(wraps[0]), ( "the pager must follow the table card among the shell's children" ) def test_pager_buttons_carry_their_aria_labels_and_hidden_svgs() -> None: """D3: both buttons carry their EXACT aria-labels (the accessible name the mobile squeeze relies on — the label is visible at every width, the aria-label keeps it in both), both inline-SVG chevrons carry aria-hidden="true" (decorative — the accessible name is the label's), and the visible "Prev" / "Next" labels ship (the house inline-SVG glyph + visible label + aria-label).""" html = _read(SHELL_HTML) pager = _tag_span( html, '
Prev', pager) assert re.search(r'Next', pager) def test_pager_div_ships_hidden_with_an_empty_status() -> None: """A4: the div SHIPS hidden — the hidden-when-single-page default; history.js's updatePager() is the only thing that reveals it (and only while total > PAGE_SIZE). A markup without `hidden` would show a dead "Page 1 of 1" control on a one-page list. The status span ships empty (the JS writes "Page N of M").""" html = _read(SHELL_HTML) m = re.search(r'
]*id="history-pager"[^>]*>', html) assert m is not None, "the #history-pager div must ship in the markup" tag = m.group(0) assert " hidden" in tag or tag.rstrip(">").endswith("hidden"), ( f"the pager div must ship hidden: {tag}" ) assert ( '' in html ), "the status span ships empty (the JS writes `Page N of M`)" # ---------- styles.css: the pager's rules ---------- def test_pager_rule_is_the_flex_row_with_the_page_gap() -> None: """D3: the .history-pager row rule — display: flex, align-items: center, gap: 0.75rem (the page's OWN 0.75rem — the #view-history .page-head row gap; any other gap value is a new spacing token and fails — no new spacing value).""" rules = _css_rules(_read(STYLES_CSS)) row = [b for s, b in rules if s == ".history-pager"] assert row, "the top-level .history-pager row rule must exist" body = row[0] assert "display: flex" in body, body assert "align-items: center" in body, body gaps = re.findall(r"gap\s*:\s*([^;]+)", body) assert gaps == ["0.75rem"], f"gap regression (a new spacing token): {gaps}" def test_pager_buttons_reuse_the_refresh_pill_declarations() -> None: """D3 (the B3 pin's structural half): .history-pager-btn is covered by the pill / :disabled / svg-sizing declarations — the .history-refresh pill language (pinned by PRESENCE in the declarations, not by a particular CSS authoring choice — the selector union or a duplication both pass).""" rules = _css_rules(_read(STYLES_CSS)) # The pill (the desktop .history-refresh pill language — the # brand fill, the ≥44px target, the 999px shape, the borderless # solid pill): pill = [ b for s, b in rules if ".history-pager-btn" in s and ":disabled" not in s and "svg" not in s and ":hover" not in s ] assert pill, "the pill rule must carry .history-pager-btn" all_pill = " ; ".join(pill) for decl in ( "display: inline-flex", "min-height: 44px", "border-radius: 999px", "border: 0", "background: var(--brand)", "color: var(--bg)", ): assert decl in all_pill, f"the pill declaration is missing: {decl}" # The :disabled idiom (the in-flight dimming — the Refresh idiom; # the Prev/Next edges use the real `disabled` attribute): dis = [b for s, b in rules if ".history-pager-btn:disabled" in s] assert dis, "the :disabled rule must carry .history-pager-btn:disabled" dis_all = " ; ".join(dis) assert "opacity: 0.6" in dis_all and "cursor: wait" in dis_all, dis_all # The svg sizing (the glyph hidden on desktop — visible in the # ≤640px block; the .history-refresh idiom): svg = [b for s, b in rules if ".history-pager-btn svg" in s] assert svg, "the svg-sizing rule must carry .history-pager-btn svg" assert any("width: 16px" in b and "height: 16px" in b for b in svg), svg def test_le640_pager_buttons_go_full_width_and_the_row_stacks() -> None: """D6: the ≤640px block — the pager buttons join the .history-refresh full-width idiom (width: 100% + the svg glyph visible), and the row STACKS (the .settings-actions idiom) so the two full-width pills don't side-by-side overflow the phone width (the pill rule's flex-shrink: 0 would keep each at 100%).""" rules = _css_rules(_read(STYLES_CSS)) fw = [b for s, b in rules if ".history-pager-btn" in s and "width: 100%" in b] assert fw, "the ≤640px block must take .history-pager-btn full width" svg = [ b for s, b in rules if ".history-pager-btn svg" in s and "display: block" in b ] assert svg, ( "the ≤640px block must show the pager glyph (the .history-refresh " "svg idiom)" ) stack = [ b for s, b in rules if s == ".history-pager" and "flex-direction: column" in b ] assert stack, ( "the ≤640px block must stack the pager row (the full-width " "pills must not side-by-side overflow the phone width)" ) def test_pager_rules_introduce_no_new_tokens() -> None: """B3: no new palette variable — every var(--x) used in ANY pager rule (the row rule, the pill union, the :disabled / svg rules — desktop AND the ≤640px block) is already declared in the file's :root block. (The row rule itself uses no tokens — the gap is a plain 0.75rem — but the pin covers the union declarations too, so a future `var(--new)` rides no pager change undetected.)""" css = _read(STYLES_CSS) declared = set(re.findall(r"(--[a-z0-9-]+)\s*:", _root_block(css))) used: set[str] = set() for s, b in _css_rules(css): if "history-pager" in s: used.update(re.findall(r"var\(\s*(--[a-z0-9-]+)\s*\)", b)) missing = used - declared assert not missing, ( f"pager rules use undeclared tokens (a new palette variable, B3): " f"{missing}" ) # ---------- history.js: the pager's load contract ---------- def test_fetch_is_parameterized_with_limit_and_offset() -> None: """D4: loadChats(pageOffset) fetches the 25-row page at the given offset — the manual string join (the file's house style) — and reads the body's `total` (the server's full count) into the pager (updatePager, on the 200 path). The failed-load branches keep the house error lines + the empty state (a failed load leaves the pager as it was — the previous state is the last known truth).""" js = _js() assert "async function loadChats(pageOffset)" in js, ( "the load must be parameterized on the page's offset" ) load = _fn(js, "loadChats") assert re.search( r'fetch\("/api/chats\?limit="\s*\+\s*PAGE_SIZE\s*\+\s*"&offset="\s*\+\s*pageOffset\)', load, ), "the fetch must be the manual /api/chats?limit=…&offset=… join" assert re.search(r"const \{ chats, total \} = await r\.json\(\);", load), ( "the 200 path must read `total` from the JSON body" ) assert "updatePager(total)" in load, ( "the 200 path must run the pager on the fresh total" ) # The failure branches keep the house error lines (unchanged): assert "Couldn't load saved chats — is the app reachable?" in load assert "Couldn't load saved chats — try again." in load assert load.count("showEmptyState()") == 3, ( "network failure, non-2xx and a 0-row list all show the empty state" ) def test_settled_load_lands_its_own_showing_line_for_the_visible_pager() -> None: """D4 (task 03, the first-load half of the never-stale contract): a settled load announces its own page — the mount is fire-and-forget (the house-style ``loadChats(0);`` line), so the announcement lives in ``loadChats`` itself, where the settled ``total`` is in hand. It lands ONLY while the pager is visible (total > PAGE_SIZE): a one-page list IS the whole list (A4 — no redundant line) and the 0-row branch shows the empty state (nothing to show).""" js = _js() fn = _fn(js, "loadChats") assert re.search(r"if \(total > PAGE_SIZE\)", fn), ( "the first-load line must land only while the pager is " "visible (A4 — no redundant line)" ) assert re.search( r'"Showing "\s*\+\s*\(pageOffset \+ 1\)\s*\+\s*"–"', fn ), "the line starts `Showing –` (the en-dash, the settled page)" assert re.search(r"Math\.min\(pageOffset \+ PAGE_SIZE, total\)", fn), ( "the last row of the page is min(pageOffset + PAGE_SIZE, total)" ) assert re.search(r'" of "\s*\+\s*total\s*\+\s*"\."', fn), ( "the line ends ` of .`" ) def test_pager_state_is_the_25_row_page_and_the_offset() -> None: """A1: PAGE_SIZE = 25 matches the server's default page (no size picker in the UI — the E2E suite catches a drift); the module keeps the current page's offset (it starts at 0 — the view opens on page 1).""" js = _js() assert "const PAGE_SIZE = 25;" in js, "PAGE_SIZE must be the 25-row page" assert "let offset = 0;" in js, "the module keeps the current page's offset" def test_update_pager_hides_when_single_page_and_edges_disable() -> None: """D3/A4: updatePager — `total <= PAGE_SIZE` → the pager is hidden (no dead "Page 1 of 1" control; it disappears with the empty state); otherwise it shows, the status reads `Page N of M`, Prev is disabled on page 1, Next on the last page (the real `disabled` attribute — the Refresh idiom; the edges make the click clamping trivial).""" js = _js() fn = _fn(js, "updatePager") assert "Math.ceil(total / PAGE_SIZE)" in fn, "the page count is the ceil" assert "total <= PAGE_SIZE" in fn, "the hidden-when-single-page branch" assert "pagerEl.hidden = true" in fn, "the branch hides the pager" assert "pagerEl.hidden = false" in fn, "the multi-page state shows it" assert re.search( r'"Page "\s*\+\s*\(offset / PAGE_SIZE \+ 1\)\s*\+\s*" of "\s*\+\s*pages', fn, ), "the status reads `Page N of M`" # The two disabled edge lines: assert "pagerPrevBtn.disabled = offset === 0" in fn, ( "Prev is disabled on page 1" ) assert "pagerNextBtn.disabled = offset + PAGE_SIZE >= total" in fn, ( "Next is disabled on the last page" ) def test_page_change_lands_the_showing_line_in_the_live_region() -> None: """D4 (the never-stale contract): a page change re-runs the re-entrant load (rows are REPLACED, never appended) and, on success, lands `Showing – of .` in #history-status (the existing live region — announce()). A failed load keeps the pager (no Showing line; loadChats' own error line is the feedback).""" js = _js() fn = _fn(js, "changePage") assert "loadChats(offset)" in fn, "the change re-runs the re-entrant load" assert re.search(r'"Showing "\s*\+\s*\(offset \+ 1\)\s*\+\s*"–"', fn), ( "the line starts `Showing –` (the en-dash)" ) assert "Math.min(offset + PAGE_SIZE, total)" in fn, ( "the last row of the page is min(offset + PAGE_SIZE, total)" ) assert re.search(r'" of "\s*\+\s*total\s*\+\s*"."', fn), ( "the line ends ` of .`" ) assert "announce(" in fn, "the line lands in the live region (announce)" def test_refresh_and_resend_keep_the_current_page_and_boot_on_page_1() -> None: """A5: refreshChats re-fetches the CURRENT page — the module's offset, never 0 (a page-2 refresh must land page-2 rows); the re-show listener does the same; the mount boots on page 1 (offset 0). The Prev/Next handlers move the offset by PAGE_SIZE (the disabled edges make clamping trivial).""" js = _js() fn = _fn(js, "refreshChats") assert "loadChats(offset)" in fn, "refresh re-fetches the CURRENT page" assert "loadChats(0)" not in fn, "refresh must never reset to page 1" assert re.search(r"if \(started\)\s+loadChats\(offset\)", js), ( "the re-show listener re-fetches the CURRENT page (A5)" ) assert re.search(r"^\s*loadChats\(0\);", js, re.M), ( "the mount boots on page 1 (the view opens on page 1)" ) assert "offset -= PAGE_SIZE" in js, "Prev moves the offset back a page" assert "offset += PAGE_SIZE" in js, "Next moves the offset forward a page" def test_pager_lookups_are_scoped_to_root() -> None: """Phase 76's view-module contract: every DOM lookup scopes to root (the view's section) — the four pager elements included (the view ids stay unique across the shell; the scoped lookups keep the module honest and testable).""" js = _js() for sel in ( 'root.querySelector("#history-pager")', 'root.querySelector("#history-pager-prev")', 'root.querySelector("#history-pager-next")', 'root.querySelector("#history-pager-status")', ): assert sel in js, f"the pager lookup must be scoped to root: {sel}" def test_pager_is_bound_in_the_admin_branch_only() -> None: """D5: the whoami gate is unchanged — the anonymous branch hides the table, shows the gate, hides Refresh, and RETURNS without touching the pager (it keeps its markup-shipped hidden state) and without a /api/chats request (the E2E pins the request log). The Prev/Next bindings — like Refresh's — are armed ONLY after the gate passes, in the admin branch.""" js = _js() gate_i = js.find("if (!(await fetchIsAdmin()))") assert gate_i != -1, "the whoami gate must run in mount" branch = js[gate_i : js.find("return;", gate_i)] for name in ("pagerEl", "pagerPrevBtn", "pagerNextBtn", "pagerStatusEl"): assert name not in branch, ( f"the anonymous branch must not touch {name} (D5 — the pager " "keeps its markup-shipped hidden state)" ) assert "fetch(" not in branch, ( "the anonymous branch must not fetch anything" ) # The bindings are armed in the admin branch (after the return): after = js[js.find("return;", gate_i):] assert "pagerPrevBtn.addEventListener" in after, ( "Prev is bound in the admin branch only (the same gate as Refresh)" ) assert "pagerNextBtn.addEventListener" in after, ( "Next is bound in the admin branch only (the same gate as Refresh)" ) assert "loadChats(0);" in after, "the admin path boots the list on page 1" def test_local_delete_does_not_touch_the_pager_or_the_offset() -> None: """D4: a local Delete removes the row (the empty state reappears when it was the last one) but does NOT recompute the pager or the offset — the pager's `total` stays the server's last truth; the next load (a page click or a Refresh) corrects it.""" js = _js() fn = _fn(js, "confirmDelete") for name in ( "pagerEl", "pagerPrevBtn", "pagerNextBtn", "pagerStatusEl", "offset", "updatePager", ): assert name not in fn, ( f"confirmDelete must not touch {name} (D4 — the next load " "corrects the pager)" )