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:
@@ -0,0 +1,700 @@
|
||||
"""Phase 175 E2E (Playwright): the History page's list pager.
|
||||
|
||||
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 (``GET /api/chats`` takes ``?limit=`` (default 25,
|
||||
clamped 1–100) / ``?offset=`` and reports the full ``total`` on
|
||||
every response), and the view walks it through the ``.history-pager``
|
||||
control under the table card (Prev / "Page N of M" / Next — the
|
||||
``.history-refresh`` pill language; hidden while the list is a single
|
||||
page, A4; Refresh keeps the current page, A5; the anonymous gate is
|
||||
untouched, D5; the ≤640px full-width block, D6).
|
||||
|
||||
Run in isolation (the DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_history_pagination.py -v --no-cov
|
||||
|
||||
The contract under test:
|
||||
|
||||
* **page 1 of 2 (a 30-row fixture)** — the table holds 25 rows (the
|
||||
25-row page at offset 0), the pager is visible, ``#history-pager-
|
||||
status`` reads "Page 1 of 2", Prev ``disabled`` / Next enabled, and
|
||||
the live region carries "Showing 1–25 of 30." (the never-stale
|
||||
first-load line — the settled load announces its own page);
|
||||
* **Next → page 2** — the 5 remaining rows (rows are REPLACED, never
|
||||
appended — the 25 page-1 rows are gone), "Page 2 of 2", Next
|
||||
``disabled`` / Prev enabled, "Showing 26–30 of 30.";
|
||||
* **Prev → page 1** — back to 25 rows, "Page 1 of 2", Prev
|
||||
``disabled``, "Showing 1–25 of 30.";
|
||||
* **Refresh on page 2** — re-fetches page 2 (A5 — never resets to
|
||||
page 1): still 5 rows + "Page 2 of 2" and "Saved chats
|
||||
refreshed." lands;
|
||||
* **delete-to-empty on the last page** — the inline two-step confirm
|
||||
(the owner-locked no-native-dialog pin — the "Delete? [Yes] [No]"
|
||||
pair appearing in place is the contract) deletes all five page-2
|
||||
rows: the empty state appears, and the pager is NOT recomputed on
|
||||
the local delete (D4 — it still reads "Page 2 of 2", the last
|
||||
known truth); the next load (Prev) corrects it with the fresh
|
||||
``total`` (25 → single page → the pager hides, A4);
|
||||
* **single page** — a 10-row list: all ten rows, the pager fully
|
||||
hidden (A4 — no dead "Page 1 of 1" control);
|
||||
* **anonymous** — the gate shows, the table AND the pager stay
|
||||
hidden, and NO ``GET /api/chats`` request appears in the log (the
|
||||
house request-log pin — the router 403s them);
|
||||
* **≤640px** — the pager row stacks and the buttons go full width
|
||||
(the ``.history-refresh`` mobile idiom, D6) while the table still
|
||||
scrolls inside its card.
|
||||
|
||||
DB isolation: the pagination pins are EXACT (25 of 30, 10 of 10,
|
||||
"Page N of M"), so every test starts from an EMPTY ``saved_chats``
|
||||
(the ``test_history_page_width.py`` TRUNCATE house precedent — the
|
||||
state is under test, not a byproduct; the KB tables are never
|
||||
touched — the suite never drives the chat page). Fixture rows are
|
||||
created through the PUBLIC ``POST /api/chats`` write surface with
|
||||
DISTINCTIVE auto-titles, re-Saved (``PUT /api/chats/<id>`` — the
|
||||
public upsert, ``onupdate`` bumps ``updated_at``) in a controlled
|
||||
loop (a short sleep between bumps — Postgres' ``now()`` is µs-
|
||||
resolution, the HTTP round-trip ms; the sleep makes the
|
||||
``updated_at desc`` order FULLY deterministic) so the last re-Saved
|
||||
chat is row 1, and they are deleted in a ``finally`` (admin cookie —
|
||||
the ``test_chat_history.py`` idiom). The order is pinned by the first
|
||||
row of page 1 (the last re-Saved) and the first / last rows of the
|
||||
last page (the earliest re-Saved) — never the full middle order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_saved_chats(db_ready: None) -> None:
|
||||
"""Every test here pins an EXACT total (25 of 30, 10 of 10 —
|
||||
"Page N of M"), so the shared dev DB's leftover rows from other
|
||||
suites would break it: each test starts from an EMPTY
|
||||
``saved_chats`` (the ``test_history_page_width.py`` TRUNCATE
|
||||
house precedent — the state is under test, not a byproduct).
|
||||
The KB tables are never touched — the suite never drives the
|
||||
chat page."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture helpers (the test_chat_history.py DB-isolation idiom, adapted
|
||||
# to a known-total fixture: the suite's pins are exact, so the rows
|
||||
# are created through the public API with distinctive auto-titles,
|
||||
# order-controlled, and deleted in a finally)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _title(i: int, n: int) -> str:
|
||||
"""The fixture row's distinctive auto-title (the first user
|
||||
message — the auto-title convention: whitespace-collapsed,
|
||||
120-char cap)."""
|
||||
return f"Pagination probe {i} of {n} — what is the capital of France?"
|
||||
|
||||
|
||||
def _make_chat(app_url: str, title: str) -> str:
|
||||
"""POST /api/chats (the PUBLIC write surface, phase 55): the
|
||||
auto-title IS the first user message, so the title goes in as
|
||||
the question; the minimal house conversation shape (one user +
|
||||
one brain reply)."""
|
||||
r = httpx.post(
|
||||
f"{app_url}/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{"who": "user", "text": title},
|
||||
{"who": "brain", "text": "That's a good question."},
|
||||
]
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _re_save(app_url: str, chat_id: str, title: str) -> None:
|
||||
"""PUT /api/chats/<id> (the public re-Save upsert): a full
|
||||
message replacement that bumps ``updated_at``
|
||||
(``onupdate=func.now()``) — the controlled order bump."""
|
||||
r = httpx.put(
|
||||
f"{app_url}/api/chats/{chat_id}",
|
||||
json={
|
||||
"messages": [
|
||||
{"who": "user", "text": title},
|
||||
{"who": "brain", "text": "That's a good question."},
|
||||
]
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _make_chats(app_url: str, n: int) -> list[str]:
|
||||
"""n fixture conversations (distinctive auto-titles), then the
|
||||
controlled re-Save loop — a short sleep between bumps makes the
|
||||
``updated_at desc`` order FULLY deterministic, so the last
|
||||
re-Saved chat is row 1. Returns the ids in re-Save order (index
|
||||
0 = the OLDEST = the last row of the last page)."""
|
||||
ids: list[str] = []
|
||||
for i in range(1, n + 1):
|
||||
ids.append(_make_chat(app_url, _title(i, n)))
|
||||
for i, chat_id in enumerate(ids, start=1):
|
||||
time.sleep(0.02)
|
||||
_re_save(app_url, chat_id, _title(i, n))
|
||||
return ids
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form
|
||||
login — used to call the admin API with plain httpx (the
|
||||
``test_chat_history.py`` idiom)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _delete_all(app_url: str, cookies: dict[str, str], ids: list[str]) -> None:
|
||||
"""Best-effort row cleanup (a 403 — not signed in yet — or a 404
|
||||
— already deleted — is fine; the next test's TRUNCATE covers
|
||||
any leak)."""
|
||||
for chat_id in ids:
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _settle_admin_history(page: Page, app_url: str, rows: int) -> None:
|
||||
"""Real form login onto /history.html + wait for the view's
|
||||
first load to have settled: the N data rows (the title links)
|
||||
IS the settle signal — the pager's state and the first-load
|
||||
"Showing" line land in the same settled load (updatePager runs
|
||||
before the row append; the announcement after it)."""
|
||||
login(page, app_url, next="/history.html")
|
||||
expect(page).to_have_url(app_url + "/history.html", timeout=30_000)
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(rows, timeout=15_000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contrast helpers (copied from test_responsive_polish.py — suites
|
||||
# stay self-contained; only auth_helpers is imported)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _rgb(value: str) -> tuple[int, int, int]:
|
||||
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
|
||||
assert match, f"unparsable color: {value!r}"
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
|
||||
def chan(c: int) -> float:
|
||||
s = c / 255
|
||||
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = (chan(c) for c in rgb)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def contrast_ratio(fg: str, bg: str) -> float:
|
||||
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
|
||||
if l1 < l2:
|
||||
l1, l2 = l2, l1
|
||||
return (l1 + 0.05) / (l2 + 0.05)
|
||||
|
||||
|
||||
def _assert_aa(pair: Any, label: str) -> None:
|
||||
fg, bg = str(pair[0]), str(pair[1])
|
||||
ratio = contrast_ratio(fg, bg)
|
||||
assert ratio >= 4.5, (
|
||||
f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Page 1 of 2 for 30 rows: the 25-row page, the pager's visible
|
||||
# state, the never-stale first-load line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pager_shows_page_1_of_2_for_30_rows(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
# The table holds the first 25 of 30 (the 25-row page)…
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 1 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-prev")).to_be_disabled()
|
||||
expect(page.locator("#history-pager-next")).to_be_enabled()
|
||||
# …and the live region carries the first-load line (the
|
||||
# settled load announces its own page — task 03).
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 1–25 of 30."
|
||||
)
|
||||
# Order pinned by the first row of page 1 (the last
|
||||
# re-Saved — the newest).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(30, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Next → page 2: the 5 rows (replaced, never appended), the
|
||||
# disabled edge, the live-region line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_next_walks_to_page_2(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
# The 25 page-1 rows are GONE (replaced, never appended —
|
||||
# an append would leave 30; the count IS the pin)…
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 2 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-next")).to_be_disabled()
|
||||
expect(page.locator("#history-pager-prev")).to_be_enabled()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 26–30 of 30."
|
||||
)
|
||||
# …reinforced: the newest page-1 title (row 1) is not on
|
||||
# the page at all.
|
||||
expect(
|
||||
page.locator(
|
||||
f'#history-tbody a.history-title-link:text-is("{_title(30, 30)}")'
|
||||
)
|
||||
).to_have_count(0)
|
||||
# Order: the first row of the last page (the 5th re-Saved —
|
||||
# row 26) …
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(5, 30))
|
||||
# …and the last row is the earliest re-Saved (row 30).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").last
|
||||
).to_have_text(_title(1, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Prev → page 1: the round trip (25 rows again, the edge back,
|
||||
# the line)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prev_walks_back_to_page_1(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
page.locator("#history-pager-prev").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(25, timeout=15_000)
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 1 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-prev")).to_be_disabled()
|
||||
expect(page.locator("#history-pager-next")).to_be_enabled()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 1–25 of 30."
|
||||
)
|
||||
# Order round-trips: the first row is the last re-Saved
|
||||
# again.
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(30, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Refresh on page 2 stays on page 2 (A5 — never resets to
|
||||
# page 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_on_page_2_stays_on_page_2(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
# The Refresh lifecycle: click → disabled while in flight →
|
||||
# re-enabled in the finally — waiting for the re-enable IS
|
||||
# the settle gate (the house idiom).
|
||||
page.locator("#history-refresh").click()
|
||||
expect(page.locator("#history-refresh")).to_be_enabled(
|
||||
timeout=15_000
|
||||
)
|
||||
# A5: the re-fetch landed page 2, not page 1 — still the 5
|
||||
# page-2 rows + "Page 2 of 2" (a reset to page 1 would show
|
||||
# 25 rows + "Page 1 of 2" — the pins fail it).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5)
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 2 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-next")).to_be_disabled()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Saved chats refreshed."
|
||||
)
|
||||
# The page-2 order is intact (the 5th re-Saved first).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(5, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Delete-to-empty on the last page: the inline two-step confirm
|
||||
# (no native dialog), the empty state, the pager NOT recomputed
|
||||
# on the local delete (D4), the next load's fresh total hides
|
||||
# it (A4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_all_rows_of_page_2_then_prev_single_page_hides_pager(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
# Delete all five page-2 rows through the inline two-step
|
||||
# confirm — the owner-locked no-native-dialog pin: the
|
||||
# "Delete? [Yes] [No]" pair appearing IN PLACE is the
|
||||
# contract (a real window.confirm would hang Playwright).
|
||||
for remaining in (4, 3, 2, 1, 0):
|
||||
tr = page.locator(
|
||||
"#history-tbody tr",
|
||||
has=page.locator("a.history-title-link"),
|
||||
).first
|
||||
expect(tr).to_be_visible()
|
||||
tr.locator("button.history-delete").click()
|
||||
expect(tr.locator(".history-confirm-yes")).to_be_visible()
|
||||
expect(tr.locator(".history-confirm-no")).to_be_visible()
|
||||
tr.locator(".history-confirm-yes").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(remaining)
|
||||
# The last row's deletion reveals the empty state…
|
||||
expect(page.locator("#history-empty-row")).to_be_visible()
|
||||
# …and the pager is NOT recomputed on the local delete
|
||||
# (D4 — the last known truth: still "Page 2 of 2", the
|
||||
# server's 30-row count from the page-2 load; only the next
|
||||
# load corrects it).
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 2 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-prev")).to_be_enabled()
|
||||
expect(page.locator("#history-pager-next")).to_be_disabled()
|
||||
# The next load (Prev) corrects the pager with the fresh
|
||||
# total: 25 rows (the page-1 rows — the page-2 ones are
|
||||
# deleted) → single page → the pager hides (A4).
|
||||
page.locator("#history-pager-prev").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(25, timeout=15_000)
|
||||
expect(page.locator("#history-pager")).to_be_hidden()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 1–25 of 25."
|
||||
)
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Single page: the pager is fully hidden (A4 — no dead "Page 1
|
||||
# of 1" control)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_page_hides_the_pager(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 10)
|
||||
_settle_admin_history(page, app_url, 10)
|
||||
try:
|
||||
# The whole list is on the page (10 of 10)…
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(10)
|
||||
# …and the pager is fully hidden (the container's `hidden`
|
||||
# takes the whole row away — the buttons with it).
|
||||
expect(page.locator("#history-pager")).to_be_hidden()
|
||||
expect(page.locator("#history-pager-prev")).to_be_hidden()
|
||||
expect(page.locator("#history-pager-next")).to_be_hidden()
|
||||
# Order: the first row is the last re-Saved (the newest).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(10, 10))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Anonymous: the gate, the table AND the pager hidden, and NO
|
||||
# /api/chats request in the log (D5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_sees_gate_and_never_fetches(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# A fresh context is anonymous by construction (no login).
|
||||
requests: list[str] = []
|
||||
page.on("request", lambda r: requests.append(r.url))
|
||||
page.goto(app_url + "/history.html")
|
||||
# Settled anonymous state (the whoami round-trip has landed)…
|
||||
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
|
||||
# …the table is wrapped away AND the pager stays hidden (the
|
||||
# markup-shipped state — the anonymous branch never touches
|
||||
# it, D5 — no dead control beside the gate).
|
||||
expect(page.locator("#history-table-wrap")).to_be_hidden()
|
||||
expect(page.locator("#history-pager")).to_be_hidden()
|
||||
# …and the view NEVER calls /api/chats (the router 403s
|
||||
# anonymous) — a short settle wait so the claim is made on a
|
||||
# settled page (the house log-pin idiom).
|
||||
page.wait_for_timeout(500)
|
||||
assert not any("/api/chats" in u for u in requests), (
|
||||
f"the anonymous History page must not fetch /api/chats: {requests}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. ≤640px (D6): the pager row stacks, the buttons go full width
|
||||
# (the .history-refresh mobile idiom), the table still scrolls
|
||||
# inside its card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pager_mobile_375px(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
page.set_viewport_size({"width": 375, "height": 720})
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
pager = page.locator("#history-pager").bounding_box()
|
||||
assert pager is not None, "the pager must render at 375px"
|
||||
# The D6 block: the row STACKS (the .settings-actions idiom)
|
||||
# and the buttons go FULL WIDTH (the .history-refresh mobile
|
||||
# idiom) — each button's width is the pager's (±1px
|
||||
# subpixel; the pills are `width: 100%` of the stacked row).
|
||||
direction = page.evaluate(
|
||||
"() => getComputedStyle("
|
||||
"document.getElementById('history-pager')).flexDirection"
|
||||
)
|
||||
assert direction == "column", (
|
||||
f"the pager row must stack at 375px (got {direction!r})"
|
||||
)
|
||||
for sel in ("#history-pager-prev", "#history-pager-next"):
|
||||
box = page.locator(sel).bounding_box()
|
||||
assert box is not None, f"{sel} must render at 375px"
|
||||
assert abs(box["width"] - pager["width"]) <= 1, (
|
||||
f"{sel} must be full width: {box['width']:.1f}px vs the "
|
||||
f"pager's {pager['width']:.1f}px"
|
||||
)
|
||||
assert pager["x"] + pager["width"] <= 375 + 1, (
|
||||
f"the pager's right edge ({pager['x'] + pager['width']:.1f}) "
|
||||
f"reaches/past the 375px viewport"
|
||||
)
|
||||
# No page-level horizontal scroll (the table's own overflow-x
|
||||
# scroll is the mobile scroll)…
|
||||
scroll_width = page.evaluate(
|
||||
"() => document.scrollingElement.scrollWidth"
|
||||
)
|
||||
assert scroll_width <= 375, (
|
||||
f"page-level horizontal scroll at 375px "
|
||||
f"(scrollWidth {scroll_width})"
|
||||
)
|
||||
# …and the 640px table still scrolls INSIDE its card (the
|
||||
# phase-86 contract the D6 block must not break).
|
||||
report = page.evaluate(
|
||||
"() => { const el = document.getElementById("
|
||||
"'history-table-wrap'); "
|
||||
"return { scroll: el.scrollWidth, client: el.clientWidth }; }"
|
||||
)
|
||||
assert report["scroll"] > report["client"], (
|
||||
f"the 640px table must scroll inside its card at 375px "
|
||||
f"(scrollWidth {report['scroll']} <= clientWidth "
|
||||
f"{report['client']})"
|
||||
)
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pager_a11y_and_no_cdn_basics(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
|
||||
# Standard app frame (PLAN §7.2).
|
||||
expect(page.locator("header.app-header")).to_have_count(1)
|
||||
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
|
||||
expect(page.locator("main#main")).to_have_count(1)
|
||||
expect(page.locator("footer.app-footer")).to_have_count(1)
|
||||
expect(page.locator(".skip-link")).to_have_count(1)
|
||||
|
||||
# The pager buttons' accessible names (the aria-labels — what
|
||||
# the accessibility tree exposes; the visible "Prev" / "Next"
|
||||
# labels stay in both) and the decorative SVGs (aria-hidden).
|
||||
assert page.locator("#history-pager-prev").get_attribute("aria-label") == (
|
||||
"Previous page of saved chats"
|
||||
), "the Prev button's accessible name"
|
||||
assert page.locator("#history-pager-next").get_attribute("aria-label") == (
|
||||
"Next page of saved chats"
|
||||
), "the Next button's accessible name"
|
||||
assert (
|
||||
page.locator("#history-pager-prev .history-pager-icon")
|
||||
.get_attribute("aria-hidden")
|
||||
== "true"
|
||||
), "the Prev chevron is decorative (aria-hidden)"
|
||||
assert (
|
||||
page.locator("#history-pager-next .history-pager-icon")
|
||||
.get_attribute("aria-hidden")
|
||||
== "true"
|
||||
), "the Next chevron is decorative (aria-hidden)"
|
||||
# The live region that announces the page lines (the
|
||||
# never-stale contract).
|
||||
assert page.locator("#history-status").get_attribute("role") == "status"
|
||||
assert (
|
||||
page.locator("#history-status").get_attribute("aria-live")
|
||||
== "polite"
|
||||
), "#history-status must be a polite live region"
|
||||
|
||||
# The pager button reaches the 3px :focus-visible outline via a
|
||||
# REAL keyboard Tab walk (the phase-171 walk shape: the skip
|
||||
# link is focused first — the document's FIRST tabbable
|
||||
# element; every move after it is a real Tab keypress). The
|
||||
# walk crosses the header, the Refresh pill, the table region
|
||||
# (tabindex=0) and the 25 row's controls (title link + share
|
||||
# Create link + Delete — 3 tabbables per row) before it lands
|
||||
# on the pager's Next (Prev is `disabled` on page 1 — not
|
||||
# tabbable).
|
||||
page.evaluate("() => document.querySelector('a.skip-link').focus()")
|
||||
seen: list[str] = []
|
||||
reached = False
|
||||
for _ in range(150):
|
||||
page.keyboard.press("Tab")
|
||||
active = page.evaluate(
|
||||
"() => (document.activeElement && document.activeElement.id)"
|
||||
" || ''"
|
||||
)
|
||||
seen.append(active)
|
||||
if active == "history-pager-next":
|
||||
reached = True
|
||||
break
|
||||
assert reached, (
|
||||
f"the walk never reached the pager's Next control "
|
||||
f"(seen={seen[-12:]})"
|
||||
)
|
||||
outline = page.evaluate(
|
||||
"() => getComputedStyle(document.activeElement).outlineWidth"
|
||||
)
|
||||
assert outline == "3px", (
|
||||
f"focus-visible outline missing on the pager button: {outline!r}"
|
||||
)
|
||||
|
||||
# Contrast (the existing tokens — the phase-08 AA palette,
|
||||
# computed-style spot check): the "Page N of M" status text on
|
||||
# the page bg (the inherited text color), and the button ink on
|
||||
# the brand pill.
|
||||
pairs = page.evaluate(
|
||||
"""() => {
|
||||
const cs = (sel, prop) =>
|
||||
getComputedStyle(document.querySelector(sel))[prop];
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const body = getComputedStyle(document.body);
|
||||
const bg =
|
||||
root.backgroundColor !== "rgba(0, 0, 0, 0)"
|
||||
? root.backgroundColor
|
||||
: (body.backgroundColor !== "rgba(0, 0, 0, 0)"
|
||||
? body.backgroundColor
|
||||
: "rgb(0, 0, 0)");
|
||||
return {
|
||||
status: [cs("#history-pager-status", "color"), bg],
|
||||
next_btn: [
|
||||
cs("#history-pager-next", "color"),
|
||||
cs("#history-pager-next", "backgroundColor"),
|
||||
],
|
||||
};
|
||||
}"""
|
||||
)
|
||||
_assert_aa(pairs["status"], "pager status text on the page bg")
|
||||
_assert_aa(pairs["next_btn"], "pager button ink on brand")
|
||||
|
||||
# No CDN (rule 6): no https:// asset tags; every script/link
|
||||
# ref is same-origin or a data: URI (the house check).
|
||||
html = page.content()
|
||||
assert 'src="https://' not in html and 'href="https://' not in html
|
||||
refs = page.evaluate(
|
||||
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
||||
.map((el) => el.src || el.href)"""
|
||||
)
|
||||
assert refs, "expected local asset references"
|
||||
for ref in refs:
|
||||
assert ref.startswith(app_url) or ref.startswith("data:"), (
|
||||
f"non-local asset reference: {ref}"
|
||||
)
|
||||
@@ -9,7 +9,11 @@ surface (list / detail / delete / unshare) stays admin-only (guests get
|
||||
403 on exactly those four routes). The admin CRUD pins stay green
|
||||
unchanged: the auto-title convention (first
|
||||
user message, whitespace-collapsed, 120-char cap + the no-user-message
|
||||
fallback), the list order (``updated_at desc, id desc``), the
|
||||
fallback), the list order (``updated_at desc, id desc``; phase 175
|
||||
task 01: server-side pagination — ``limit`` (default 25, clamped
|
||||
1–100) / ``offset`` + the full ``total`` on every response; out-of-
|
||||
range params 422; an offset beyond the end → an empty list with the
|
||||
honest ``total``), the
|
||||
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
|
||||
``sources``/``thinking``/``tools``/``stopped``/``failed``/``error``
|
||||
survives losslessly),
|
||||
@@ -244,7 +248,7 @@ def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _assert_no_chats(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
|
||||
|
||||
|
||||
# ---------- guest (no session): the public write surface (phase 55,
|
||||
@@ -518,7 +522,7 @@ def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
|
||||
def test_list_empty(admin_client: TestClient) -> None:
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"chats": []}
|
||||
assert r.json() == {"chats": [], "total": 0}
|
||||
|
||||
|
||||
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
|
||||
@@ -560,6 +564,111 @@ def test_list_reports_message_count(admin_client: TestClient) -> None:
|
||||
assert [c["message_count"] for c in body["chats"]] == [2]
|
||||
|
||||
|
||||
# ---------- list — pagination (phase 175, task 01) ----------
|
||||
|
||||
|
||||
def _seed_staggered_chats(db: Session, n: int) -> list[str]:
|
||||
"""``n`` saved chats with distinctive titles and DISTINCT,
|
||||
staggered ``updated_at`` (the
|
||||
``test_list_orders_by_updated_at_desc`` idiom, scaled up): the
|
||||
ordered list is fully determined by ``updated_at desc`` (the
|
||||
uuid4 id tie-break never fires). Returns the titles in ORDERED
|
||||
order (newest first); ``clean_chats`` truncates around the test."""
|
||||
base = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
SavedChat(
|
||||
title=f"page175 row {i:02d}",
|
||||
messages=[_user(f"question {i}")],
|
||||
updated_at=base + timedelta(hours=i), # i=0 oldest
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return [f"page175 row {i:02d}" for i in range(n - 1, -1, -1)]
|
||||
|
||||
|
||||
def _ordered_chat_ids(db: Session) -> list[str]:
|
||||
"""The full ordered id sequence (``updated_at desc, id desc``)
|
||||
via raw SQL — the ground truth a page concatenation is checked
|
||||
against (bypassing the API itself)."""
|
||||
return [
|
||||
str(r[0])
|
||||
for r in db.execute(
|
||||
text(
|
||||
"SELECT id FROM saved_chats ORDER BY updated_at DESC, "
|
||||
"id DESC"
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_list_default_page_is_25_and_reports_total(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
ordered = _seed_staggered_chats(db, 30)
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"chats", "total"}
|
||||
assert body["total"] == 30 # the FULL count, not the page length
|
||||
assert len(body["chats"]) == 25
|
||||
# The page is the FIRST 25 of the full ordered list
|
||||
# (``updated_at desc`` — the staggered fixture pins the order).
|
||||
assert [c["title"] for c in body["chats"]] == ordered[:25]
|
||||
for c in body["chats"]:
|
||||
assert set(c) == ROW_KEYS
|
||||
uuid.UUID(c["id"])
|
||||
|
||||
|
||||
def test_list_limit_offset_slice(admin_client: TestClient, db: Session) -> None:
|
||||
ordered = _seed_staggered_chats(db, 30)
|
||||
r = admin_client.get("/api/chats", params={"limit": 10, "offset": 20})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 30
|
||||
# Exactly the LAST 10 of the ordered list (the fixture's ten
|
||||
# oldest), pinned by title.
|
||||
assert [c["title"] for c in body["chats"]] == ordered[20:]
|
||||
|
||||
|
||||
def test_list_order_preserved_across_pages(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
_seed_staggered_chats(db, 30)
|
||||
pages: list[dict[str, Any]] = []
|
||||
for offset in (0, 10, 20):
|
||||
body = admin_client.get(
|
||||
"/api/chats", params={"limit": 10, "offset": offset}
|
||||
).json()
|
||||
assert body["total"] == 30
|
||||
pages.extend(body["chats"])
|
||||
# The three pages concatenated == the full ordered id sequence
|
||||
# (the raw-SQL ground truth) — no duplicates, no gaps.
|
||||
assert [c["id"] for c in pages] == _ordered_chat_ids(db)
|
||||
assert len({c["id"] for c in pages}) == 30
|
||||
|
||||
|
||||
def test_list_rejects_out_of_range_params(admin_client: TestClient) -> None:
|
||||
# The house fixed-422 contract (FastAPI field-message validation
|
||||
# — no hand-rolled checks): one request per case, status
|
||||
# asserted.
|
||||
for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}):
|
||||
assert admin_client.get("/api/chats", params=params).status_code == 422
|
||||
|
||||
|
||||
def test_list_offset_beyond_total_returns_empty_with_total(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
_seed_staggered_chats(db, 30)
|
||||
r = admin_client.get("/api/chats", params={"offset": 100})
|
||||
assert r.status_code == 200
|
||||
# An honest count, not an error: the list is empty but ``total``
|
||||
# still reports the fixture's 30 rows.
|
||||
assert r.json() == {"chats": [], "total": 30}
|
||||
|
||||
|
||||
# ---------- get ----------
|
||||
|
||||
|
||||
@@ -708,7 +817,7 @@ def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -
|
||||
|
||||
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
|
||||
assert db.scalars(select(SavedChat)).all() == []
|
||||
|
||||
|
||||
|
||||
@@ -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