Files
brain-of-reese/tests/unit/test_theme_frontend.py
T
ducoterra 9188be259b
Build and Push Containers / build-and-push-app (push) Successful in 1m56s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 93_theme_semantic_completion
All verification is complete. Final report:

**Phase 93 — Theme semantic completion: FINAL VERIFICATION PASS — ALL GREEN**

- Verified full implementation in tree: migration `0016` (8 nullable semantic columns, applied at head), 17-var `BUILTIN_COLORS`/`COLOR_FIELDS`/`effective_settings`, API validation, `#view-theme` State-colors fieldset (17 pickers), `theme.js` FIELDS/PAIRS (5→8), `.page-head` surface panel (6 shell views + doc-edit + shared.html; login card / document sticky header audited as already-surfaced), mock_llm `content: None` fix
- Fixed 2 pre-existing defects (both fail identically on baseline `d4f38ad`, proven via worktree A/B): `test_nav_rename_sources` — expected nav tail missing the phase-91 "Theme" link; `test_stale_ui_copy` — now truncates `saved_chats` before/after (house `test_suggestion_chips` pattern) so the seed-chip contract is deterministic on the shared dev DB (owner's 22 saved chats triggered phase-80 last-3-questions)
- Tests: `uv run pytest --cov=app --cov-report=term-missing` → **1868 passed, app/ 99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
- E2E: dedicated `uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov` → **8/8 in isolation** (all-gray 17-color theme: zero residual color on saved-result/Stale/Revoked/Local/tool-call elements, text labels intact, gray heads non-transparent, pre-paint tag, Reset → byte-identical no-tag); 15 theme/header/nav/responsive suites green in isolation; full 85-file combined run: only the 2 fixed pre-existing failures + 1 combined-run artifact (`test_sync_upload_progress`, green in isolation)
- Completion criteria: (1) monochrome E2E ✓ (2) default byte-identical, no `#bor-theme` tag ✓ (3) all page heads on solid surface ✓ (4) suite/coverage/lint/E2E green ✓ (5) phases 01–92 no behavior change ✓ (6) commit left to harness per protocol
- Notable: cleaned stray uvicorn leftovers from prior implementation pass (owner's `--reload` dev server untouched); no deviations from the phase design
- Next pending phase: `94_ls_tree_drilldown`
2026-09-10 16:43:08 -04:00

279 lines
12 KiB
Python

"""Unit: the phase-93 Theme-tab frontend contract (task 02 — source
pins).
The Theme tab edits ALL 17 palette variables (the 9 identity + the 8
semantic state colors — B3 revised, owner permission 2026-09-10,
TODO.md L3) plus the 3 branding strings: 20 form fields total. The
live behavior (live preview, the Save/Reset PUT body, the served-theme
sync, the contrast warnings) is E2E-gated (``tests/e2e/
test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04);
here we pin the source-level invariants the editor's FIELDS-driven
design depends on, so a silent regression is caught without a browser
(house pattern: ``tests/unit/test_big_read_progress.py`` reads
frontend sources and asserts on their mechanisms):
* ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order
(the 3 branding strings, then the 17 colors in the server's
``theming.COLOR_FIELDS`` order — identity, brand, then state):
everything that iterates FIELDS (live preview, ``collectBody``'s PUT
body, ``clearPreview``, ``applyServedTheme``'s tag content) covers
the 8 semantic pickers automatically only if this order holds;
* ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA
(4.5:1) pairs: the five identity pairs + the three semantic
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
(decorative borders, no contrast duty — the same rule as
``--line`` / ``--grid-line``); the docstring mirror is pinned in
``tests/unit/test_theming.py``;
* ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids
+ visible labels; the 17 color inputs are ``type="color"`` and ship
the BUILT-IN static values — asserted against
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
house drift pattern — the same guard ``test_theming.py`` runs on
``BUILTIN_COLORS``), never a third hardcoded palette copy;
* the "State colors" fieldset sits AFTER the palette fieldset (the
task-02 form shape), and the palette legend's "five pairs" copy is
the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept).
"""
from __future__ import annotations
import re
from pathlib import Path
from app.core import theming
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
INDEX_HTML = FRONTEND / "index.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
THEME_JS = FRONTEND / "assets" / "theme.js"
def _js() -> str:
return THEME_JS.read_text(encoding="utf-8")
def _html() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _theme_view(body: str) -> str:
"""The ``#view-theme`` section slice (the router pin's convention:
from the section's opening tag to the ``</main>`` that closes the
single main)."""
view = body.find('<section class="view" id="view-theme"')
assert view != -1, "the #view-theme section must be in the shell"
main_end = body.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
return body[view:main_end]
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped (test_theming's parser)."""
css = STYLES_CSS.read_text(encoding="utf-8")
match = re.search(r":root\s*\{", css)
assert match is not None, "styles.css must have a :root block"
block = css[match.end() : css.index("}", match.end())]
block = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
# ---------- theme.js: FIELDS — the 20 form fields, in order ----------
def test_fields_lists_all_20_in_form_order() -> None:
"""``FIELDS`` has EXACTLY 20 entries — the 3 branding strings,
then the 17 color fields — in the form's own order (the palette
fieldset's 9 identity pickers, then the State colors fieldset's 8
semantic pickers), each with its E2E-stable ``theme-*`` id and
kind (the color entries are ``kind: "color"`` — the live preview,
``collectBody``, and the served-theme sync all key on it)."""
js = _js()
start = js.index("const FIELDS = [")
end = js.index("];", start)
fields = re.findall(
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
js[start:end],
)
assert len(fields) == 20, f"FIELDS must list exactly 20 entries, got {len(fields)}"
assert [(f, i) for f, i, _ in fields] == [
("app_name", "theme-app-name"),
("input_placeholder", "theme-placeholder"),
("footer_text", "theme-footer"),
("bg", "theme-bg"),
("surface", "theme-surface"),
("ink", "theme-ink"),
("ink_soft", "theme-ink-soft"),
("line", "theme-line"),
("grid_line", "theme-grid-line"),
("brand", "theme-brand"),
("brand_soft", "theme-brand-soft"),
("brand_ink", "theme-brand-ink"),
("ok_bg", "theme-ok-bg"),
("ok_ink", "theme-ok-ink"),
("err_bg", "theme-err-bg"),
("err_ink", "theme-err-ink"),
("err_line", "theme-err-line"),
("accent_bg", "theme-accent-bg"),
("accent_ink", "theme-accent-ink"),
("accent_line", "theme-accent-line"),
], "FIELDS must list the 20 fields in the form's order"
assert all(kind == "color" for _, _, kind in fields[3:]), (
"the 17 palette entries are all kind color"
)
assert all(kind == "string" for _, _, kind in fields[:3])
def test_fields_color_order_is_the_server_color_fields_order() -> None:
"""The 17 color entries of ``FIELDS`` follow the server's
``theming.COLOR_FIELDS`` order (identity, brand, then state) —
the invariant that keeps ``collectBody``'s PUT body, the
``applyServedTheme`` tag content, and the pre-paint tag in the
SAME order without a second ordering copy."""
js = _js()
start = js.index("const FIELDS = [")
end = js.index("];", start)
fields = re.findall(
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
js[start:end],
)
color_fields = [f for f, _, kind in fields if kind == "color"]
assert tuple(color_fields) == theming.COLOR_FIELDS, (
"FIELDS' color order must equal the server's COLOR_FIELDS order"
)
# ---------- theme.js: PAIRS — exactly the eight WCAG pairs ----------
def test_pairs_has_exactly_the_eight_pairs() -> None:
"""``PAIRS`` has EXACTLY 8 entries (the five identity pairs, then
the three semantic ink-on-bg pairs — phase 93) in the
authoritative order; no other pair is warned about (the list is
the whole warning surface)."""
js = _js()
start = js.index("const PAIRS = [")
end = js.index("];", start)
pairs = re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end])
assert pairs == [
("ink", "bg"),
("ink", "surface"),
("ink_soft", "surface"),
("bg", "brand"),
("brand_ink", "surface"),
("ok_ink", "ok_bg"),
("err_ink", "err_bg"),
("accent_ink", "accent_bg"),
], f"PAIRS must be exactly the eight pairs, got {pairs}"
def test_line_vars_are_excluded_from_pairs() -> None:
"""The two ``*_line`` state variables (like ``line`` /
``grid_line``) are DECORATIVE borders — no contrast duty — so
none of them appears in a PAIRS entry (the ``err_line`` /
``accent_line`` exclusion the phase-93 design locked; the
identity ``line`` / ``grid_line`` exclusion predates it)."""
js = _js()
start = js.index("const PAIRS = [")
end = js.index("];", start)
pairs = re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end])
for field, _ in pairs:
assert not field.endswith("_line") and field not in ("line", "grid_line"), (
f"decorative border var {field} must not be a contrast foreground"
)
for _, background in pairs:
assert not background.endswith("_line") and background not in ("line", "grid_line"), (
f"decorative border var {background} must not be a contrast background"
)
# ---------- index.html: the #view-theme form (20 inputs) ----------
def test_theme_view_carries_all_20_labeled_inputs() -> None:
"""All 20 inputs are STATIC markup in the shell (the E2E-stable
selectors convention): each with a visible ``<label for>``; the 17
palette inputs are ``type="color"`` (the 3 branding inputs
``type="text"``)."""
body = _theme_view(_html())
text_ids = ("theme-app-name", "theme-placeholder", "theme-footer")
for field_id in text_ids:
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
f"missing the visible label for #{field_id}"
)
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="text"[^>]*>', body), (
f"#{field_id} must be a text input"
)
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
f"missing the visible label for #{field_id}"
)
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body), (
f"#{field_id} must be a type=color input"
)
def test_theme_view_color_inputs_ship_the_builtin_static_values() -> None:
"""The house contract: the color inputs ship the BUILT-IN values —
asserted against ``styles.css``'s ``:root`` parsed in-test (no
third hardcoded palette copy): ``theme.js`` captures these static
values as its ``BUILTINS`` no-op check, so a drift here would
break the byte-identical no-op save for every owner."""
body = _theme_view(_html())
decls = _root_declarations()
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
css_name = f"--{field.replace('_', '-')}"
assert css_name in decls, f"styles.css :root is missing {css_name}"
match = re.search(rf'<input[^>]*id="{field_id}"[^>]*>', body)
assert match is not None, f"#{field_id} is missing from #view-theme"
assert f'value="{decls[css_name].strip()}"' in match.group(0), (
f"#{field_id} must ship the built-in static value "
f"{decls[css_name].strip()!r}, got {match.group(0)!r}"
)
def test_state_colors_fieldset_after_the_palette() -> None:
"""The task-02 form shape: a "State colors" fieldset with the 8
semantic pickers sits AFTER the palette fieldset (the fieldset
order the FIELDS order mirrors), and the palette legend's old
"five pairs" copy is the "eight pairs" copy (the WCAG 2.1 AA
(4.5:1) wording kept)."""
body = _theme_view(_html())
palette = re.search(
r'<fieldset[^>]*class="theme-group"[^>]*>\s*'
r"(?:(?!</fieldset>).)*?Palette — eight pairs checked against "
r"WCAG 2.1 AA \(4\.5:1\)",
body,
re.S,
)
assert palette, "the palette fieldset's legend must say 'eight pairs'"
assert "five pairs" not in body, "the old 'five pairs' copy must be gone"
state = re.search(
r'<fieldset[^>]*class="theme-group"[^>]*>\s*'
r"(?:(?!</fieldset>).)*?<legend[^>]*>State colors</legend>",
body,
re.S,
)
assert state, "the 'State colors' fieldset must be in #view-theme"
assert state.start() > palette.start(), (
"the State colors fieldset must come after the palette fieldset"
)
# All 8 semantic pickers live inside the State colors fieldset.
state_body = body[state.start() : body.index("</fieldset>", state.start())]
for field in ("ok_bg", "ok_ink", "err_bg", "err_ink",
"err_line", "accent_bg", "accent_ink", "accent_line"):
field_id = f"theme-{field.replace('_', '-')}"
assert f'id="{field_id}"' in state_body, (
f"#{field_id} must live in the State colors fieldset"
)
# …and NOT in the palette fieldset (the 9 identity pickers only).
palette_body = body[palette.start() : body.index("</fieldset>", palette.start())]
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
if field in ("ok_bg", "ok_ink", "err_bg", "err_ink",
"err_line", "accent_bg", "accent_ink", "accent_line"):
assert field_id not in palette_body
else:
assert field_id in palette_body