phase: 168_admin_settings_panel
**Phase 168 — Admin Settings panel — final verification: PASS** 1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch. 2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override). 3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests). 4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.** 5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**. 6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326). 7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green. 8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**. 9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol). 10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's). 11. **Next pending phase:** `170_edit_bubble_sizing`.
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
"""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).
|
||||
* 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)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- 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"
|
||||
)
|
||||
Reference in New Issue
Block a user