All documentation claims verify against the code. Everything is green — final verification complete.
## Phase 176 — Final Verification Report
- **Verified (all 3 tasks in `complete/`):** the mobile Value-column fix is in the working tree — `frontend/index.html` L1602 carries `class="settings-value-th"`; `frontend/assets/styles.css` has exactly two new rules inside the `@media (max-width: 640px)` block: `.settings-table { min-width: 720px }` and `.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem) }`; `settings.js` + `app/` byte-unchanged (diff = index.html + styles.css + unit test only).
- **Documented deviation (flagged in task-03 report, not silent):** spec's `min-width: 13rem` on the border-box cell left the control at 176px (< the 192px A3 pin); executor corrected the cell to `calc(13rem + 2rem)` so the control (100% of content) lands at exactly 13rem ≈ 208px — the A2 lock intent preserved, unit pin + CSS comment updated to match.
- **Tests/lint/coverage (exact commands, all green):** `uv run pytest --cov=app` → **4138 passed**, `app/` coverage **99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Completion criteria:** 375px controls ≥ 192px — ✅ (E2E `test_mobile_value_inputs_are_usable_width`); card scrolls / page doesn't overflow at 375px — ✅ (`test_mobile_table_still_scrolls_and_page_does_not_overflow`); 1440px media query inert — ✅ (`test_desktop_value_width_is_unchanged`); tab order + zero external requests — ✅; phase E2E isolated — ✅ 4/4; phase 171 isolated — ✅ 5/5; phase 168 isolated — ✅ 12/12; unit pins — ✅ 17/17; commit/move — the harness commits; phase dir already in `complete/`.
- **Documentation:** README.md and AGENTS.md reviewed in full — every claim spot-checked against code (E2E ports 8123/8901, defaults `BOR_COMPRESS_MIN_BYTES=860` / `BOR_LOG_LARGE_RESPONSE_BYTES=65536`, audit budget 262144, `.cell-clip`, `.bubble:empty`, `min(70vh, 64rem)`, aria-pressed toggle, CDN integration test, `.gitignore` entries, all referenced scripts/routes) — verified consistent, no changes needed.
- **Notable:** `image-1.png` (owner's TODO screenshot, referenced by the phase overview) sits untracked in the tree — left alone; not part of the phase's commit scope.
- **Next pending phase:** `177_themed_favicon`.
582 lines
25 KiB
Python
582 lines
25 KiB
Python
"""Phase 176 E2E (Playwright): the mobile Settings Value-column width —
|
||
the width contract from ``TODO.md`` L3, proven in a real browser.
|
||
|
||
Source: ``TODO.md`` L3 — "On mobile, the settnigs values are
|
||
impossibly small. This is a horizontally scrollable table so we can
|
||
have slightly wider input fields." (the screenshot: at a 375px phone
|
||
viewport the Value-column inputs render only 2–3 characters wide —
|
||
"Br", "0.", "pr", "Wr", "Pc", "IN" — unreadable and unusable.)
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_settings_mobile_value_width.py -v --no-cov
|
||
|
||
Test → contract mapping (one story, one phase, one isolated file):
|
||
|
||
1. ``test_mobile_value_inputs_are_usable_width`` — the "impossibly
|
||
small" pin: at 375px the admin arrives at the settings view (the
|
||
phase-168 gate flow), and a representative spread of rows — one
|
||
``type="text"`` input, one ``select``, one ``textarea`` (three
|
||
distinct control types among the catalogue's 78 rows; the
|
||
phase-168 catalogue guarantees text / number / select / textarea /
|
||
password controls) — each renders its ``.settings-control``
|
||
bounding box ≥ 192px wide (12 rem — owner-locked A3: the
|
||
deliberate margin under the 13 rem rule; the pin is 12 rem and
|
||
≥ 200px is NOT asserted, so the pin is not brittle to sub-pixel
|
||
rounding), the ``.settings-table``'s own ``scrollWidth`` exceeds
|
||
the ``.table-wrap`` card's ``clientWidth`` (the horizontal scroll
|
||
is active on the phone — the TODO's own premise, the 720px
|
||
mobile floor, owner-locked A2), while
|
||
``document.documentElement.scrollWidth ≤ 375`` (the PAGE does not
|
||
overflow — the phase-171 pin: the scroll is the card's, not the
|
||
page's).
|
||
2. ``test_mobile_table_still_scrolls_and_page_does_not_overflow`` —
|
||
the "the card scrolls, never the page" pin: at 375px the
|
||
``.table-wrap`` computed ``overflow`` is ``auto``, the card's
|
||
``scrollWidth > clientWidth``, the page-level
|
||
``documentElement.scrollWidth ≤ 375 + 1`` (1px rounding), and the
|
||
sticky-``thead`` contract survives — scroll the card and the
|
||
``thead th``s stay pinned at the card's top edge (computed
|
||
``position: sticky`` — the phase-156/168/171 catalogue
|
||
contract intact under the new column floor).
|
||
3. ``test_desktop_value_width_is_unchanged`` — the "no desktop
|
||
regression" pin: at 1440px the media query is INERT — the
|
||
computed ``min-width`` of ``.settings-value-cell`` (and
|
||
``.settings-value-th``) is the UA ``0px``/``auto``
|
||
(``getComputedStyle`` returns ``0px`` or ``auto``, never
|
||
``208px``/``208.0px`` — the 13rem floor binds only inside the
|
||
≤640px block, owner-locked A1) and the ``.settings-table``
|
||
``scrollWidth`` ≤ the card's ``clientWidth`` (no forced scroll at
|
||
desktop — the 720px floor is inert, the 560px base rule is
|
||
subsumed by the card's width).
|
||
4. ``test_keyboard_order_and_no_external_requests`` — PLAN §7.1 +
|
||
rule 6: at 375px the keyboard tab order still reaches
|
||
``#settings-filter`` → the first row's Value control →
|
||
``#settings-save`` → ``#settings-clear-all`` (the phase-168/171
|
||
contract under the new column floor), and the whole test issues
|
||
ZERO external (non-127.0.0.1) requests.
|
||
|
||
DB isolation: the shared e2e Postgres keeps ``app_settings`` (the
|
||
overlay rows — a leftover row would retune every later suite's app
|
||
through the conftest pin's failure mode), ``ui_settings`` (the
|
||
single row the caching middleware reads for EVERY served page — the
|
||
effective-strings resolution ``/api/config`` uses) and ``api_tokens``
|
||
rows across suites. An autouse fixture truncates ``app_settings``
|
||
and ``ui_settings`` and deletes the ``e2e-``-labeled tokens before
|
||
AND after every test (never a TRUNCATE on ``api_tokens`` — the
|
||
shared DB may hold the owner's real tokens); the module teardown
|
||
pins ``app_settings`` empty. This suite's only write is NONE (no
|
||
save is issued — the width contract needs no dirty row; test 4's
|
||
typed row is form-dirty only, never PUT): the per-test TRUNCATE
|
||
before/after is the same idiom as phases 168/171, and the teardown
|
||
guarantees no leftover row. The module app opts BACK IN over the
|
||
conftest's global ``BOR_SETTINGS_DB=0`` pin
|
||
(``env["BOR_SETTINGS_DB"] = "1"`` — the ``test_pictorial_pdf.py``
|
||
opt-in pattern; the panel's gate flow — ``#settings-disabled``
|
||
hidden — needs the overlay ON) and pins the branding base to
|
||
DISTINCTIVE values (``Env placeholder for panel E2E`` / ``Env footer
|
||
for panel E2E``, the phase-168 idiom). A module-scope ``.env`` mtime
|
||
guard pins LOCKED A1: no code path the suite drives opens the env
|
||
file for writing.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from collections.abc import Iterator
|
||
|
||
import pytest
|
||
from playwright.sync_api import Page, expect
|
||
from sqlalchemy import text
|
||
|
||
from app.config import Settings
|
||
from app.db import SessionLocal
|
||
from e2e.auth_helpers import login
|
||
from e2e.conftest import (
|
||
ADMIN_PASSWORD,
|
||
APP_PORT,
|
||
REPO,
|
||
SESSION_SECRET,
|
||
USE_REAL_LLM,
|
||
_wait_http,
|
||
)
|
||
|
||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||
|
||
#: The distinctive env base (the phase-168 panel-suite idiom — the
|
||
#: module boots exactly like the panel/layout suites, so the width
|
||
#: contract is proven against the same app shape).
|
||
ENV_PLACEHOLDER = "Env placeholder for panel E2E"
|
||
ENV_FOOTER = "Env footer for panel E2E"
|
||
|
||
#: The typed (never saved) dirty-row value — test 4's Save must be
|
||
#: ENABLED (a disabled button is not tabbable) for the keyboard
|
||
#: walk.
|
||
DIRTY_VALUE = "E2E width brain"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-module app env (the phase-171 env block — the test_admin_settings_panel.py
|
||
# fixture, verbatim)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _boot_app(port: int, settings_db: str) -> subprocess.Popen[bytes]:
|
||
"""One module app (the shared env block + the task's pins), on
|
||
``port`` with the overlay ``settings_db`` (``"1"`` / ``"0"``)."""
|
||
env = dict(os.environ)
|
||
env.pop("DEBUGPY", None)
|
||
env["BOR_ENVIRONMENT"] = "e2e"
|
||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||
env["BOR_LLM_BASE_URL"] = (
|
||
"https://aipi.reeseapps.com/v1"
|
||
if USE_REAL_LLM
|
||
else f"http://127.0.0.1:{_MOCK_PORT}/v1"
|
||
)
|
||
# The E2E-calibrated gate pins (the conftest pattern — the mock's
|
||
# token-overlap distribution, explicit so an operator's local
|
||
# .env cannot leak a different gate into the suite's app).
|
||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||
env.setdefault(
|
||
"BOR_DATABASE_URL",
|
||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||
)
|
||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||
env["BOR_DOCS_REPO"] = ""
|
||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||
Settings.model_fields["suggestions"].default
|
||
)
|
||
# The app name stays the CODE default (the theme-suite pin — the
|
||
# assertions below name it).
|
||
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
|
||
# Phase 176: the module opts BACK IN over the conftest's global
|
||
# BOR_SETTINGS_DB=0 pin (the test_pictorial_pdf.py pattern) —
|
||
# the panel's gate flow (test 1) needs the overlay ON.
|
||
env["BOR_SETTINGS_DB"] = settings_db
|
||
# The distinctive env base: the module boots exactly like the
|
||
# phase-168/171 panel suites (the width contract is proven
|
||
# against the same app shape).
|
||
env["BOR_INPUT_PLACEHOLDER"] = ENV_PLACEHOLDER
|
||
env["BOR_FOOTER_TEXT"] = ENV_FOOTER
|
||
env["BOR_GIT_SOURCES"] = ""
|
||
return subprocess.Popen(
|
||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||
cwd=REPO,
|
||
env=env,
|
||
)
|
||
|
||
|
||
def _stop_app(proc: subprocess.Popen[bytes]) -> None:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def app_server(mock_llm: int) -> Iterator[str]:
|
||
"""The real app under test — per-module env: the branding vars
|
||
pinned to the DISTINCTIVE E2E values (the phase-168 idiom) and
|
||
``BOR_SETTINGS_DB=1`` (the module opts back in over the
|
||
conftest pin)."""
|
||
global _MOCK_PORT
|
||
_MOCK_PORT = mock_llm
|
||
proc = _boot_app(APP_PORT, "1")
|
||
try:
|
||
_wait_http(f"{APP_URL}/api/health")
|
||
yield APP_URL
|
||
finally:
|
||
_stop_app(proc)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def app_url(app_server: str) -> str:
|
||
return app_server
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB isolation + helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _clean_settings_state() -> None:
|
||
"""Fresh settings + theme + token state per test: truncate
|
||
``app_settings`` (a leftover row is the cross-suite poison this
|
||
phase's D6 exists to prevent) and the single-row
|
||
``ui_settings`` (the middleware reads it for EVERY page — the
|
||
effective-strings resolution ``/api/config`` uses), and delete
|
||
this suite's issued tokens (label-scoped on ``e2e-`` — never a
|
||
TRUNCATE: the shared DB may hold the owner's real tokens)."""
|
||
with SessionLocal() as db:
|
||
db.execute(text("TRUNCATE app_settings"))
|
||
db.execute(text("TRUNCATE ui_settings"))
|
||
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
|
||
db.commit()
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean(db_ready: None) -> Iterator[None]:
|
||
_clean_settings_state()
|
||
yield
|
||
_clean_settings_state()
|
||
# The module's final state: app_settings EMPTY (the D6 poison
|
||
# guard — the next suite's app must boot with no overrides).
|
||
with SessionLocal() as db:
|
||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||
|
||
|
||
@pytest.fixture(scope="module", autouse=True)
|
||
def _env_never_written() -> Iterator[None]:
|
||
"""LOCKED A1, mtime-pinned: no code path the suite drives opens
|
||
``.env`` for writing (the file is gitignored — a ``git status``
|
||
check cannot see it, so ``stat``'s mtime is the assertion)."""
|
||
env_file = REPO / ".env"
|
||
before = env_file.stat().st_mtime_ns if env_file.exists() else None
|
||
yield
|
||
after = env_file.stat().st_mtime_ns if env_file.exists() else None
|
||
assert before == after, "the app under test must never write .env"
|
||
|
||
|
||
def _login_admin(page: Page, app_url: str) -> None:
|
||
"""The phase-168 gate flow: the admin arrives at the settings
|
||
view (the gate down, the overlay on)."""
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/settings.html")
|
||
expect(page.locator("#nav-settings")).to_be_visible(timeout=15_000)
|
||
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
|
||
expect(page.locator("#settings-gate")).to_be_hidden()
|
||
expect(page.locator("#settings-disabled")).to_be_hidden() # the overlay is on
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. The "impossibly small" pin: a representative spread of rows —
|
||
# one text input, one select, one textarea — each renders its
|
||
# .settings-control ≥ 192px wide at 375px (12 rem, A3), the card
|
||
# scrolls horizontally, the page does not
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_mobile_value_inputs_are_usable_width(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
# The gate flow at the default (1280) viewport — the nav link is
|
||
# only visible on desktop (the ≤640px hamburger owns the nav
|
||
# pill); the width contract itself is proven at 375px below.
|
||
_login_admin(page, app_url)
|
||
|
||
page.set_viewport_size({"width": 375, "height": 900})
|
||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||
expect(page.locator("#settings-content")).to_be_visible()
|
||
|
||
# A representative spread: the FIRST row of each of three
|
||
# distinct control types (one type="text" input, one select, one
|
||
# textarea) among the catalogue's 78 rows (the phase-168
|
||
# catalogue guarantees text / number / select / textarea /
|
||
# password controls).
|
||
keys = page.evaluate(
|
||
"""() => {
|
||
const row = (sel) => {
|
||
const c = document.querySelector(sel);
|
||
return c ? c.closest("tr").dataset.key : null;
|
||
};
|
||
return {
|
||
text: row("tr.settings-row input.settings-control[type='text']"),
|
||
select: row("tr.settings-row select.settings-control"),
|
||
textarea: row("tr.settings-row textarea.settings-control"),
|
||
};
|
||
}"""
|
||
)
|
||
rows = page.evaluate(
|
||
"() => document.querySelectorAll('tr.settings-row').length"
|
||
)
|
||
assert keys["text"] is not None, (
|
||
f"the catalogue must carry a text-input row (catalogue = {rows} rows)"
|
||
)
|
||
assert keys["select"] is not None, "the catalogue must carry a select row"
|
||
assert keys["textarea"] is not None, "the catalogue must carry a textarea row"
|
||
assert len(set(keys.values())) == 3, (
|
||
f"the three probes must be three DISTINCT rows (got {keys!r})"
|
||
)
|
||
|
||
# The "impossibly small" pin: every probe's .settings-control
|
||
# bounding box is ≥ 192px (12 rem — owner-locked A3: the
|
||
# deliberate margin under the 13 rem rule). The pin is 12 rem;
|
||
# ≥ 200px is deliberately NOT asserted (13 rem ≈ 208px is the
|
||
# RULE, 12 rem the pin) so the pin is not brittle to sub-pixel
|
||
# rounding.
|
||
for kind, key in keys.items():
|
||
box = page.locator(
|
||
f'tr[data-key="{key}"] .settings-control'
|
||
).bounding_box()
|
||
assert box is not None, f"the {kind} control ({key}) must render at 375px"
|
||
assert box["width"] >= 192, (
|
||
f"the {kind} control ({key}) must be ≥ 192px (12 rem) at 375px "
|
||
f"(got {box['width']:.1f}px)"
|
||
)
|
||
|
||
# The TODO's own premise: the table is horizontally scrollable —
|
||
# at 375px the .settings-table's own scrollWidth exceeds the
|
||
# .table-wrap card's clientWidth (the 720px mobile floor,
|
||
# owner-locked A2)…
|
||
scroll = page.evaluate(
|
||
"""() => {
|
||
const card = document.querySelector(".settings-shell .table-wrap");
|
||
const table = document.querySelector(
|
||
".settings-shell .table-wrap .settings-table");
|
||
return { table: table.scrollWidth, card: card.clientWidth };
|
||
}"""
|
||
)
|
||
assert scroll["table"] > scroll["card"], (
|
||
f"at 375px the table must overflow the card horizontally "
|
||
f"(table {scroll['table']}px vs card {scroll['card']}px)"
|
||
)
|
||
# …while the PAGE itself does not overflow (the phase-171 pin —
|
||
# the scroll is the card's, not the page's).
|
||
page_width = page.evaluate("() => document.documentElement.scrollWidth")
|
||
assert page_width <= 375, (
|
||
f"the page must not overflow at 375px (got {page_width}px)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. The "the card scrolls, never the page" pin: overflow: auto,
|
||
# scrollWidth > clientWidth, no page overflow, the sticky thead
|
||
# survives a card scroll
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_mobile_table_still_scrolls_and_page_does_not_overflow(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
_login_admin(page, app_url)
|
||
|
||
page.set_viewport_size({"width": 375, "height": 900})
|
||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||
expect(page.locator("#settings-content")).to_be_visible()
|
||
|
||
# The .table-wrap is the bounded-scroll card (the phase-156
|
||
# catalogue contract): computed overflow auto…
|
||
overflow = page.evaluate(
|
||
"() => getComputedStyle("
|
||
"document.querySelector('.settings-shell .table-wrap')).overflow"
|
||
)
|
||
assert overflow == "auto", (
|
||
f"the card's computed overflow must be `auto` (got {overflow!r})"
|
||
)
|
||
# …and at 375px the card's horizontal scroll is active
|
||
# (scrollWidth > clientWidth — the 720px table floor, the
|
||
# TODO's premise).
|
||
dims = page.evaluate(
|
||
"""() => {
|
||
const card = document.querySelector(".settings-shell .table-wrap");
|
||
return { scroll: card.scrollWidth, client: card.clientWidth };
|
||
}"""
|
||
)
|
||
assert dims["scroll"] > dims["client"], (
|
||
f"the card's horizontal scroll must be active at 375px "
|
||
f"(scrollWidth {dims['scroll']}px vs clientWidth {dims['client']}px)"
|
||
)
|
||
# …while the PAGE-level width stays inside the viewport (1px
|
||
# rounding — the phase-171 pin).
|
||
page_width = page.evaluate("() => document.documentElement.scrollWidth")
|
||
assert page_width <= 375 + 1, (
|
||
f"the page must not overflow at 375px (got {page_width}px)"
|
||
)
|
||
|
||
# The sticky-``thead`` contract survives the new column floor:
|
||
# scroll the card down and the thead ths stay pinned at the
|
||
# card's top edge (computed position: sticky — the phase-156/168
|
||
# contract, inherited).
|
||
page.evaluate(
|
||
"() => { const c = document.querySelector('.settings-shell .table-wrap');"
|
||
" c.scrollTop = 300; }"
|
||
)
|
||
page.wait_for_timeout(100) # the sticky reposition settles
|
||
sticky = page.evaluate(
|
||
"() => getComputedStyle("
|
||
"document.querySelector("
|
||
"'.settings-shell .table-wrap .settings-table thead th'"
|
||
")).position"
|
||
)
|
||
assert sticky == "sticky", (
|
||
f"the thead th must stay sticky after a card scroll (got {sticky!r})"
|
||
)
|
||
pinned = page.evaluate(
|
||
"""() => {
|
||
const card = document.querySelector(".settings-shell .table-wrap");
|
||
const th = document.querySelector(
|
||
".settings-shell .table-wrap .settings-table thead th");
|
||
return { th: th.getBoundingClientRect().top,
|
||
card: card.getBoundingClientRect().top };
|
||
}"""
|
||
)
|
||
assert abs(pinned["th"] - pinned["card"]) <= 1, (
|
||
f"the thead th must stay pinned at the card's top edge after a "
|
||
f"scroll (th {pinned['th']:.1f}px vs card {pinned['card']:.1f}px)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. The "no desktop regression" pin: at 1440px the media query is
|
||
# INERT — no 13rem floor on the Value cell, no forced scroll
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_desktop_value_width_is_unchanged(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
_login_admin(page, app_url)
|
||
|
||
page.set_viewport_size({"width": 1440, "height": 900})
|
||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||
expect(page.locator("#settings-content")).to_be_visible()
|
||
|
||
# The fixed probe row (the first type="text" control) renders —
|
||
# the desktop layout is the pre-phase natural layout.
|
||
first = page.locator("tr.settings-row input[type='text']").first.bounding_box()
|
||
assert first is not None, "the first text control must render at 1440px"
|
||
assert first["width"] > 0 and first["height"] > 0, (
|
||
"the first text control must be non-degenerate at 1440px"
|
||
)
|
||
|
||
# The contract (the media query is INERT — owner-locked A1): the
|
||
# computed min-width of .settings-value-cell (and its header
|
||
# twin .settings-value-th) at 1440px is the UA 0px/auto —
|
||
# never 208px/208.0px (the 13rem floor binds only inside the
|
||
# ≤640px block; the 720px floor with it).
|
||
for selector in (".settings-value-cell", ".settings-value-th"):
|
||
min_width = page.evaluate(
|
||
f"() => getComputedStyle("
|
||
f"document.querySelector('{selector}')).minWidth"
|
||
)
|
||
normalized = min_width.strip().lower()
|
||
assert normalized in ("0px", "0.0px", "auto"), (
|
||
f"the 13rem floor must be inert at 1440px: {selector} computed "
|
||
f"min-width must be `0px`/`auto` (got {min_width!r})"
|
||
)
|
||
assert "208" not in min_width, (
|
||
f"the 13rem floor (208px) must not apply at 1440px "
|
||
f"({selector} min-width {min_width!r})"
|
||
)
|
||
|
||
# And no forced horizontal scroll at desktop: the table's
|
||
# scrollWidth stays inside the card's clientWidth (the 720px
|
||
# floor is inert — the 560px base rule is subsumed by the
|
||
# card's width).
|
||
dims = page.evaluate(
|
||
"""() => {
|
||
const card = document.querySelector(".settings-shell .table-wrap");
|
||
const table = document.querySelector(
|
||
".settings-shell .table-wrap .settings-table");
|
||
return { table: table.scrollWidth, card: card.clientWidth };
|
||
}"""
|
||
)
|
||
assert dims["table"] <= dims["card"], (
|
||
f"the table must not force horizontal scroll at 1440px "
|
||
f"(table {dims['table']}px vs card {dims['card']}px)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. PLAN §7.1 + rule 6: the keyboard walk under the new column
|
||
# floor at 375px, and ZERO external requests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_keyboard_order_and_no_external_requests(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
externals: list[str] = []
|
||
|
||
def _watch(request: object) -> None:
|
||
url = getattr(request, "url", "")
|
||
if url.startswith(("http://", "https://")) and "127.0.0.1" not in url:
|
||
externals.append(url)
|
||
|
||
page.on("request", _watch)
|
||
_login_admin(page, app_url)
|
||
|
||
page.set_viewport_size({"width": 375, "height": 900})
|
||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||
expect(page.locator("#settings-content")).to_be_visible()
|
||
|
||
# The first row's Value control — the walk's middle stop.
|
||
first_control_id = page.evaluate(
|
||
"() => document.querySelector('tr.settings-row .settings-control').id"
|
||
)
|
||
assert first_control_id.startswith("set-"), (
|
||
f"the first row's Value control must be a settings control "
|
||
f"(got {first_control_id!r})"
|
||
)
|
||
|
||
# A dirty row (typed, never saved — the suite leaves no override
|
||
# and issues no save): the walk's Save must be ENABLED (a
|
||
# disabled button is not tabbable).
|
||
page.fill(f"#{first_control_id}", DIRTY_VALUE)
|
||
expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes")
|
||
expect(page.locator("#settings-save")).to_be_enabled()
|
||
|
||
# The keyboard tab order still reaches filter → first row's Value
|
||
# control → Save settings → Clear all overrides (the
|
||
# phase-168/171 contract under the new column floor, at 375px).
|
||
# The walk starts at the skip-link — the document's FIRST
|
||
# tabbable element (a blur() would only continue the cycle after
|
||
# the last focused element, so the script focuses the cycle
|
||
# start; every move after it is a real Tab keypress).
|
||
page.evaluate("() => document.querySelector('a.skip-link').focus()")
|
||
seen: list[str] = []
|
||
for _ in range(200):
|
||
page.keyboard.press("Tab")
|
||
seen.append(
|
||
page.evaluate(
|
||
"""() => {
|
||
const el = document.activeElement;
|
||
if (!el) return "";
|
||
return el.id || (typeof el.className === "string" ? el.className : "");
|
||
}"""
|
||
)
|
||
)
|
||
i_filter = next(
|
||
(i for i, s in enumerate(seen) if s == "settings-filter"), None
|
||
)
|
||
i_first_input = next(
|
||
(i for i, s in enumerate(seen) if s == first_control_id), None
|
||
)
|
||
i_save = next(
|
||
(i for i, s in enumerate(seen) if s == "settings-save"), None
|
||
)
|
||
i_clear = next(
|
||
(i for i, s in enumerate(seen) if s == "settings-clear-all"), None
|
||
)
|
||
assert i_filter is not None, (
|
||
f"the walk never reached the filter (seen={seen[:20]})"
|
||
)
|
||
assert i_first_input is not None, (
|
||
f"the walk never reached the first row's Value control "
|
||
f"({first_control_id}; seen={seen[:20]})"
|
||
)
|
||
assert i_save is not None, "the walk never reached Save (the dirty row enables it)"
|
||
assert i_clear is not None, "the walk never reached Clear all"
|
||
assert i_filter < i_first_input, "the filter must precede the first input"
|
||
assert i_first_input < i_save, "the first input must precede Save"
|
||
assert i_save < i_clear, "Save must precede Clear all"
|
||
|
||
# The suite's only write is NONE: no save was issued (the dirty
|
||
# row is form-state only — the per-test TRUNCATE + the module
|
||
# teardown pin app_settings empty).
|
||
|
||
# Rule 6: the whole test issued ZERO external requests (the CSP
|
||
# default-src 'self' posture — every asset is local).
|
||
assert externals == [], f"external requests leaked: {externals}"
|