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/`.
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
Reference in New Issue
Block a user