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.
809 lines
37 KiB
Python
809 lines
37 KiB
Python
"""Unit: the phase-168 Settings view wiring (task 05) — the static
|
|
source-level pins for the shell's eighth view.
|
|
|
|
The browser behavior is E2E-gated (the phase-168 story suite —
|
|
``test_admin_settings_panel.py``, task 06); here we pin the
|
|
source-level invariants the "views of one document" architecture
|
|
depends on, so a silent regression is caught without a browser
|
|
(house pattern: ``tests/unit/test_thinking_scroll.py`` /
|
|
``test_frontend_router.py`` read the sources and assert on their
|
|
mechanisms).
|
|
|
|
Pinned (task 05 spec):
|
|
|
|
* ``index.html`` carries the ``#view-settings`` section (ship-hidden
|
|
+ inert + focusable — the WCAG pair, AGENTS.md rule 5) with the
|
|
``#settings-gate`` (ship-hidden, its Sign in returning to the
|
|
Settings view), the ship-hidden ``#settings-content`` holding the
|
|
``#settings-disabled`` kill-switch banner (D6), the ONE
|
|
``role="status"`` region, the ``role="alert"`` error line, the
|
|
client-side search input, the ``.table-wrap`` catalogue with the
|
|
visually-hidden caption + the four-column thead, and the action
|
|
row (``#settings-save`` — ships disabled — ``#settings-clear-all``
|
|
+ the aria-hidden ``#settings-dirty`` hint); the ``#nav-settings``
|
|
link ships hidden (admin-only).
|
|
* ``router.js`` maps ``/settings.html`` in ALL THREE tables
|
|
(VIEW / VIEW_PATH / VIEW_MODULES — a static specifier so the
|
|
Containerfile's esbuild stage inlines it into the router bundle)
|
|
+ the per-view title/description (the router is the SINGLE writer
|
|
of both).
|
|
* ``app/main.py``'s shell tuple and ``app/core/caching.py``'s
|
|
``HTML_PAGES`` both list ``/settings.html`` (the no-cache +
|
|
``?v=`` contract, A19).
|
|
* ``header.js`` reveals ``#nav-settings`` keyed off ``admin`` (NOT
|
|
``signedIn`` — a token user never sees it), the same two-line
|
|
null-safe pattern as the other admin-only links.
|
|
* ``settings.js`` builds the DOM with ``createElement`` /
|
|
``textContent`` — NO ``innerHTML`` (template-literal or not) and
|
|
NO ``http(s)://`` reference (rule 6 — everything local; the
|
|
no-HTML-strings house rule).
|
|
* ``styles.css`` declares ``.settings-table`` + the three
|
|
``.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).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
ASSETS = FRONTEND / "assets"
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
assert path.is_file(), f"missing {path}"
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _html() -> str:
|
|
return _read(FRONTEND / "index.html")
|
|
|
|
|
|
# ---------- the shell markup: the #view-settings section ----------
|
|
|
|
|
|
def test_settings_view_section_ships_hidden_inert_focusable() -> None:
|
|
"""The #view-settings section lives in the shell's single main,
|
|
ships hidden AND inert (the WCAG a11y pair — a hidden view must
|
|
not receive focus or keyboard traversal), and is focusable
|
|
(tabindex="-1" — the router focuses it on user-initiated
|
|
switches)."""
|
|
html = _html()
|
|
view = html.find('<section class="view" id="view-settings"')
|
|
assert view != -1, "the #view-settings section must be in the shell"
|
|
tag_end = html.find(">", view)
|
|
tag = html[view:tag_end]
|
|
assert "hidden" in tag and "inert" in tag, (
|
|
"the folded view ships hidden AND inert"
|
|
)
|
|
assert 'tabindex="-1"' in tag, "the target view is focusable"
|
|
assert 'aria-label="Settings"' in tag, "the view is labelled"
|
|
main_end = html.find("</main>", view)
|
|
assert view < main_end, "the view section lives inside the single main"
|
|
# The 72rem .container — no new width token (PLAN §7.1).
|
|
body = html[view:main_end]
|
|
assert 'class="container settings-shell"' in body, (
|
|
"the view rides the standard 72rem .container (no new width)"
|
|
)
|
|
|
|
|
|
def test_settings_page_head_states_the_rule_in_the_owner_terms() -> None:
|
|
"""The page-head: <h1>Settings</h1> + a .page-sub that states
|
|
the rule in the owner's terms — the value is stored in the
|
|
database and wins over the environment variables; clearing hands
|
|
control back to the environment (and the .env file is never
|
|
written — LOCKED A1)."""
|
|
html = _html()
|
|
view = html.find('<section class="view" id="view-settings"')
|
|
main_end = html.find("</main>", view)
|
|
body = html[view:main_end]
|
|
assert "<h1>Settings</h1>" in body
|
|
sub = re.search(r'<p class="page-sub">([\s\S]*?)</p>', body)
|
|
assert sub, "the .page-sub must exist"
|
|
text = " ".join(sub.group(1).split())
|
|
assert "stored in the database" in text, (
|
|
"the sub states: the value is stored in the database"
|
|
)
|
|
assert "win over" in text, "the sub states: it wins over the env vars"
|
|
assert "hands control back to the environment" in text, (
|
|
"the sub states: clearing hands control back to the environment"
|
|
)
|
|
|
|
|
|
def test_settings_gate_is_the_tokens_gate_shape_ships_hidden() -> None:
|
|
"""The anonymous sign-in gate: the EXACT #tokens-gate shape —
|
|
the .sources-gate visual language, ship-hidden, its heading
|
|
naming what is admin-only, and its Sign in returning to the
|
|
Settings view (?next=/settings.html — the no-JS fallback)."""
|
|
html = _html()
|
|
view = html.find('<section class="view" id="view-settings"')
|
|
main_end = html.find("</main>", view)
|
|
body = html[view:main_end]
|
|
gate = re.search(r'<section[^>]*id="settings-gate"[^>]*>', body)
|
|
assert gate, "the #settings-gate must exist"
|
|
assert "hidden" in gate.group(0), "#settings-gate must ship hidden"
|
|
assert 'class="sources-gate"' in gate.group(0), (
|
|
"the gate reuses the .sources-gate visual language"
|
|
)
|
|
assert 'aria-labelledby="settings-gate-title"' in gate.group(0)
|
|
assert "<h2 id=\"settings-gate-title\">Sign in to change settings</h2>" in body
|
|
assert 'href="/login.html?next=/settings.html"' in body, (
|
|
"the gate's Sign in returns to the Settings view (no-JS fallback)"
|
|
)
|
|
|
|
|
|
def test_settings_content_scaffold_and_feedback_regions() -> None:
|
|
"""The ship-hidden #settings-content holds, in order: the
|
|
#settings-disabled kill-switch banner (role=alert, ship-hidden —
|
|
D6: the mechanism is off, not the panel), the ONE status region
|
|
(role=status, aria-live=polite — the #tokens-status precedent),
|
|
the role=alert error line (ship-hidden — the #theme-error
|
|
precedent), the search row (label + type=search input), the
|
|
.table-wrap catalogue (the visually-hidden caption + the
|
|
four-column thead with the visually-hidden Actions header), and
|
|
the action row (Save — ships DISABLED — Clear all + the
|
|
aria-hidden dirty hint)."""
|
|
html = _html()
|
|
view = html.find('<section class="view" id="view-settings"')
|
|
main_end = html.find("</main>", view)
|
|
body = html[view:main_end]
|
|
content = re.search(r'<div[^>]*id="settings-content"[^>]*>', body)
|
|
assert content and "hidden" in content.group(0), (
|
|
"#settings-content must ship hidden (anonymous-safe)"
|
|
)
|
|
banner = re.search(
|
|
r'<div[^>]*class="kb-banner is-error"[^>]*id="settings-disabled"[^>]*>'
|
|
r"|<div[^>]*id=\"settings-disabled\"[^>]*>",
|
|
body,
|
|
)
|
|
assert banner, "the #settings-disabled banner must exist"
|
|
btag_match = re.search(r'<div[^>]*id="settings-disabled"[^>]*>', body)
|
|
assert btag_match, "the #settings-disabled banner tag must match"
|
|
btag = btag_match.group(0)
|
|
assert 'role="alert"' in btag and "hidden" in btag, (
|
|
"the banner is a ship-hidden role=alert"
|
|
)
|
|
status = re.search(
|
|
r'<span[^>]*id="settings-status"[^>]*>', body
|
|
)
|
|
assert status, "the #settings-status region must exist"
|
|
stag = status.group(0)
|
|
assert 'role="status"' in stag and 'aria-live="polite"' in stag, (
|
|
"the ONE status region (the #tokens-status precedent)"
|
|
)
|
|
error = re.search(r'<p[^>]*id="settings-error"[^>]*>', body)
|
|
assert error, "the #settings-error line must exist"
|
|
etag = error.group(0)
|
|
assert 'role="alert"' in etag and "hidden" in etag, (
|
|
"the error line is a ship-hidden role=alert"
|
|
)
|
|
# The search row: the label + the type=search input (client-side,
|
|
# the phase-101 D4 contract — no fetch).
|
|
assert '<label for="settings-filter">Search settings</label>' in body
|
|
filter_input = re.search(
|
|
r'<input[^>]*id="settings-filter"[^>]*>', body, re.S
|
|
)
|
|
assert filter_input, "the #settings-filter input must exist"
|
|
assert 'type="search"' in filter_input.group(0), (
|
|
"the filter is a type=search input"
|
|
)
|
|
# The scroll card: the .table-wrap + the visually-hidden caption
|
|
# + the four-column thead (the Actions header visually-hidden —
|
|
# the row buttons carry their own aria-labels).
|
|
assert 'class="table-wrap"' in body, (
|
|
"the catalogue rides the .table-wrap card (the phase-156 "
|
|
"bounded scroll + sticky header contract)"
|
|
)
|
|
table = re.search(r'<table[^>]*class="settings-table"[^>]*>', body)
|
|
assert table, "the .settings-table must exist"
|
|
thead = body[body.find('class="settings-table"') : body.find("</thead>")]
|
|
assert '<caption class="visually-hidden">' in thead, (
|
|
"the caption is visually-hidden (the phase-86 table pattern)"
|
|
)
|
|
assert thead.count('<th scope="col">') == 4, (
|
|
"the table is four columns (Setting | Value | Source | Actions)"
|
|
)
|
|
assert '<th scope="col">Setting</th>' in thead
|
|
assert '<th scope="col">Value</th>' in thead
|
|
assert '<th scope="col">Source</th>' in thead
|
|
assert (
|
|
'<th scope="col"><span class="visually-hidden">Actions</span></th>'
|
|
in thead
|
|
), "the Actions header is visually-hidden (the row buttons are labelled)"
|
|
# The action row: Save (type=button, ships DISABLED — no dirty
|
|
# rows yet), Clear all (type=button), the aria-hidden dirty hint.
|
|
save = re.search(r'<button[^>]*id="settings-save"[^>]*>', body)
|
|
assert save, "the #settings-save button must exist"
|
|
stag = save.group(0)
|
|
assert 'type="button"' in stag and "disabled" in stag, (
|
|
"Save is a type=button that ships disabled (no dirty rows)"
|
|
)
|
|
clear_all = re.search(r'<button[^>]*id="settings-clear-all"[^>]*>', body)
|
|
assert clear_all, "the #settings-clear-all button must exist"
|
|
assert 'type="button"' in clear_all.group(0)
|
|
assert "Clear all overrides" in body
|
|
hint = re.search(r'<span[^>]*id="settings-dirty"[^>]*>', body)
|
|
assert hint, "the #settings-dirty hint must exist"
|
|
assert 'aria-hidden="true"' in hint.group(0), (
|
|
"the hint is aria-hidden (the rows + the status carry the info)"
|
|
)
|
|
|
|
|
|
def test_settings_nav_link_ships_hidden_in_the_shell() -> None:
|
|
"""The #nav-settings link (the shell's eighth view) ships hidden
|
|
(admin-only) and UNstamped — the router is the single writer of
|
|
the active state, and a token user (role "user") must never see
|
|
the link (header.js reveals it for admin only)."""
|
|
html = _html()
|
|
match = re.search(r'<a[^>]*id="nav-settings"[^>]*>', html)
|
|
assert match, "the shell must carry the #nav-settings nav link"
|
|
link = match.group(0)
|
|
assert 'href="/settings.html"' in link, "the Settings link's href"
|
|
assert "hidden" in link, "#nav-settings ships hidden (admin-only)"
|
|
assert "is-active" not in link, "no static active stamp on the Settings link"
|
|
|
|
|
|
def test_nav_settings_sits_after_nav_theme() -> None:
|
|
"""The one-bar contract: the Settings link is the LAST admin link
|
|
(after #nav-theme) — the navbar order is part of the
|
|
test_nav_consistency inventory, so the position is pinned."""
|
|
html = _html()
|
|
theme_i = html.find('id="nav-theme"')
|
|
settings_i = html.find('id="nav-settings"')
|
|
sign_in_i = html.find('id="sign-in-link-mobile"')
|
|
assert 0 <= theme_i < settings_i < sign_in_i, (
|
|
"the Settings link sits after Theme and before the auth links"
|
|
)
|
|
|
|
|
|
# ---------- router.js: /settings.html in ALL THREE tables ----------
|
|
|
|
|
|
def test_router_js_maps_settings_in_all_three_tables() -> None:
|
|
"""router.js: VIEW maps /settings.html → settings, VIEW_PATH maps
|
|
settings → /settings.html, and VIEW_MODULES lazy-imports
|
|
./settings.js with a STATIC specifier (so the Containerfile's
|
|
esbuild --bundle of router.js inlines it — settings.js needs no
|
|
new esbuild line because no page references it directly;
|
|
tests/integration/test_containerfile_assets.py proves the
|
|
coverage). The router is the SINGLE writer of the per-view
|
|
title/meta — settings has both entries."""
|
|
js = _read(ASSETS / "router.js")
|
|
view_start = js.find("const VIEW = {")
|
|
view_body = js[view_start : js.find("\n}", view_start)]
|
|
assert '"/settings.html": "settings"' in view_body, (
|
|
"phase 168 task 05 folds the Settings view into the shell"
|
|
)
|
|
path_start = js.find("const VIEW_PATH = {")
|
|
path_body = js[path_start : js.find("\n}", path_start)]
|
|
assert 'settings: "/settings.html"' in path_body, (
|
|
"VIEW_PATH must map settings → /settings.html"
|
|
)
|
|
mods_start = js.find("const VIEW_MODULES = {")
|
|
mods_body = js[mods_start : js.find("\n}", mods_start)]
|
|
assert 'settings: () => import("./settings.js")' in mods_body, (
|
|
"the Settings view module is lazy-imported on first show "
|
|
"(a static specifier — the bundle inlines it)"
|
|
)
|
|
# The per-view head values (the router's single-writer contract).
|
|
assert 'settings: "Settings · Brain of Reese"' in js, (
|
|
"the Settings view's title (carried through brandName)"
|
|
)
|
|
assert (
|
|
"Edit any application setting — a saved value wins over the environment, "
|
|
"live on save; reset hands control back."
|
|
) in js, "the Settings view's meta description"
|
|
|
|
|
|
# ---------- the server-side registration (shell route + caching) ----------
|
|
|
|
|
|
def test_shell_route_and_html_pages_carry_settings() -> None:
|
|
"""app/main.py's _shell_routes tuple and caching.HTML_PAGES both
|
|
list /settings.html — without the tuple the deep link 404s
|
|
(the static mount has no settings.html file), without the
|
|
HTML_PAGES entry it would serve unversioned asset refs, which
|
|
the immutable-for-a-year asset caching would pin to stale CSS
|
|
after a deploy (the phase-35 lesson, the A19 contract)."""
|
|
main_py = _read(REPO / "app" / "main.py")
|
|
app_start = main_py.find("def create_app")
|
|
mount_i = main_py.find('app.mount("/", StaticFiles')
|
|
assert 0 <= app_start < mount_i, "create_app + the static mount must exist"
|
|
routes_block = main_py[app_start:mount_i]
|
|
assert '"/settings.html"' in routes_block, (
|
|
"main.py's shell tuple must list /settings.html (deep link)"
|
|
)
|
|
caching_py = _read(REPO / "app" / "core" / "caching.py")
|
|
pages_start = caching_py.find("HTML_PAGES: tuple")
|
|
pages_end = caching_py.find("\n)\n", pages_start)
|
|
pages_block = caching_py[pages_start:pages_end]
|
|
assert '"/settings.html"' in pages_block, (
|
|
"caching.HTML_PAGES must list /settings.html (no-cache + ?v=)"
|
|
)
|
|
# The shell route comes AFTER the API router registration and
|
|
# BEFORE the static mount (routes-first — the docstring contract).
|
|
include_i = main_py.find("app.include_router(admin_settings_router")
|
|
assert 0 <= app_start < include_i < mount_i, (
|
|
"the shell route sits between the API routers and the static mount"
|
|
)
|
|
|
|
|
|
# ---------- header.js: the reveal is admin-keyed, null-safe ----------
|
|
|
|
|
|
def test_header_js_reveals_settings_link_for_admin_only() -> None:
|
|
"""header.js reveals #nav-settings for role admin — the same
|
|
ship-hidden / reveal-for-admin contract as the other admin-only
|
|
links: the two-line reveal (`hidden = !admin`) sits in
|
|
initSharedHeader, null-safe (a page without the link is a
|
|
no-op), and the gate is the `admin` flag (role === "admin") —
|
|
a token user (role "user") never sees the link. The lookup +
|
|
reveal sit AFTER the whoami resolution (the admin flag exists
|
|
only once fetchWhoami has settled)."""
|
|
header_js = _read(ASSETS / "header.js")
|
|
fn = header_js.find("export async function initSharedHeader")
|
|
assert fn != -1, "initSharedHeader must exist"
|
|
body = header_js[fn:]
|
|
lookup = body.find('document.querySelector("#nav-settings")')
|
|
assert lookup != -1, "header.js must look up #nav-settings"
|
|
reveal = body.find("navSettings.hidden = !admin")
|
|
assert 0 <= lookup < reveal, (
|
|
"the reveal must be the two-line pattern: null-safe lookup, "
|
|
'then hidden = !admin (the admin flag — role === "admin")'
|
|
)
|
|
whoami = body.find("const whoami = await fetchWhoami()")
|
|
admin_flag = body.find('const admin = whoami.role === "admin"')
|
|
assert 0 <= whoami < admin_flag < lookup, (
|
|
"the reveal keys off the resolved admin flag"
|
|
)
|
|
# The gate is `admin`, NOT `signedIn` (a token user never sees
|
|
# it — the phase-79 contract).
|
|
reveal_line = body[lookup:reveal + 60]
|
|
assert "signedIn" not in reveal_line, (
|
|
"the reveal must key off admin (a token user never sees the link)"
|
|
)
|
|
|
|
|
|
# ---------- settings.js: DOM APIs only, no CDN ----------
|
|
|
|
|
|
def test_settings_js_builds_dom_with_element_apis_only() -> None:
|
|
"""settings.js follows the phase-76 view-module contract:
|
|
`export async function mount(root)` is the entry, the whoami
|
|
gate (fetchIsAdmin) runs in mount and the anonymous branch shows
|
|
the gate + hides the content + RETURNS with NO
|
|
/api/admin/settings request (the router 403s anonymous), and the
|
|
bor:view-refresh listener is armed ONLY in the ADMIN branch
|
|
(after the gate), gated on the started flag. The DOM is built
|
|
with createElement + textContent — NO innerHTML (template
|
|
literal or not: the XSS-safe-by-construction house rule) and NO
|
|
http(s):// reference (rule 6 — everything is local; the fetch
|
|
paths are same-origin)."""
|
|
js = _read(ASSETS / "settings.js")
|
|
assert "export async function mount(root)" in js, (
|
|
"mount(root) must be the module's entry (the phase-76 fold)"
|
|
)
|
|
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
|
|
"the view imports ONLY the shared cached whoami promise"
|
|
)
|
|
mount_i = js.find("export async function mount(root)")
|
|
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
|
assert 0 <= mount_i < gate_i, "the whoami gate must run in mount"
|
|
branch = js[gate_i:js.find("return;", gate_i)]
|
|
assert "fetch(" not in branch, (
|
|
"the anonymous branch must not fetch anything"
|
|
)
|
|
assert "gateEl.hidden = false" in branch
|
|
assert "contentEl.hidden = true" in branch
|
|
# The re-show refresh: armed in the ADMIN branch only (after the
|
|
# gate), gated on the first load (started).
|
|
listener = js.find('addEventListener("bor:view-refresh"')
|
|
assert 0 <= gate_i < listener, (
|
|
"the refresh listener is armed only in the ADMIN branch (after the gate)"
|
|
)
|
|
assert re.search(r"if \(started\) void loadSettings\(\)", js), (
|
|
"the listener is gated on the first load (started)"
|
|
)
|
|
# No HTML-string DOM building (the house rule) — innerHTML is
|
|
# absent from the file entirely (a template-literal innerHTML
|
|
# would also match this check).
|
|
assert "innerHTML" not in js, (
|
|
"the DOM is built with createElement + textContent — no "
|
|
"innerHTML anywhere (XSS-safe by construction)"
|
|
)
|
|
# No CDN (rule 6): no http(s):// reference in the module — the
|
|
# fetch paths are same-origin (/api/admin/settings…).
|
|
assert not re.search(r"https?://", js), (
|
|
"no CDN — every reference is local (AGENTS.md rule 6)"
|
|
)
|
|
# The API surface: GET the catalogue, PUT one value, DELETE one /
|
|
# DELETE all (the phase-168 API contract, task 04).
|
|
assert 'fetch("/api/admin/settings")' in js, "the catalogue GET"
|
|
assert "fetch(`/api/admin/settings/${key}`" in js, (
|
|
"the per-key PUT / DELETE"
|
|
)
|
|
# The §7.4 lifecycle: the save button is aria-busy + labelled
|
|
# while in flight, re-enabled in the same path (success AND
|
|
# failure — the saveInFlight guard).
|
|
assert 'setAttribute("aria-busy", "true")' in js
|
|
assert "removeAttribute(\"aria-busy\")" in js
|
|
assert 'textContent = "Saving…"' in js
|
|
|
|
|
|
def test_settings_js_dirty_tracking_and_stop_at_first_failure() -> None:
|
|
"""The K4 contract, pinned at source level: the dirty state is a
|
|
module Map (key → value); markDirty compares the control value
|
|
against the SERVED value (a masked field's served value is "" —
|
|
an empty input means "leave unchanged", never an accidental
|
|
wipe); Save walks the dirty rows IN DOM ORDER, PUTs { value },
|
|
and breaks at the first failure (the 422 detail lands in the
|
|
role=alert region, the load re-applies the remaining edits,
|
|
focus goes to the offending input); a successful save lands the
|
|
`Saved N settings.` status line + the restart-required
|
|
suffix."""
|
|
js = _read(ASSETS / "settings.js")
|
|
assert "const dirty = new Map();" in js, (
|
|
"the dirty state is a module Map"
|
|
)
|
|
mark = js.find("function markDirty(")
|
|
mark_body = js[mark:js.find("\n }", mark)]
|
|
assert 'item.secret ? "" : item.value ?? ""' in mark_body, (
|
|
"the served value is '' for a masked field (never an accidental wipe)"
|
|
)
|
|
save = js.find("async function saveSettings()")
|
|
save_body = js[save:js.find("\n }", save)]
|
|
assert "for (const row of dataRows())" in save_body, (
|
|
"Save walks the dirty rows in DOM order"
|
|
)
|
|
put_i = save_body.find("JSON.stringify({ value })")
|
|
break_i = save_body.find("break; // stop at the first failure (K4)")
|
|
assert 0 <= put_i < break_i, "the loop breaks at the first failure (K4)"
|
|
assert "showError(" in save_body, "the 422 detail lands in the error region"
|
|
assert "input.focus()" in save_body, (
|
|
"focus goes to the offending input (K4)"
|
|
)
|
|
assert '`Saved ${saved.length} settings.`' in save_body, (
|
|
"the one status line (role=status)"
|
|
)
|
|
assert "applies on the next restart" in save_body, (
|
|
"a saved restart-required row says so (the owner is never "
|
|
"told 'saved' and then sees nothing change)"
|
|
)
|
|
# The reset + clear-all contract: the DELETE one/all + the
|
|
# settled status lines.
|
|
assert "`${envVar} is back to the environment value.`" in js, (
|
|
"the per-row Reset status line"
|
|
)
|
|
assert "Cleared ${cleared} overrides." in js, (
|
|
"the Clear-all status line (N from the response)"
|
|
)
|
|
# The two-step confirm (the house history-confirm-* classes,
|
|
# focus to Yes, Escape / No cancels).
|
|
assert 'label.textContent = "Clear all overrides?"' in js
|
|
assert 'yes.className = "history-confirm-yes"' in js
|
|
assert 'no.className = "history-confirm-no"' in js
|
|
assert "yes.focus()" in js, "focus moves to Yes (keyboard confirm)"
|
|
assert "e.key === \"Escape\"" in js, "Escape cancels (the task contract)"
|
|
|
|
|
|
# ---------- styles.css: the settings block, no new :root tokens ----------
|
|
|
|
|
|
def test_styles_css_settings_block_uses_existing_tokens_only() -> None:
|
|
"""styles.css declares the .settings-table (full width — the
|
|
.git-sources-table language, no skinny list) + the three
|
|
.settings-src.is-* variants (SOLID existing tokens — B3) + the
|
|
dirty-row marker, and adds NO new :root variable (the phase-156
|
|
B3 rule — the badges are text, the color is decoration; a themed
|
|
page's #bor-theme override of the token handles the color)."""
|
|
css = _read(ASSETS / "styles.css")
|
|
assert "---------- Settings view (phase 168) ----------" in css, (
|
|
"the Settings view block must exist"
|
|
)
|
|
block = re.search(r"\.settings-table \{([\s\S]*?)\n\}", css)
|
|
assert block, "styles.css must style .settings-table"
|
|
assert "width: 100%" in block.group(1), (
|
|
"the catalogue is full width (no skinny list — PLAN §7.1)"
|
|
)
|
|
for variant, bg in (
|
|
("is-db", "var(--brand-soft)"),
|
|
("is-env", "var(--surface)"),
|
|
("is-default", "var(--line)"),
|
|
):
|
|
rule = re.search(
|
|
rf"\.settings-src\.{variant} \{{([\s\S]*?)\n\}}", css
|
|
)
|
|
assert rule, f"styles.css must style .settings-src.{variant}"
|
|
body = rule.group(1)
|
|
assert bg in body, f".settings-src.{variant} is the solid {bg} token (B3)"
|
|
# The badge is TEXT (B5): the text is never color-alone — the
|
|
# variant always pairs the background with an existing ink
|
|
# token (never a color declaration without a text color).
|
|
assert "color: var(--" in body, (
|
|
f".settings-src.{variant} carries an existing ink token (text, not color alone)"
|
|
)
|
|
# The dirty-row marker (the --brand left border on the row).
|
|
assert "settings-row.is-dirty" in css, (
|
|
"the dirty-row marker must exist (unsaved survives grayscale)"
|
|
)
|
|
dirty = re.search(r"\.settings-row\.is-dirty[^{]*\{([\s\S]*?)\n\}", css)
|
|
assert dirty and "var(--brand)" in dirty.group(1), (
|
|
"the dirty marker is the --brand left border"
|
|
)
|
|
# No new :root variable (B3, the phase-156 rule): the settings
|
|
# block must not declare a custom property — only var(--…)
|
|
# references of the pre-existing tokens (a themed page's
|
|
# #bor-theme override of the token handles the color).
|
|
settings_block = css[css.find("Settings view (phase 168)"): css.find(
|
|
"Shared page (phase 51"
|
|
)]
|
|
declarations = re.findall(r"^\s{2,}--[\w-]+\s*:", settings_block, re.M)
|
|
assert not declarations, (
|
|
f"the settings block must not declare :root variables (B3): "
|
|
f"{declarations}"
|
|
)
|
|
# The touch targets: the ≥44px rules (the house language) + the
|
|
# mobile 640px block stacks the search + action rows.
|
|
for selector in ("#settings-filter", ".settings-control"):
|
|
rule = re.search(rf"{re.escape(selector)} \{{([\s\S]*?)\n\}}", css)
|
|
assert rule, f"styles.css must style {selector}"
|
|
assert "min-height: 44px" in rule.group(1), f"{selector}: the ≥44px target"
|
|
mobile = re.search(
|
|
r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css
|
|
)
|
|
assert mobile, "the 640px media query must exist"
|
|
mbody = mobile.group(1)
|
|
assert ".settings-search { flex-direction: column; align-items: stretch; }" in mbody
|
|
assert "#settings-save { width: 100%; }" in mbody, (
|
|
"the Save pill is full width on phones (the ≥44px target stands)"
|
|
)
|
|
|
|
|
|
# ---------- 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 ----------
|
|
|
|
|
|
def test_settings_link_ships_on_every_theme_bearing_page() -> None:
|
|
"""The phase-34 one-bar contract — the SAME nav ships on every
|
|
page (test_nav_consistency pins the header inventory PARITY
|
|
across the shell pages, the document viewer, and the login
|
|
page), so #nav-settings (ship-hidden, admin-only) must be in
|
|
the #app-nav of EVERY header-bearing page: the shell +
|
|
document.html + login.html + shared.html. The doc-edit flow
|
|
page ships the reduced header (no admin links at all) and is
|
|
out of the contract."""
|
|
for page in (
|
|
FRONTEND / "index.html",
|
|
FRONTEND / "document.html",
|
|
FRONTEND / "login.html",
|
|
FRONTEND / "shared.html",
|
|
):
|
|
text = _read(page)
|
|
# The page must carry BOTH links — the Theme link (the
|
|
# phase-91 precedent) and the Settings link (this task).
|
|
theme = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
|
|
assert theme, f"{page.name} must carry the #nav-theme nav link"
|
|
match = re.search(r'<a[^>]*id="nav-settings"[^>]*>', text)
|
|
assert match, (
|
|
f"{page.name} must carry the #nav-settings nav link (one-bar)"
|
|
)
|
|
link = match.group(0)
|
|
assert 'href="/settings.html"' in link, (
|
|
f"{page.name}: the Settings link's href"
|
|
)
|
|
assert "hidden" in link, (
|
|
f"{page.name}: #nav-settings ships hidden (admin-only)"
|
|
)
|
|
assert "is-active" not in link, (
|
|
f"{page.name}: no static active stamp on the Settings link"
|
|
)
|
|
# The Settings link sits AFTER the Theme link (the order
|
|
# test_nav_consistency's inventory compares).
|
|
assert text.find('id="nav-theme"') < text.find('id="nav-settings"'), (
|
|
f"{page.name}: the Settings link follows the Theme link"
|
|
)
|