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`
622 lines
27 KiB
Python
622 lines
27 KiB
Python
"""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()
|