Files
brain-of-reese/tests/e2e/test_table_ux_polish.py
T
ducoterra 1513981c1c
Build and Push Containers / build-and-push-app (push) Successful in 2m15s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 156_table_ux_polish
Everything is verified. All gates green on the current working tree. Final report:

**Phase 156 — final verification pass (all 4 task files present in `complete/` — no outstanding work)**

- **Verified (not re-implemented):** bounded-panel `.table-wrap` (`max-height: min(70vh, 64rem)`, `overflow: auto`, opaque sticky `th` via `var(--brand-soft)`), `.cell-clip` ellipsis rule + the five label families in the three renderers, the added aria-labels, unit pins (incl. `.md-table-wrap` byte-unchanged scope guard, `#git-sources-table-wrap` harmony — no divergent rule), dedicated E2E suite (sticky ×3 tables, horizontal contract, computed-style ellipsis + reachable full text, mobile hamburger + scroll + pin)
- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **3570 passed, 99% on `app/`** (gate >90%); `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` (DB up) → **6/6 passed in isolation**; `uv run ruff check . && uv run pyright` → **clean** (0 errors)
- **Completion criteria:** sticky header all 3 tables (exact-0 offset while `scrollTop > 0`) ✅ · horizontal scroll unchanged ✅ · ellipsis computed style + actual clip + title/aria reachable ✅ · mobile ≤640px contract ✅ · full suite/E2E/lint/coverage ✅ · atomic commit: **harness commits this phase after the pass** (per pipeline protocol — no git history commands run by me)
- **Documentation:** AGENTS.md phase-156 wording (bounded panels, sticky headers, `.cell-clip` + aria-label idiom) matches the implementation exactly — verified consistent; README.md reviewed in full (pages/URLs vs registered routes, all `.env.example` vars documented, Quick Start, eval/llm scripts, compose prod profile, E2E examples) — verified consistent, no changes needed (README describes no table layout/scroll, so no phase-156 mention required)
- **Deviations:** none — all work was already in the working tree; this pass re-verified every gate and the docs
- **Next pending phase:** `157_chat_chip_cap`
2026-10-04 20:46:57 -04:00

622 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 156 E2E (Playwright): the catalogue tables' sticky headers +
the ellipsis hint on clipped labels — the owner's UX audit, pinned.
Source: the owner UX audit 2026-10-03 (live interactive-browser pass):
"sticky table headers (RAG/Sources/History — the tables are long; the
header scrolls away)" + "At ~1,009px window width the rightmost table
columns start clipped with no ellipsis hint on the 'Hidden'/'Edit'
labels (the wrapper scrolls fine — just subtle)".
Story: n/a (audit-derived phase; the table machinery is phase 97 / 86 /
63, all complete).
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov
The phase design (phase 156, owner-locked A1/A3): the three catalogue
tables (the RAG folder table ``#folders-table`` in ``#folders-wrap``,
the Sources table ``#git-sources-table`` in ``#git-sources-table-wrap``,
the History table in ``#history-table-wrap``) all ride the ONE
``.table-wrap`` card, which is now the bounded vertical scroll region
(``max-height: min(70vh, 64rem)`` + ``overflow: auto``) with the
OPAQUE sticky ``th`` (``position: sticky; top: 0; z-index: 1``). A
clipped action label (the five ``.cell-clip`` families) shows ``…``
instead of a hard cut; the full text stays reachable through the
control's ``aria-label``/``title`` naming the row's identity (the
source URL / the chat title / the source name — the phase-89/105,
66, and 97 idioms).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_sticky_header_on_vertical_scroll`` (×3 — RAG folders /
Sources / History, parameterized) — THE audit's sticky pin: at
1280×800 (the panel's ``max-height`` resolves to 560px) a 16-row
fixture overflows the panel (precondition asserted: the table's
``scrollHeight`` > the panel's ``clientHeight``); after
``scrollTop = 400`` (clamped to the max), ``scrollTop > 0`` AND the
``th``'s top edge is EXACTLY at the panel's content top — the
``boundingBox`` delta (``thTop − wrapTop − borderTop``) is 0 within
sub-pixel precision (the standard sticky assertion; WITHOUT the
phase-156 rule the ``th`` would sit ~400px above the panel top —
the counterfactual guard asserts the ``th`` never goes above the
panel top).
2. ``test_horizontal_scroll_contract_unchanged`` — the phase-97/86
contract the ``overflow: auto`` change must keep: at 375×812 each
table's ``min-width`` (480 / 560 / 640) still triggers the in-card
horizontal scroll (``wrap.scrollWidth > wrap.clientWidth`` with the
table wider than the 346px card).
3. ``test_ellipsis_on_clipped_label`` — the audit's ~1,009px case: at
the 1,009px viewport the clip is FORCED on the row's host controls
(the inline width below the label's natural size — see the "Why
the clip is forced at the control" note below), and for EVERY
clipped ``.cell-clip`` span: the COMPUTED ``text-overflow`` is
``"ellipsis"``, ``scrollWidth > clientWidth`` (the label is
actually clipped, not merely styled), and the control's
``aria-label`` names the ROW's identity (the source URL / the chat
title / the source name — the audit's additions). At least one
span per table must clip (the determinism guard).
4. ``test_mobile_contract`` — the PLAN §7.1 mobile ≤640px regression
guard (the phase-86 suite's idiom): at 375×812 a REAL
``#nav-toggle`` click opens the hamburger
(``aria-expanded="true"``), a menu row navigates (the menu closes
on navigation — the phase-85/88 contract), and in EVERY catalogue
view the table still scrolls horizontally inside the bounded panel
(test 2's contract) while the ``thead`` still pins (test 1's
contract — the ``max-height`` must not break the mobile scroll
model).
Why the clip is forced at the control (test 3): Chromium's auto table
layout renders the table at its MAX-content width whenever the
min-content sum exceeds the panel (measured: at the 1,009px 967px
card, with this suite's 45-char source URLs, 45-char chat titles, and
8-char folder names, the tables keep 950–968px of columns and
hard-cut nothing), so a viewport alone cannot deterministically
engage the ``.cell-clip`` clamp (the engagement width is
font-metric dependent). The test instead narrows the row's host
controls to below the label's natural size (40px ``.git-source-hidden``
label, 60px ``.git-source-ignore`` / ``.git-source-remove``, 30px
``.kb-summary-edit``, 60px ``.history-share-create`` /
``.history-unshare``, 50px ``.history-delete``): the clamp chain a
squeezed cell engages is the EXACT same one (the control's
``max-width: 100%`` + ``min-width: 0`` — the phase-156 task-02
support rules — plus the span's ``min-width: 0`` + ``overflow:
hidden`` + ``text-overflow: ellipsis`` + ``white-space: nowrap``), so
the assertion is deterministic and metric-independent (measured clip
margins 16–53px under the E2E Chromium).
The five ``.cell-clip`` families under test (phase 156 task 02): the
Sources ``Hidden`` checkbox label + ``Ignore paths`` + ``Remove``, the
RAG folder table's ``Edit``, the History ``Create link`` (the
``Copy``/``Unshare`` shared-state variants share the class family and
aria idiom) + ``Delete``.
House conventions: a fresh page per test via the session ``browser``
fixture (the conftest ``page`` is 1280×800), ``e2e.auth_helpers.login``
for the real form login, ``_wait_settled_admin`` copied from
``test_mobile_hamburger_nav.py`` (suites stay self-contained —
helpers copied, only ``auth_helpers`` imported), ``_cookies`` copied
from ``test_api_tokens.py``.
Data: each test seeds a FIXED 16-row fixture (16 git sources with
deterministic ``e2e.example.com`` URLs — the folder table lists
registered sources even with 0 documents, the tree endpoint's
"always present" contract — plus 16 saved chats with deterministic
45-char titles) and deletes it in ``finally`` (the idempotent
seed/delete keeps re-runs green; no KB documents are imported, so no
house KB reset is needed). The 45-char chat title and the source's
deterministic URL are what the aria assertions read back.
"""
from __future__ import annotations
import httpx
import pytest
from playwright.sync_api import Browser, Page, ViewportSize, expect
from e2e.auth_helpers import login
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
AUDIT: ViewportSize = {"width": 1009, "height": 800} # the audit's ~1,009px case
MOBILE: ViewportSize = {"width": 375, "height": 812} # the phase-86 phone viewport
#: The fixed fixture size — 16 rows overflow the 560px desktop panel
#: (measured table heights 835–1178px) and the 568px mobile panel.
N_ROWS = 16
SOURCE_PREFIX = "https://e2e.example.com/table-polish/"
CHAT_PREFIX = "e2e polish chat "
#: The three catalogue tables: view path, the ``.table-wrap`` card,
#: the row signal (admin settle + render), the table ``min-width``,
#: and the mobile nav link (PLAN §7.1).
TABLES = {
"rag": {
"path": "/sources.html",
"wrap": "#folders-wrap",
"row": "#folders-tbody tr",
"min_width": 480, # .kb-folders-table (the phase-97 override of the 640px .docs-table base)
"nav": "#nav-sources",
},
"sources": {
"path": "/git-sources.html",
"wrap": "#git-sources-table-wrap",
"row": "#git-sources-tbody tr",
"min_width": 560,
"nav": "#nav-git-sources",
},
"history": {
"path": "/history.html",
"wrap": "#history-table-wrap",
"row": "#history-table-wrap .history-title-link",
"min_width": 640,
"nav": "#nav-history",
},
}
def _chat_title(i: int) -> str:
"""The deterministic 45-char chat title (16 prefix + 2 index + 27 pad)."""
return (CHAT_PREFIX + f"{i:02d}" + "y" * 27)[:45]
def _source_url(i: int) -> str:
return f"{SOURCE_PREFIX}{i:02d}-repo.git"
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (copied from
``test_api_tokens.py``)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin: the whoami reveal
has un-hidden the admin-only nav links (copied from
``test_mobile_hamburger_nav.py``)."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
def _seed(page: Page, app_url: str) -> dict[str, dict[str, str]]:
"""The fixed 16-row fixture (idempotent — a half-finished previous
run's rows are reused, not duplicated): 16 git sources (the
Sources table rows + the RAG folder-table rows — the tree lists
registered sources even with 0 documents) + 16 saved chats (the
History rows)."""
ck = _cookies(page)
sources: dict[str, str] = {
s["url"]: s["id"]
for s in httpx.get(f"{app_url}/api/git-sources", cookies=ck, timeout=10).json()["sources"]
if s["url"].startswith(SOURCE_PREFIX)
}
for i in range(1, N_ROWS + 1):
url = _source_url(i)
if url not in sources:
r = httpx.post(
f"{app_url}/api/git-sources",
json={"kind": "git", "url": url},
cookies=ck, timeout=10,
)
assert r.status_code == 201, r.text
sources[url] = r.json()["id"]
chats: dict[str, str] = {
c["title"]: c["id"]
for c in httpx.get(f"{app_url}/api/chats", cookies=ck, timeout=10).json()["chats"]
if c["title"].startswith(CHAT_PREFIX)
}
for i in range(1, N_ROWS + 1):
title = _chat_title(i)
if title not in chats:
r = httpx.post(
f"{app_url}/api/chats",
json={
"title": title,
"messages": [
{"who": "user", "text": f"question {i}"},
{"who": "brain", "text": f"answer {i}"},
],
},
cookies=ck, timeout=10,
)
assert r.status_code == 201, r.text
chats[title] = r.json()["id"]
return {"sources": sources, "chats": chats}
def _cleanup(page: Page, app_url: str, data: dict[str, dict[str, str]] | None) -> None:
"""Delete the fixture rows (a row another cleanup pass already
removed is a 404, not a failure)."""
if data is None:
return
ck = _cookies(page)
for sid in data["sources"].values():
r = httpx.delete(f"{app_url}/api/git-sources/{sid}", cookies=ck, timeout=30)
assert r.status_code in (204, 404), r.text
for cid in data["chats"].values():
r = httpx.delete(f"{app_url}/api/chats/{cid}", cookies=ck, timeout=10)
assert r.status_code in (204, 404), r.text
def _assert_sticky(page: Page, wrap_sel: str, label: str) -> None:
"""THE sticky pin: the panel is overflowed (precondition), the
scroll moved (``scrollTop > 0``), and the ``th``'s top edge is
EXACTLY at the panel's content top — the boundingBox delta
(``thTop − wrapTop − borderTop``) is 0 within sub-pixel
precision. The counterfactual guard: without the sticky rule the
``th`` would sit the whole ``scrollTop`` above the panel top — a
delta below −0.5 IS "scrolled away"."""
rep = page.evaluate(
"""(ws) => {
const w = document.querySelector(ws);
const t = w.querySelector('table');
const th = w.querySelector('table thead th');
w.scrollTop = 400;
const thTop = th.getBoundingClientRect().top;
const wTop = w.getBoundingClientRect().top;
const border = parseFloat(getComputedStyle(w).borderTopWidth);
return {top: w.scrollTop, clientH: w.clientHeight,
tableH: t.scrollHeight, delta: thTop - wTop - border};
}""",
wrap_sel,
)
assert rep["tableH"] > rep["clientH"], (
f"{label}: the 16-row fixture must overflow the bounded panel "
f"(table {rep['tableH']}px in a {rep['clientH']}px panel) — "
"the scroll precondition does not hold"
)
assert rep["top"] > 0, (
f"{label}: the panel's vertical scroll did not move "
f"(scrollTop {rep['top']}) — the bounded-panel contract is not in effect"
)
assert abs(rep["delta"]) < 0.5, (
f"{label}: the sticky header must sit EXACTLY at the panel's content top "
f"(th top − panel content top = {rep['delta']}px while scrollTop is "
f"{rep['top']}px) — phase 156 task 01's sticky rule; a large negative "
"delta means the header scrolled away with the page (the audit's defect)"
)
def _assert_hscroll(page: Page, wrap_sel: str, min_width: int, label: str) -> None:
"""The in-card horizontal contract: the table keeps its ``min-width``
and scrolls INSIDE the (bounded) card (``overflow: auto``)."""
rep = page.evaluate(
"""(ws) => {
const w = document.querySelector(ws);
return {scroll: w.scrollWidth, client: w.clientWidth,
table: w.querySelector('table').scrollWidth};
}""",
wrap_sel,
)
assert rep["table"] >= min_width, (
f"{label}: the table must keep its {min_width}px min-width, "
f"got {rep['table']}px"
)
assert rep["scroll"] > rep["client"], (
f"{label}: the {min_width}px table must scroll INSIDE the card "
f"(card {rep['client']}px < table {rep['table']}px)"
)
@pytest.mark.parametrize("view", ["rag", "sources", "history"])
def test_sticky_header_on_vertical_scroll(
view: str, browser: Browser, app_url: str, db_ready: None
) -> None:
"""THE audit's sticky pin, per catalogue table (1280×800 — the
panel's ``max-height: min(70vh, 64rem)`` resolves to 560px): the
16-row fixture overflows the panel (asserted), the scroll moves
(``scrollTop > 0`` after a 400px request — clamped to the max),
and the ``thead th``'s top edge is EXACTLY at the panel's content
top (the boundingBox delta is 0 within sub-pixel precision —
WITHOUT phase 156's rule it would be ~−400px: the header scrolls
away with the page, the audit's defect)."""
spec = TABLES[view]
page = browser.new_page(viewport=DESKTOP)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
# Seed BEFORE the view loads: the views fetch once on load and
# do not refetch — arriving before the fixture would render
# the pre-seed state (the rows the pins need never appear).
data = _seed(page, app_url)
page.goto(app_url + spec["path"])
_wait_settled_admin(page)
page.wait_for_selector(spec["row"], timeout=15_000)
_assert_sticky(page, spec["wrap"], f"{spec['path']} (desktop)")
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()
def test_horizontal_scroll_contract_unchanged(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The phase-97/86 contract (the ``min-width`` → in-card scroll)
survives phase 156's ``overflow: auto``: at 375×812 (the 346px
card) every catalogue table's ``min-width`` (480 / 560 / 640) is
wider than the card and scrolls INSIDE it (``wrap.scrollWidth >
wrap.clientWidth``) — the ``overflow: auto`` change (superseding
``overflow-x: auto``) must keep the horizontal axis intact."""
page = browser.new_page(viewport=MOBILE)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
data = _seed(page, app_url)
page.goto(app_url + TABLES["history"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (375px)")
page.goto(app_url + TABLES["sources"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (375px)")
page.goto(app_url + TABLES["rag"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (375px)")
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()
# ---------------------------------------------------------------------------
# 3. The ellipsis hint: computed style + the actual clip + the full
# text reachable (the audit's ~1,009px case)
# ---------------------------------------------------------------------------
#: Force the clip: every row's host controls narrowed to below the
#: label's natural size (the measured E2E-Chromium naturals: Hidden
#: 44 / Ignore paths 82 / Remove 51+icon / Edit 24+padding / Create
#: link 69 / Delete 42 — the forced widths leave 16–53px of clip
#: margin, so the clip is deterministic; the exact chain a squeezed
#: cell engages — the module docstring's "Why the clip is forced at
#: the control" note).
FORCE_CLIP_JS = {
"sources": """
() => {
for (const row of document.querySelectorAll('#git-sources-tbody tr')) {
const h = row.querySelector('.git-source-hidden');
const i = row.querySelector('.git-source-ignore');
const r = row.querySelector('.git-source-remove');
if (h) h.style.width = '40px';
if (i) i.style.width = '60px';
if (r) r.style.width = '60px';
}
}
""",
"rag": """
() => {
for (const row of document.querySelectorAll('#folders-tbody tr')) {
const b = row.querySelector('.kb-summary-edit');
if (b) b.style.width = '30px';
}
}
""",
"history": """
() => {
for (const row of document.querySelectorAll('#history-table-wrap tbody tr')) {
const c = row.querySelector('.history-share-create');
const u = row.querySelector('.history-unshare');
const d = row.querySelector('.history-delete');
if (c) c.style.width = '60px';
if (u) u.style.width = '60px';
if (d) d.style.width = '50px';
}
}
""",
}
#: The per-table span audits — (the clipped span, the row-identity
#: element whose text the aria-label must contain, the control
#: carrying the aria-label). The sources' "Hidden" label's identity
#: rides the CHECKBOX's aria-label (the phase-105 idiom — the label
#: itself carries the explanatory ``title``, the phase-156 audit
#: note); the RAG's identity is the row's folder link (the phase-97
#: idiom); the History's identity is the chat title (the phase-66
#: idiom).
SPAN_AUDITS: dict[str, list[tuple[str, str, str]]] = {
"sources": [
(
".git-source-hidden .cell-clip",
".git-source-url-cell code",
".git-source-hidden input",
),
(
".git-source-ignore .cell-clip",
".git-source-url-cell code",
".git-source-ignore",
),
(
".git-source-remove .cell-clip",
".git-source-url-cell code",
".git-source-remove",
),
],
"rag": [
(
".kb-summary-edit .cell-clip",
"td .folder-link",
".kb-summary-edit",
),
],
"history": [
(
".history-share-create .cell-clip",
".history-title-link",
".history-share-create",
),
(
".history-delete .cell-clip",
".history-title-link",
".history-delete",
),
],
}
def test_ellipsis_on_clipped_label(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The audit's ~1,009px case, made deterministic (the module
docstring's "Why the clip is forced at the control"): at
1,009×800 the row's host controls are narrowed below the label's
natural size, and for EVERY clipped ``.cell-clip`` span: (a) the
COMPUTED ``text-overflow`` is ``"ellipsis"`` (the deterministic
style assertion — never a pixel probe), (b) ``scrollWidth >
clientWidth`` (the label is actually clipped), and (c) the
control's ``aria-label`` contains the ROW's identity (the source
URL / the chat title / the source name — the audit's additions).
At least one span per table must clip (the guard that the
fixture engages the mechanism). The five ``.cell-clip`` families
are all covered (the module docstring's family list)."""
page = browser.new_page(viewport=AUDIT)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/git-sources.html")
_wait_settled_admin(page)
data = _seed(page, app_url)
page.goto(app_url + "/git-sources.html")
_wait_settled_admin(page)
page.wait_for_selector("#git-sources-tbody tr", timeout=15_000)
for view in ("sources", "rag", "history"):
spec = TABLES[view]
page.goto(app_url + spec["path"])
_wait_settled_admin(page)
page.wait_for_selector(spec["row"], timeout=15_000)
page.evaluate(FORCE_CLIP_JS[view])
rep = page.evaluate(
"""(audits) => {
const rowsSel = audits.rowsSel;
const out = [];
for (const row of document.querySelectorAll(rowsSel)) {
for (const a of audits.items) {
const span = row.querySelector(a.spanSel);
if (!span) continue;
const ident = row.querySelector(a.identitySel);
const ariaEl = row.querySelector(a.ariaSel);
out.push({
span: span.textContent,
clip: span.scrollWidth > span.clientWidth,
sw: span.scrollWidth, cw: span.clientWidth,
to: getComputedStyle(span).textOverflow,
identity: ident ? ident.textContent : '',
aria: ariaEl ? (ariaEl.getAttribute('aria-label') || '') : '',
});
}
}
return out;
}""",
{
"rowsSel": (
"#history-table-wrap tbody tr:not([hidden])"
if view == "history"
else TABLES[view]["row"]
),
"items": [
{"spanSel": s, "identitySel": i, "ariaSel": a}
for (s, i, a) in SPAN_AUDITS[view]
],
},
)
clips = [r for r in rep if r["clip"]]
assert clips, (
f"{spec['path']}: the forced-narrow fixture must clip at "
f"least one {view} label (the determinism guard) — "
f"got {[(r['span'], r['sw'], r['cw']) for r in rep]}"
)
for r in rep:
if not r["clip"]:
continue
assert r["to"] == "ellipsis", (
f"{spec['path']}: the clipped '{r['span']}' label must compute "
f"text-overflow: ellipsis (got '{r['to']}') — phase 156 task 02"
)
assert r["identity"], (
f"{spec['path']}: the row identity could not be read "
f"(the aria assertion is vacuous without it)"
)
assert r["identity"] in r["aria"], (
f"{spec['path']}: the clipped '{r['span']}' label's full text "
f"must stay reachable — its control's aria-label must name the "
f"row's identity ({r['identity']!r}), got {r['aria']!r}"
)
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()
# ---------------------------------------------------------------------------
# 4. The mobile ≤640px contract: the hamburger + the table scroll
# survive the bounded panel
# ---------------------------------------------------------------------------
def test_mobile_contract(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The PLAN §7.1 mobile regression guard (the phase-86 suite's
idiom): at 375×812 a REAL ``#nav-toggle`` click opens the
hamburger (``aria-expanded="true"``), a menu row navigates (the
menu closes on navigation — the phase-85/88 contract), and in
EVERY catalogue view the table still scrolls horizontally inside
the bounded panel (``min-width`` vs the 346px card) while the
``thead`` still pins — phase 156's ``max-height`` must not break
the mobile scroll model."""
page = browser.new_page(viewport=MOBILE)
data: dict[str, dict[str, str]] | None = None
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
data = _seed(page, app_url)
page.goto(app_url + TABLES["history"]["path"])
_wait_settled_admin(page)
page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
# The hamburger opens (the ≤640px nav contract)…
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# …and a menu row navigates, closing the menu.
page.click(TABLES["sources"]["nav"])
expect(page).to_have_url(app_url + TABLES["sources"]["path"], timeout=15_000)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (mobile)")
_assert_sticky(page, TABLES["sources"]["wrap"], "Sources (mobile)")
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.click(TABLES["rag"]["nav"])
expect(page).to_have_url(app_url + TABLES["rag"]["path"], timeout=15_000)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (mobile)")
_assert_sticky(page, TABLES["rag"]["wrap"], "RAG (mobile)")
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.click(TABLES["history"]["nav"])
expect(page).to_have_url(app_url + TABLES["history"]["path"], timeout=15_000)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
page.wait_for_selector(TABLES["history"]["row"], timeout=15_000)
_assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (mobile)")
_assert_sticky(page, TABLES["history"]["wrap"], "History (mobile)")
finally:
if data is not None:
_cleanup(page, app_url, data)
page.close()