phase: 175_history_pagination
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.

**Phase 175 final verification pass — report**

- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
This commit is contained in:
2026-10-11 04:12:53 -04:00
parent 938d6cf09d
commit 375169bbfc
31 changed files with 2339 additions and 61 deletions
+36 -18
View File
@@ -516,11 +516,12 @@ def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None:
def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
"""Phase 77: the History view re-fetches on a user-initiated
re-show — history.js registers a ``bor:view-refresh`` listener on
the view's root that re-runs the (now re-entrant) ``loadChats()``.
The listener is armed only AFTER the whoami gate passes: anonymous
shows the gate and never fetches (the phase-50 contract the story
E2E pins), and the ``started`` flag means the listener can only
re-run a load the mount already made."""
the view's root that re-runs the (now re-entrant) load. Phase 175:
the re-show re-fetches the CURRENT page — ``loadChats(offset)``,
never 0 (A5). The listener is armed only AFTER the whoami gate
passes: anonymous shows the gate and never fetches (the phase-50
contract the story E2E pins), and the ``started`` flag means the
listener can only re-run a load the mount already made."""
history_js = (ASSETS / "history.js").read_text(encoding="utf-8")
assert 'addEventListener("bor:view-refresh"' in history_js, (
"history.js must listen for the refresh event on the view root"
@@ -530,20 +531,21 @@ def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
assert 0 <= gate < listener, (
"the listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), (
"the listener is gated on the first load (started)"
assert re.search(r"if \(started\)\s+loadChats\(offset\)", history_js), (
"the listener is gated on the first load (started) — and "
"re-fetches the CURRENT page (phase 175, A5: never resets to 0)"
)
# Re-entrancy: a re-load drops the data rows (except the hidden
# empty-state row) before fetching — the list is replaced, not
# duplicated.
load = history_js.find("async function loadChats()")
load = history_js.find("async function loadChats(pageOffset)")
assert load != -1, "loadChats must exist"
load_body = history_js[load : history_js.find("\n }", load)]
assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, (
"loadChats must remove the data rows (the empty row stays) first"
)
clear_i = load_body.find("tr !== emptyRow")
fetch_i = load_body.find('fetch("/api/chats")')
fetch_i = load_body.find('fetch("/api/chats?limit="')
assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch"
@@ -763,12 +765,15 @@ def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None:
fn = js.find("async function refreshChats()")
assert fn != -1, "refreshChats must exist"
fn_body = js[fn:js.find("\n }", fn)]
assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load"
assert "loadChats(offset)" in fn_body, (
"the button re-runs the (re-entrant) load — the CURRENT page "
"(phase 175, A5: a page-2 refresh never resets to page 1)"
)
assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, (
"the button re-enables on success AND failure (the finally)"
)
assert '"Saved chats refreshed."' in fn_body, "the exact success line"
load = js.find("async function loadChats()")
load = js.find("async function loadChats(pageOffset)")
load_body = js[load:js.find("\n }", load)]
assert "Couldn't load saved chats — is the app reachable?" in load_body, (
"the network-error line lives in loadChats (every caller sees it)"
@@ -788,20 +793,33 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
The page-head flex row is SCOPED to #view-history (the other four
views' page-heads are untouched). Below 640px the pill goes full
width with the label visible (the icon-only squeeze is gone —
the glyph joins the label)."""
the glyph joins the label). Phase 175: the pager buttons join
the rule (the selector union — this pin names the union shape,
so the pill language stays pinned on .history-refresh itself)."""
css = _asset("styles.css")
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
block = re.search(
r"\.history-refresh(?:,\s*\.history-pager-btn)? \{([\s\S]*?)\n\}", css
)
assert block, "styles.css must style .history-refresh"
body = block.group(1)
assert "background: var(--brand)" in body, "the .new-chat-btn brand fill"
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
assert "min-height: 44px" in body, "the comfortable touch target"
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
assert (
".history-refresh:hover, .history-pager-btn:hover {"
" background: var(--brand-hover); color: var(--bg); }"
) in css
assert (
".history-refresh:disabled, .history-pager-btn:disabled {"
" opacity: 0.6; cursor: wait; }"
) in css, (
"the in-flight disabled state is dimmed (the house language)"
)
assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, (
assert (
".history-refresh svg, .history-pager-btn svg {"
" width: 16px; height: 16px; display: none; }"
) in css, (
"desktop: the label carries the pill (the glyph is hidden)"
)
row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css)
@@ -811,10 +829,10 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".history-refresh { width: 100%; }" in mbody, (
assert ".history-refresh, .history-pager-btn { width: 100%; }" in mbody, (
"full width below 640px (the icon-only squeeze is gone)"
)
assert ".history-refresh svg { display: block; }" in mbody, (
assert ".history-refresh svg, .history-pager-btn svg { display: block; }" in mbody, (
"the glyph joins the visible label below 640px"
)
+10 -6
View File
@@ -325,14 +325,17 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
zero extra), hides the table, shows the gate, and RETURNS — no
``/api/chats`` request on the wire (the router 403s anonymous; the
story E2E pins the request log). Only the admin path reaches
``loadChats()``. The single ``fetch("/api/chats")`` in the file
lives in loadChats."""
``loadChats(pageOffset)``. The single list fetch in the file —
phase 175's parameterized form (``fetch("/api/chats?limit=" +
PAGE_SIZE + "&offset=" + pageOffset)``) — lives in loadChats."""
js = _js()
assert js.count('fetch("/api/chats")') == 1, (
assert js.count('fetch("/api/chats?limit="') == 1, (
"exactly ONE list fetch — the anonymous path must never add one"
)
load = _fn(js, "loadChats")
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
assert (
'fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset)' in load
), "the parameterized list fetch lives in loadChats (phase 175)"
# Phase 76 (task 03): the view module never boots the shell's
# header — no CALL to the header boot (a docstring may name it;
@@ -359,10 +362,11 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The admin path: the gate hides, then the list loads.
# The admin path: the gate hides, then the list loads on page 1
# (phase 175: the view opens on page 1 — offset 0).
after = js[js.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "loadChats();" in after
assert "loadChats(0);" in after
def test_admin_load_renders_rows_or_empty_state() -> None:
+593
View File
@@ -0,0 +1,593 @@
"""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 <first>–<last> of <total>.`` 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 ``<!-- … <id> … -->`` 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 <tag …> to its matching </tag>
(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|</{tag}>", html[start:]):
if m.group(0) == f"</{tag}>":
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 <div> is depth 0 — the counted element).
Void elements (input/br/img/… — this markup uses non-self-closing
<input …>) 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"</?[a-zA-Z][^>]*?>", html):
tag = m.group(0)
mname = re.match(r"</?([a-zA-Z]+)", tag)
name = mname.group(1).lower() if mname else ""
if tag.startswith("</"):
depth -= 1
if depth == 0:
break
elif name not in VOID and not tag.endswith("/>"):
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 <name>`` 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, '<section class="view" id="view-history"', "section"
)
wrap_open = (
'<div class="table-wrap history-table-wrap" id="history-table-wrap"'
)
assert wrap_open in view, "the table card must be in the History view"
pager_open = '<div class="history-pager" id="history-pager"'
assert pager_open in view, "the pager must be in the History view"
assert view.index(pager_open) > 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 = '<div class="container history-shell">'
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, '<div class="history-pager" id="history-pager"', "div"
)
assert pager.count('aria-label="Previous page of saved chats"') == 1, (
"the Prev button's exact aria-label (once)"
)
assert pager.count('aria-label="Next page of saved chats"') == 1, (
"the Next button's exact aria-label (once)"
)
assert 'class="history-pager-icon"' in pager, "the house inline-SVG glyph"
assert pager.count('aria-hidden="true"') == 2, (
"both SVG chevrons are aria-hidden (decorative)"
)
assert re.search(r'<span class="history-pager-label">Prev</span>', pager)
assert re.search(r'<span class="history-pager-label">Next</span>', 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'<div [^>]*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 (
'<span class="history-pager-status" id="history-pager-status"></span>'
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 <first>–` (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 <total>.`"
)
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 <first>–<last> of <total>.` 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 <first>–` (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 <total>.`"
)
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)"
)