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/`.
594 lines
25 KiB
Python
594 lines
25 KiB
Python
"""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)"
|
||
)
|