All documentation claims verify against the code. Everything is green — final verification complete.
## Phase 176 — Final Verification Report
- **Verified (all 3 tasks in `complete/`):** the mobile Value-column fix is in the working tree — `frontend/index.html` L1602 carries `class="settings-value-th"`; `frontend/assets/styles.css` has exactly two new rules inside the `@media (max-width: 640px)` block: `.settings-table { min-width: 720px }` and `.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem) }`; `settings.js` + `app/` byte-unchanged (diff = index.html + styles.css + unit test only).
- **Documented deviation (flagged in task-03 report, not silent):** spec's `min-width: 13rem` on the border-box cell left the control at 176px (< the 192px A3 pin); executor corrected the cell to `calc(13rem + 2rem)` so the control (100% of content) lands at exactly 13rem ≈ 208px — the A2 lock intent preserved, unit pin + CSS comment updated to match.
- **Tests/lint/coverage (exact commands, all green):** `uv run pytest --cov=app` → **4138 passed**, `app/` coverage **99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Completion criteria:** 375px controls ≥ 192px — ✅ (E2E `test_mobile_value_inputs_are_usable_width`); card scrolls / page doesn't overflow at 375px — ✅ (`test_mobile_table_still_scrolls_and_page_does_not_overflow`); 1440px media query inert — ✅ (`test_desktop_value_width_is_unchanged`); tab order + zero external requests — ✅; phase E2E isolated — ✅ 4/4; phase 171 isolated — ✅ 5/5; phase 168 isolated — ✅ 12/12; unit pins — ✅ 17/17; commit/move — the harness commits; phase dir already in `complete/`.
- **Documentation:** README.md and AGENTS.md reviewed in full — every claim spot-checked against code (E2E ports 8123/8901, defaults `BOR_COMPRESS_MIN_BYTES=860` / `BOR_LOG_LARGE_RESPONSE_BYTES=65536`, audit budget 262144, `.cell-clip`, `.bubble:empty`, `min(70vh, 64rem)`, aria-pressed toggle, CDN integration test, `.gitignore` entries, all referenced scripts/routes) — verified consistent, no changes needed.
- **Notable:** `image-1.png` (owner's TODO screenshot, referenced by the phase overview) sits untracked in the tree — left alone; not part of the phase's commit scope.
- **Next pending phase:** `177_themed_favicon`.
928 lines
44 KiB
Python
928 lines
44 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" class="settings-value-th">Value</th>' in thead, (
|
||
"phase 176: the Value th carries the .settings-value-th class "
|
||
"(the mobile 13rem column floor constrains the header cell "
|
||
"under auto table layout, not just the td's)"
|
||
)
|
||
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()}"
|
||
)
|
||
|
||
|
||
# ---------- phase 176 (task 01): the mobile Value-column floor ----------
|
||
|
||
|
||
def test_settings_mobile_value_column_floor() -> None:
|
||
"""Phase 176 (task 01; TODO.md L3, owner-locked A1/A2): on a
|
||
phone (≤640px) the Settings table's Value inputs rendered 2–3
|
||
characters wide — the Setting cells' nowrap labels (the
|
||
``.settings-table td`` rule) exhausted the table's 560px floor
|
||
and collapsed the ``min-width: 0`` Value cells. The owner's
|
||
direction: "the table is horizontally scrollable, so we can have
|
||
slightly wider input fields." Pinned here (static, no browser):
|
||
|
||
* the Value column — ``.settings-value-cell`` (the td) and
|
||
``.settings-value-th`` (the th; task 01 added the class so the
|
||
column floor constrains the header cell too, under auto table
|
||
layout) — gets a ``min-width: calc(13rem + 2rem)`` floor: the
|
||
global border-box model (``* { box-sizing: border-box }``)
|
||
makes a cell's min-width a BORDER-box, so the cell is floored
|
||
at the locked 13rem of CONTENT (≈208px ≈ 14 mono chars at the
|
||
controls' 0.85rem) plus the td's own 1rem × 2 padding — the
|
||
``.settings-control`` (``width: 100%`` of the cell's content)
|
||
thus lands at the locked 13rem (the A2 intent, "slightly
|
||
wider input fields", and the A3 pin's 12rem / 192px threshold
|
||
with a 16px margin); a bare ``13rem`` border-box would only
|
||
leave 176px of content (the control), under the A3 pin, and
|
||
* the table's mobile scroll floor is raised to ``720px`` while
|
||
the base rule keeps ``560px`` (≥641px — the desktop layout
|
||
stays byte-identical, A1/A2).
|
||
|
||
Both rules live ONLY inside the existing ``@media (max-width:
|
||
640px)`` block (A1 — mobile-only scope): the selectors occur
|
||
exactly once in the whole file, and that one occurrence sits
|
||
inside the media block (the slice is by open/close markers —
|
||
the inner rules are 2-space-indented, so the first column-zero
|
||
``}`` after the open is the block's close; no hardcoded line
|
||
range, so the pin survives an insertion above it). The 720px
|
||
floor + the ``.table-wrap`` card's ``overflow: auto`` mean the
|
||
phone scrolls the CARD, never the page (the phase-171 "no
|
||
horizontal page overflow" E2E pin holds). 720px is a bare
|
||
length literal and the cell floor is the locked 13rem of content
|
||
+ the cell's 2rem of padding — NO new CSS custom property (B3
|
||
binds palette variables; a length in the mobile block is the
|
||
idiom the neighbouring ``width: 100%`` rules already use), so
|
||
``test_styles_css_settings_block_uses_existing_tokens_only``
|
||
stays green unchanged."""
|
||
css = _read(ASSETS / "styles.css")
|
||
# --- the ≤640px block, sliced by its open/close markers.
|
||
open_marker = "@media (max-width: 640px) {"
|
||
mobile_open = css.index(open_marker)
|
||
mobile_close = css.index("\n}", mobile_open + len(open_marker))
|
||
mobile = css[mobile_open:mobile_close + 2]
|
||
# --- the Value rule: exactly ONCE in the whole file, and that
|
||
# one occurrence sits INSIDE the ≤640px block (A1: no desktop
|
||
# copy, no stray rule elsewhere).
|
||
value_selector = ".settings-value-cell, .settings-value-th"
|
||
assert css.count(value_selector) == 1, (
|
||
"the Value rule must occur exactly once in styles.css"
|
||
)
|
||
assert mobile.count(value_selector) == 1, (
|
||
"the Value rule must sit inside the ≤640px block (A1: "
|
||
"mobile-only — at ≥641px the rule is inert)"
|
||
)
|
||
value_rule = re.search(
|
||
r"\.settings-value-cell, \.settings-value-th \{([^{}]*)\}", mobile
|
||
)
|
||
assert value_rule, "the Value rule must declare the column floor"
|
||
assert "min-width: calc(13rem + 2rem)" in value_rule.group(1), (
|
||
"the Value column (th AND td) is floored at 13rem of content "
|
||
"(≈208px ≈ 14 mono chars — A2: 'slightly wider input fields') "
|
||
"+ the cell's 2rem of padding (the global border-box model — "
|
||
"a bare 13rem border-box would leave the control at 176px, "
|
||
"under the A3 192px pin)"
|
||
)
|
||
# --- the table floor: BOTH rules pinned — the base keeps 560px
|
||
# for ≥641px (and must not know 720px) while the mobile block
|
||
# raises it to 720px (the phone scrolls the card — the
|
||
# .table-wrap overflow: auto contract, never the page).
|
||
base = re.search(r"\.settings-table \{([^{}]*)\}", css)
|
||
assert base, "styles.css must style .settings-table (the base rule)"
|
||
assert "min-width: 560px" in base.group(1), (
|
||
"the base .settings-table floor stays 560px (≥641px — "
|
||
"unchanged, A1)"
|
||
)
|
||
assert "720px" not in base.group(1), (
|
||
"the base .settings-table rule must not know 720px (the "
|
||
"720px floor belongs to the mobile block only, A1)"
|
||
)
|
||
mtable = re.search(r"\.settings-table \{([^{}]*)\}", mobile)
|
||
assert mtable, (
|
||
"the ≤640px block must carry the .settings-table floor rule"
|
||
)
|
||
assert "min-width: 720px" in mtable.group(1), (
|
||
"the mobile table floor is 720px (A2: the phone scrolls "
|
||
"the card horizontally)"
|
||
)
|
||
# The 720px declaration exists exactly once in the whole file —
|
||
# and it is the mobile block's (no ≥641px copy anywhere).
|
||
assert css.count("min-width: 720px") == 1 and (
|
||
"min-width: 720px" in mobile
|
||
), "the 720px floor must exist exactly once, in the ≤640px block"
|
||
# --- the index.html static thead: the Value th carries the
|
||
# .settings-value-th class so the column floor constrains the
|
||
# header cell too (deterministic under auto table layout —
|
||
# a plain <th> would otherwise stay unconstrained).
|
||
html = _html()
|
||
table_start = html.find('class="settings-table"')
|
||
assert table_start != -1, "the settings table must exist"
|
||
thead = html[table_start: html.find("</thead>", table_start)]
|
||
assert '<th scope="col" class="settings-value-th">Value</th>' in thead, (
|
||
"the settings thead's Value th carries the settings-value-th "
|
||
"class (task 01: the column floor is deterministic under "
|
||
"auto table layout)"
|
||
)
|
||
|
||
|
||
# ---------- 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"
|
||
)
|