phase: 171_admin_settings_layout
Expected state: only `00_phase.md` remains in `todo/` (the harness moves it on success; I must not touch it). All verification is complete. --- **Phase 171 final verification pass — report** - **Verified (all 4 tasks complete, CSS-only diff confirmed byte-exact to contract):** `#settings-content` flex column + `gap: 1.25rem` (tasks 01); `.settings-group-row th` band `--brand-soft` + 700 + `--line` top hairline (02); `#settings-save:disabled` `cursor: default` (03); new phase-dedicated Playwright suite (04). Mobile media queries and `settings.js`/`app/` byte-untouched, as pinned. - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4069 passed, app/ 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**. - **E2E (in isolation):** `test_admin_settings_layout.py` → **5 passed**; `test_admin_settings_panel.py` (phase 168) → **12 passed**; `test_wide_desktop_column.py` → **3 passed**. - **Completion criteria:** search/table no-intersection + gapped action row at 1440/375 px ✅ · distinct group bands (bg ≠ card, weight 700, ≥1px top hairline) ✅ · disabled Save `cursor: default` with §7.4 dim + aria-busy lifecycle intact ✅ · full suite + coverage + lint ✅ · all three E2E suites isolated ✅ · atomic commit + dir move = **harness's job** (task files already in `complete/`; per protocol I left every change uncommitted in the working tree). - **Documentation:** README.md (all 1487 lines) and AGENTS.md (all 80 lines) fully reviewed — every config default cross-checked vs `app/config.py`, all quick-reference commands verified against existing files, AGENTS.md rules/contracts verified vs code — **verified consistent, no changes needed** (phase 171 is CSS-only: no setup/command/env/API impact). - **Notable:** one phase-168 test failed on the first isolated run solely because an unrelated `undercooked` dev server held port 8124 (the suite's `DISABLED_PORT`); re-ran with the suite's built-in `E2E_APP_PORT=8300` override — no code change, foreign process untouched. Post-run DB state clean (`app_settings` 0, `ui_settings` 0, e2e tokens 0). - **Next pending phase:** none — phase 171 was the last `todo/` phase.
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
"""Phase 171 E2E (Playwright): the admin Settings page LAYOUT — the
|
||||
four layout contracts from ``TODO.md`` L4, proven in a real browser.
|
||||
|
||||
Source: ``TODO.md`` L4 — "In the admin settings page: The search
|
||||
settings text box overlaps settings table. The settings category
|
||||
headers are not differentiated from the settings themselves, making
|
||||
them confusing at a glance. The save settings and clear all
|
||||
overrides are smushed right up against the settings table. The save
|
||||
settings shows a loading cursor when moused over rather than just
|
||||
being disabled."
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov
|
||||
|
||||
Test → contract mapping (one story, one phase, one isolated file):
|
||||
|
||||
1. ``test_search_row_is_clear_of_the_table`` — the "overlap" pin:
|
||||
at 1440px and 375px the ``#settings-filter`` box and the
|
||||
``.table-wrap`` card's box do not intersect (the vertical gap
|
||||
between the search row's bottom and the card's top is > 0), and
|
||||
the card keeps the phase-156/168 bounded-scroll + sticky-
|
||||
``thead`` contract under the new ``#settings-content`` rhythm.
|
||||
2. ``test_actions_row_is_gapped_from_the_table`` — the "smushed"
|
||||
pin: at 1440px and 375px the ``.settings-actions`` row is gapped
|
||||
from the card (the gap between the card's bottom and the action
|
||||
row's top is > 0) and ``#settings-save`` / ``#settings-clear-all``
|
||||
are both reachable (no clipping at either viewport).
|
||||
3. ``test_group_bands_are_distinct_from_the_rows`` — the "not
|
||||
differentiated" pin: the ``Retrieval & honesty`` and
|
||||
``LLM (aipi)`` ``tr.settings-group-row`` bands render; a band's
|
||||
computed background-color ≠ the ``.table-wrap`` card's computed
|
||||
background (the ``--surface`` row-cell fill), the band's
|
||||
computed font-weight is ≥ 700 and its computed border-top-width
|
||||
is ≥ 1px (the hairline — no merge with the card edge or a
|
||||
previous band).
|
||||
4. ``test_disabled_save_is_just_disabled`` — the "loading cursor"
|
||||
pin: before any edit ``#settings-save`` is disabled with
|
||||
``cursor: default`` (NOT ``wait``); editing one row (a
|
||||
``type="search"`` filter + a value change — the phase-168
|
||||
dirty-row flow) enables it (``cursor: pointer``) and a settled
|
||||
**Save settings** dims it back to ``default`` (the §7.4
|
||||
dimmed-``aria-busy`` lifecycle, phase 168, intact under the new
|
||||
cursor).
|
||||
5. ``test_settings_page_layout_and_accessibility`` — PLAN §7.1 +
|
||||
rule 6: no horizontal page overflow at 375px and 1440px, the
|
||||
table is ≥80% of the 72rem container (not a skinny list), the
|
||||
keyboard tab order still reaches filter → first row input →
|
||||
**Save settings** → **Clear all overrides** (the phase-168
|
||||
keyboard contract under the new rhythm), and the whole test
|
||||
issues ZERO external 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 test 4's
|
||||
one-row save (the lifecycle pin) — the per-test TRUNCATE before/
|
||||
after is the same idiom as phase 168, 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) and pins the
|
||||
branding base to DISTINCTIVE values (``Env placeholder for panel
|
||||
E2E`` / ``Env footer for panel E2E``). 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 panel-suite pin): "the DB row now
|
||||
#: wins" is a VISIBLE DOM change, not a no-op against the code
|
||||
#: default.
|
||||
ENV_PLACEHOLDER = "Env placeholder for panel E2E"
|
||||
ENV_FOOTER = "Env footer for panel E2E"
|
||||
|
||||
#: The save's own acceptance value (test 4 — the dirty-row flow).
|
||||
SAVED_PLACEHOLDER = "DB placeholder — panel saved this"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-module app env (the tuning/theme pattern — 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 168 (task 06): the module opts BACK IN over the conftest's
|
||||
# global BOR_SETTINGS_DB=0 pin (the test_pictorial_pdf.py pattern)
|
||||
# — or stays OUT for the kill-switch app (test 11).
|
||||
env["BOR_SETTINGS_DB"] = settings_db
|
||||
# The distinctive env base: "the DB row now wins" is a visible
|
||||
# DOM change, not a no-op against the default.
|
||||
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 panel's saved value
|
||||
must visibly WIN over them) 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The "overlap" pin: the search row clears the table card (and the
|
||||
# card keeps its bounded scroll + sticky thead under the new
|
||||
# rhythm)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_search_row_is_clear_of_the_table(page: Page, app_url: str, db_ready: None) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/settings.html")
|
||||
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
for width in (1440, 375):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||||
|
||||
filter_box = page.locator("#settings-filter").bounding_box()
|
||||
search_box = page.locator(".settings-search").bounding_box()
|
||||
card_box = page.locator(".settings-shell .table-wrap").bounding_box()
|
||||
assert filter_box is not None, f"the filter input must render at {width}px"
|
||||
assert search_box is not None, f"the search row must render at {width}px"
|
||||
assert card_box is not None, f"the table card must render at {width}px"
|
||||
|
||||
# The "overlap" pin: the filter input's box does not intersect
|
||||
# the card's box…
|
||||
assert filter_box["y"] + filter_box["height"] <= card_box["y"], (
|
||||
f"the search input overlaps the table card at {width}px "
|
||||
f"(input bottom {filter_box['y'] + filter_box['height']:.1f} "
|
||||
f"vs card top {card_box['y']:.1f})"
|
||||
)
|
||||
# …and the row as a whole clears the card: the vertical gap
|
||||
# between the search row's bottom and the card's top is > 0
|
||||
# (the #settings-content flex gap, phase 171 task 01).
|
||||
gap = card_box["y"] - (search_box["y"] + search_box["height"])
|
||||
assert gap > 0, (
|
||||
f"the search row must clear the table card at {width}px "
|
||||
f"(gap {gap:.1f}px)"
|
||||
)
|
||||
|
||||
# The card keeps the phase-156/168 contract under the new
|
||||
# rhythm: the sticky thead (the bounded scroll's sticky
|
||||
# header) is intact.
|
||||
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 at {width}px (got {sticky!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The "smushed" pin: the action row is gapped from the table card
|
||||
# and both action buttons are reachable (no clipping)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_actions_row_is_gapped_from_the_table(page: Page, app_url: str, db_ready: None) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/settings.html")
|
||||
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
for width in (1440, 375):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||||
|
||||
card_box = page.locator(".settings-shell .table-wrap").bounding_box()
|
||||
actions_box = page.locator(".settings-actions").bounding_box()
|
||||
assert card_box is not None, f"the table card must render at {width}px"
|
||||
assert actions_box is not None, f"the action row must render at {width}px"
|
||||
|
||||
# The "smushed" pin: the gap between the card's bottom and
|
||||
# the action row's top is > 0 (the #settings-content flex
|
||||
# gap, phase 171 task 01).
|
||||
gap = actions_box["y"] - (card_box["y"] + card_box["height"])
|
||||
assert gap > 0, (
|
||||
f"the action row must be gapped from the table card at "
|
||||
f"{width}px (gap {gap:.1f}px)"
|
||||
)
|
||||
|
||||
# Both action buttons are reachable — visible, non-degenerate,
|
||||
# and not clipped horizontally at either viewport (at 375px
|
||||
# the media query stacks the row; the buttons must not be
|
||||
# cut off).
|
||||
for selector in ("#settings-save", "#settings-clear-all"):
|
||||
expect(page.locator(selector)).to_be_visible()
|
||||
box = page.locator(selector).bounding_box()
|
||||
assert box is not None, f"{selector} must render at {width}px"
|
||||
assert box["width"] > 0 and box["height"] > 0, (
|
||||
f"{selector} must be non-degenerate at {width}px"
|
||||
)
|
||||
assert box["x"] >= 0, f"{selector} is clipped off-canvas at {width}px"
|
||||
assert box["x"] + box["width"] <= width + 1, (
|
||||
f"{selector} is clipped horizontally at {width}px "
|
||||
f"(right edge {box['x'] + box['width']:.1f} > {width})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The "not differentiated" pin: the group bands are distinct from
|
||||
# the rows (the --brand-soft tint, weight 700, the top hairline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_group_bands_are_distinct_from_the_rows(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/settings.html")
|
||||
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The pinned groups render (in the server's order — at least
|
||||
# these two).
|
||||
expect(
|
||||
page.locator("tr.settings-group-row", has_text="Retrieval & honesty")
|
||||
).to_be_visible()
|
||||
expect(page.locator("tr.settings-group-row", has_text="LLM (aipi)")).to_be_visible()
|
||||
|
||||
# The "not differentiated" pin: a band's computed
|
||||
# background-color ≠ the .table-wrap card's computed background
|
||||
# (the --surface row-cell fill), the band's font-weight is ≥
|
||||
# 700, and the band carries a ≥1px top hairline (no merge with
|
||||
# the card's top edge or the band above it).
|
||||
band = page.evaluate(
|
||||
"""() => {
|
||||
const th = document.querySelector('tr.settings-group-row th');
|
||||
const card = document.querySelector('.settings-shell .table-wrap');
|
||||
const t = getComputedStyle(th);
|
||||
return {
|
||||
band: t.backgroundColor,
|
||||
card: getComputedStyle(card).backgroundColor,
|
||||
weight: t.fontWeight,
|
||||
borderTop: parseFloat(t.borderTopWidth),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
assert band["band"] != band["card"], (
|
||||
f"the band must be tinted distinct from the card "
|
||||
f"({band['band']!r} vs {band['card']!r})"
|
||||
)
|
||||
assert int(band["weight"]) >= 700, (
|
||||
f"the band must be weight ≥ 700 (got {band['weight']!r})"
|
||||
)
|
||||
assert float(band["borderTop"]) >= 1.0, (
|
||||
f"the band must carry a ≥1px top hairline (got {band['borderTop']!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The "loading cursor" pin: a disabled Save is JUST disabled
|
||||
# (cursor: default, not wait) — the §7.4 lifecycle intact under
|
||||
# the new cursor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disabled_save_is_just_disabled(page: Page, app_url: str, db_ready: None) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/settings.html")
|
||||
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The search input is a type="search" control (the phase-168
|
||||
# client-side filter row)…
|
||||
assert page.locator("#settings-filter").get_attribute("type") == "search"
|
||||
|
||||
# Before any edit: Save is disabled and its computed cursor is
|
||||
# `default` — NOT `wait` (the loading-cursor pin, TODO L4).
|
||||
expect(page.locator("#settings-save")).to_be_disabled()
|
||||
cursor = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('#settings-save')).cursor"
|
||||
)
|
||||
assert cursor == "default", (
|
||||
f"a disabled Save must show `default` (got {cursor!r})"
|
||||
)
|
||||
assert cursor != "wait", "the loading cursor must be gone"
|
||||
|
||||
# Edit one row (the phase-168 dirty-row flow): filter to the row,
|
||||
# change the value…
|
||||
page.fill("#settings-filter", "placeholder")
|
||||
expect(page.locator('tr[data-key="input_placeholder"]')).to_be_visible()
|
||||
page.fill('tr[data-key="input_placeholder"] .settings-control', SAVED_PLACEHOLDER)
|
||||
expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes")
|
||||
# …the button ENABLES — cursor `pointer` (a live control)…
|
||||
expect(page.locator("#settings-save")).to_be_enabled()
|
||||
cursor = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('#settings-save')).cursor"
|
||||
)
|
||||
assert cursor == "pointer", (
|
||||
f"an enabled Save must show `pointer` (got {cursor!r})"
|
||||
)
|
||||
# …Save settings (the §7.4 in-flight state is the dimmed
|
||||
# :disabled + aria-busy) and wait for it to settle…
|
||||
page.click("#settings-save")
|
||||
expect(page.locator("#settings-status")).to_contain_text(
|
||||
"Saved 1 settings.", timeout=30_000
|
||||
)
|
||||
# …the settled state: back to disabled, dimmed (opacity 0.6),
|
||||
# the aria-busy removed, and the cursor `default` again (the
|
||||
# §7.4 lifecycle, phase 168, intact under the new cursor).
|
||||
expect(page.locator("#settings-save")).to_be_disabled()
|
||||
expect(page.locator("#settings-save")).not_to_have_attribute("aria-busy")
|
||||
style = page.evaluate(
|
||||
"""() => {
|
||||
const s = getComputedStyle(document.querySelector('#settings-save'));
|
||||
return { cursor: s.cursor, opacity: s.opacity };
|
||||
}"""
|
||||
)
|
||||
assert style["cursor"] == "default", (
|
||||
f"the settled Save must show `default` (got {style['cursor']!r})"
|
||||
)
|
||||
assert float(style["opacity"]) == 0.6, (
|
||||
f"the settled Save must be dimmed to 0.6 (got {style['opacity']!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. PLAN §7.1 + rule 6: no horizontal overflow at 375/1440, the
|
||||
# table is ≥80% of the container, the keyboard walk, zero
|
||||
# external requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_page_layout_and_accessibility(
|
||||
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(page, app_url, next="/settings.html")
|
||||
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# A dirty row (typed, never saved — the suite leaves no
|
||||
# override): the walk's Save must be ENABLED (a disabled
|
||||
# button is not tabbable).
|
||||
page.fill('tr[data-key="app_name"] .settings-control', "E2E layout brain")
|
||||
expect(page.locator("#settings-save")).to_be_enabled()
|
||||
|
||||
# No horizontal page overflow at 375px or at 1440px (the 72rem
|
||||
# column at every viewport — the table scrolls INSIDE its card,
|
||||
# the page itself never does).
|
||||
for width in (375, 1440):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||||
overflow = page.evaluate(
|
||||
"() => document.documentElement.scrollWidth <= window.innerWidth + 1"
|
||||
)
|
||||
assert overflow, f"horizontal overflow at {width}px"
|
||||
|
||||
# At 1440 the table is ≥80% of the 72rem container (not a
|
||||
# skinny wasted-space list — PLAN §7.1).
|
||||
ratio = page.evaluate(
|
||||
"""() => document.querySelector('.settings-shell .table-wrap')
|
||||
.getBoundingClientRect().width /
|
||||
document.querySelector('.settings-shell').getBoundingClientRect().width"""
|
||||
)
|
||||
assert ratio >= 0.8, f"the table must be ≥80% of the container (got {ratio:.2f})"
|
||||
|
||||
# The keyboard tab order still reaches filter → first row input →
|
||||
# Save settings → Clear all overrides (the phase-168 keyboard
|
||||
# contract under the new rhythm). 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.set_viewport_size({"width": 1280, "height": 800})
|
||||
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.startswith("set-")), 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, "the walk never reached a settings control"
|
||||
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"
|
||||
|
||||
# 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}"
|
||||
@@ -41,6 +41,25 @@ Pinned (task 05 spec):
|
||||
``.settings-src.is-*`` variants + the dirty-row marker and adds
|
||||
NO new ``:root`` variable (the phase-156 B3 rule — the badges
|
||||
are solid existing tokens).
|
||||
* phase 171 (task 01): ``styles.css`` styles ``#settings-content``
|
||||
as a flex column with the shell's own 1.25rem gap (the ONE
|
||||
uniform gap — the search row and the action row keep no
|
||||
margin), and the four 640px stacking rules survive unchanged.
|
||||
* phase 171 (task 02): the ``.settings-group-row th`` band is the
|
||||
``--brand-soft`` tint (the sticky-th solid — B3: no new palette
|
||||
variable), weight 700, and hairline-delimited above (a
|
||||
``--line`` border-top) — the band reads as a category at a
|
||||
glance and never merges with the card edge or the band above
|
||||
it (the ``.settings-table th`` family supplies the bottom
|
||||
border); the row cells stay on the card's ``--surface``.
|
||||
* phase 171 (task 03): a disabled ``#settings-save`` is *just*
|
||||
disabled — ``cursor: default`` (the ``.settings-reset:disabled``
|
||||
idiom; the ``wait`` loading cursor is gone), the §7.4 dim
|
||||
(``opacity: 0.6``) + ``aria-busy`` in-flight state intact, and
|
||||
``cursor: wait`` is absent from every ``.settings*`` /
|
||||
``#settings-*`` rule (the negative pin is scoped to the
|
||||
settings rules — the chat edit form's ``.edit-save`` keeps its
|
||||
own ``wait`` and is out of scope).
|
||||
* the FOUR pages that carry the Theme link (index / document /
|
||||
login / shared) now carry the Settings link too (the phase-34
|
||||
one-bar contract — test_nav_consistency pins the parity).
|
||||
@@ -564,6 +583,187 @@ def test_styles_css_settings_block_uses_existing_tokens_only() -> None:
|
||||
)
|
||||
|
||||
|
||||
# ---------- phase 171 (task 01): the #settings-content rhythm ----------
|
||||
|
||||
|
||||
def test_settings_content_is_a_flex_column_with_the_shell_gap() -> None:
|
||||
"""Phase 171 (task 01; TODO L4): ``#settings-content`` — the
|
||||
disabled banner, the status + error lines, the search row, the
|
||||
``.table-wrap`` card, and the Save / Clear-all row — is a
|
||||
flex column with the shell's own 1.25rem gap (ASSUMPTION A4:
|
||||
no new spacing value). ONE uniform gap kills both layout
|
||||
complaints: the search box clears the table card (the
|
||||
"overlap") and the action row separates from the table (the
|
||||
"smushed"). The gap is the ONLY spacing source: the search
|
||||
row and the action row keep NO margin, and the 640px mobile
|
||||
stacking composes with it unchanged."""
|
||||
css = _read(ASSETS / "styles.css")
|
||||
rule = re.search(r"#settings-content \{([\s\S]*?)\n\}", css)
|
||||
assert rule, (
|
||||
"styles.css must style #settings-content (the phase 171 rhythm rule)"
|
||||
)
|
||||
body = rule.group(1)
|
||||
for decl in (
|
||||
"display: flex",
|
||||
"flex-direction: column",
|
||||
"gap: 1.25rem",
|
||||
):
|
||||
assert decl in body, (
|
||||
f"#settings-content must declare {decl} (the shell's rhythm, A4)"
|
||||
)
|
||||
# The gap is the ONLY spacing source (the task's verification
|
||||
# by reading, pinned): the search row and the action row got
|
||||
# NO new margin — the .settings-status / .settings-error
|
||||
# min-heights reserve their own lines, the gap is additive.
|
||||
for selector in (".settings-search", ".settings-actions"):
|
||||
srule = re.search(rf"{re.escape(selector)} \{{([\s\S]*?)\n\}}", css)
|
||||
assert srule, f"styles.css must style {selector}"
|
||||
assert "margin" not in srule.group(1), (
|
||||
f"{selector} keeps no margin — the #settings-content gap is "
|
||||
"the only spacing source (phase 171 task 01)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- phase 171 (task 02): the group band is distinct ----------
|
||||
|
||||
|
||||
def test_settings_group_band_is_branded_bold_hairlined() -> None:
|
||||
"""Phase 171 (task 02; TODO L4, owner-locked A3): the
|
||||
``.settings-group-row th`` band — today ``background:
|
||||
var(--surface)`` (the SAME fill as the ``.table-wrap`` card,
|
||||
i.e. invisible) — becomes a band that reads as a category at a
|
||||
glance: the ``--brand-soft`` tint (the SOLID hex the sticky
|
||||
thead rule already documents — B3: no new palette variable;
|
||||
a themed page's ``#bor-theme`` override of the token handles
|
||||
the color — B4), weight 700, and a ``--line`` top hairline so
|
||||
a band is delimited above AND below (the ``border-bottom``
|
||||
comes from the ``.settings-table th`` family) and can never
|
||||
merge with the card's top edge or the band above it. The
|
||||
text stays ``--ink`` on ``--brand-soft`` (14.8:1, AA). Static,
|
||||
text-only, no animation — and the rule keeps its existing
|
||||
0.82rem / uppercase / letter-spacing typography."""
|
||||
css = _read(ASSETS / "styles.css")
|
||||
rule = re.search(r"\.settings-group-row th \{([\s\S]*?)\n\}", css)
|
||||
assert rule, (
|
||||
"styles.css must style .settings-group-row th (the group band)"
|
||||
)
|
||||
body = rule.group(1)
|
||||
for decl in (
|
||||
"background: var(--brand-soft)",
|
||||
"font-weight: 700",
|
||||
"border-top: 1px solid var(--line)",
|
||||
):
|
||||
assert decl in body, (
|
||||
f".settings-group-row th must declare {decl} (the phase 171 "
|
||||
"band — A3)"
|
||||
)
|
||||
# The old invisible fill is gone (the band is distinct from the
|
||||
# row cells' --surface by construction).
|
||||
assert "background: var(--surface)" not in body, (
|
||||
"the band must not sit on the card's --surface (the phase-168 "
|
||||
"invisible-band defect)"
|
||||
)
|
||||
# The text pairing + the existing typography survive.
|
||||
assert "color: var(--ink)" in body, (
|
||||
"the band text stays --ink (14.8:1 on --brand-soft, AA)"
|
||||
)
|
||||
for decl in (
|
||||
"font-size: 0.82rem",
|
||||
"text-transform: uppercase",
|
||||
"letter-spacing: 0.04em",
|
||||
):
|
||||
assert decl in body, f"the band keeps {decl} (task 02: typography unchanged)"
|
||||
# The bottom delimiter is the .settings-table th family's
|
||||
# border-bottom (the band is delimited above AND below) — pinned
|
||||
# so the band can never merge with the row beneath it.
|
||||
table_th = re.search(r"\.settings-table th, \.settings-table td \{([\s\S]*?)\n\}", css)
|
||||
assert table_th, "the .settings-table th/td family rule must exist"
|
||||
assert "border-bottom: 1px solid var(--line)" in table_th.group(1), (
|
||||
"the .settings-table th family supplies the band's bottom border"
|
||||
)
|
||||
# Static, text-only, no animation (nothing for
|
||||
# prefers-reduced-motion to still).
|
||||
assert "transition" not in body and "animation" not in body, (
|
||||
"the band is static (no animation)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- phase 171 (task 03): a disabled Save is just disabled ----------
|
||||
|
||||
|
||||
def _settings_rule_bodies(css: str) -> list[str]:
|
||||
"""Every CSS rule body whose selector part is a settings-page
|
||||
selector (``.settings*`` / ``#settings-*``) — the scope of the
|
||||
task-03 negative pin. Comments are stripped from the selector
|
||||
part before matching; rules inside the 640px media query match
|
||||
on their own selector (the media preamble is a separate match
|
||||
and is excluded). Non-settings controls (the chat edit form's
|
||||
``.edit-save`` keeps its own ``cursor: wait`` — a different
|
||||
control, out of scope) never match."""
|
||||
bodies: list[str] = []
|
||||
for m in re.finditer(r"([^{}]+)\{([^{}]*)\}", css):
|
||||
sel = re.sub(r"/\*[\s\S]*?\*/", "", m.group(1)).strip()
|
||||
if not sel:
|
||||
continue
|
||||
parts = [p.strip() for p in sel.split(",")]
|
||||
if any(p.startswith((".settings", "#settings-")) for p in parts):
|
||||
bodies.append(m.group(2))
|
||||
return bodies
|
||||
|
||||
|
||||
def test_settings_save_disabled_is_just_disabled_no_wait_cursor() -> None:
|
||||
"""Phase 171 (task 03; TODO L4, owner's words): a disabled
|
||||
**Save settings** is *just* disabled — its computed cursor is
|
||||
``default`` (the ``.settings-reset:disabled`` idiom), NOT the
|
||||
``wait`` (loading) cursor the owner saw on hover. The §7.4
|
||||
in-flight state survives unchanged: the **dimmed** ``:disabled``
|
||||
(``opacity: 0.6``) + ``aria-busy`` — the cursor was never part
|
||||
of the contract. The ``#settings-save:hover:not(:disabled)``
|
||||
rule is byte-unchanged (the §7.4 lifecycle's ``cursor: pointer``
|
||||
when enabled). And ``cursor: wait`` is ABSENT from every
|
||||
settings-page rule (the house negative-pin idiom, scoped to the
|
||||
``.settings*`` / ``#settings-*`` selectors — the chat edit
|
||||
form's ``.edit-save`` rule keeps its own ``wait`` and must not
|
||||
fail this pin)."""
|
||||
css = _read(ASSETS / "styles.css")
|
||||
rule = re.search(r"#settings-save:disabled \{([^{}]*)\}", css)
|
||||
assert rule, "styles.css must style #settings-save:disabled"
|
||||
body = rule.group(1)
|
||||
assert "cursor: default" in body, (
|
||||
"#settings-save:disabled must be cursor: default (the "
|
||||
".settings-reset:disabled idiom — just disabled, task 03)"
|
||||
)
|
||||
assert "opacity: 0.6" in body, (
|
||||
"the §7.4 dim survives (opacity 0.6 — the in-flight state)"
|
||||
)
|
||||
assert "cursor: wait" not in body, (
|
||||
"the wait (loading) cursor is gone from the disabled save"
|
||||
)
|
||||
# The hover rule is byte-unchanged (task 03: the :not(:disabled)
|
||||
# guard + the brand hover pair stand).
|
||||
hover = re.search(
|
||||
r"#settings-save:hover:not\(:disabled\) \{([^{}]*)\}", css
|
||||
)
|
||||
assert hover, "the #settings-save:hover:not(:disabled) rule must exist"
|
||||
hbody = hover.group(1)
|
||||
assert "background: var(--brand-hover)" in hbody
|
||||
assert "color: var(--bg)" in hbody
|
||||
assert "cursor: wait" not in hbody
|
||||
# The idiom it mirrors stands (the .settings-reset:disabled
|
||||
# cursor: default the comment references is live).
|
||||
reset = re.search(r"\.settings-reset:disabled \{([^{}]*)\}", css)
|
||||
assert reset and "cursor: default" in reset.group(1), (
|
||||
"the .settings-reset:disabled idiom (cursor: default) must stand"
|
||||
)
|
||||
# The negative pin: no settings-page rule (a .settings* /
|
||||
# #settings-* selector) may paint cursor: wait.
|
||||
for b in _settings_rule_bodies(css):
|
||||
assert "cursor: wait" not in b, (
|
||||
"no settings-page rule may paint cursor: wait (phase 171 "
|
||||
f"task 03): {b.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the four Theme-bearing pages carry the Settings link ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user