Files
brain-of-reese/tests/unit/test_history_pagination.py
T
ducoterra 375169bbfc
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 175_history_pagination
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/`.
2026-10-11 04:12:53 -04:00

594 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)"
)