Files
brain-of-reese/tests/unit/test_theme_frontend.py
T
ducoterra 36eddce672
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 177_themed_favicon
Everything is verified. Final report:

## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end)

**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):**
- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins.

**Tests / lint / coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed
- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed

**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run).

**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed.

**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched.

**Next pending phase:** none — 177 was the last; TODO.md is cleared.
2026-10-11 15:33:39 -04:00

466 lines
20 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);
* phase 177 (task 04, A4) — the favicon live preview: the
``data:image/svg+xml,`` + ``encodeURIComponent`` mechanism, the
``isHex`` guard (a non-hex input returns ``null`` — the link is
left untouched), the EXACTLY-three driving fields (the binding's
gate + the helper call), the mirror's artwork constants pinned
against ``app/core/theming.py``'s ``favicon_svg`` source (the
divergence guard), and the ``/favicon.svg`` restore wired to the
four settle points (Save success, Reset success, the
Reset-failure ``clearPreview()`` path, the
``bor:view-refresh`` re-show — and to no other point).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import TypedDict
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
# ---------- theme.js: the favicon live preview (phase 177 — A4) ----------
# The admin Theme tab's live (unsaved) picks of the THREE driving
# fields (``surface`` / ``brand`` / ``brand_ink``) repaint the tab
# icon via a CSP-legal ``data:`` URI — the CLIENT-SIDE MIRROR of
# ``app.core.theming.favicon_svg`` (the live behavior is E2E-gated
# in ``tests/e2e/test_themed_favicon.py``; here the source pins
# keep the mirror from diverging — the ``PAIRS``-mirror house
# pattern). The settled palette always wins: the four settle points
# (Save success, Reset success, the Reset-failure ``clearPreview()``
# path, the ``bor:view-refresh`` re-show) restore the href to
# ``/favicon.svg`` (the route re-reads the DB per request — no
# process cache — so the restored URL is always fresh).
THEMING_PY = Path(__file__).resolve().parents[2] / "app" / "core" / "theming.py"
def _favicon_helper(js: str) -> str:
"""The ``themeFaviconDataUri`` function (from the signature to
the ``restoreFavicon`` definition — the helper's layout is
pinned by the pins below, so the slice is stable)."""
start = js.index("function themeFaviconDataUri(")
end = js.index("function restoreFavicon()", start)
return js[start:end]
def test_favicon_data_uri_mechanism_present() -> None:
"""The live preview builds a CSP-legal ``data:`` URI: the
literal ``data:image/svg+xml,`` prefix +
``encodeURIComponent`` — the phase-123 ``img-src 'self'
data:`` carve-out makes a ``data:`` favicon legal (CSP3
governs ``link[rel=icon]`` by ``img-src``; the carve-out is
image-scoped — ``data:`` never becomes a script/style/fetch
source). The URI is built from constants + validated hexes —
the house "never build HTML" rule binds the DOM, and this
file's URI carries no DOM-inserted HTML."""
helper = _favicon_helper(_js())
assert '"data:image/svg+xml,"' in helper
assert "encodeURIComponent(" in helper
def test_favicon_helper_guards_non_hex_inputs() -> None:
"""The ``isHex`` guard (belt-and-braces — the color inputs are
always valid hex, the inputs are the ONLY source of the
values, so there is no XSS path): a non-hex input returns
``null``, and the link is set ONLY for a non-null URI (a
``null`` URI leaves it untouched)."""
helper = _favicon_helper(_js())
assert (
"if (!isHex(surface) || !isHex(brand) || !isHex(brandInk)) "
"return null;" in helper
)
assert "if (uri && faviconLink) faviconLink.href = uri;" in _js()
def test_favicon_driving_set_is_exactly_the_three_fields() -> None:
"""The helper reads EXACTLY the three driving fields (``
surface`` / ``brand`` / ``brand_ink`` — the artwork's color
roles: hexagon fill, hexagon stroke + center dot, 4 spokes) —
a fourth field, or a missing one, fails the pin (the other 14
picks never touch the link; the 8 semantic state colors are
NOT in the artwork — a favicon has no state meaning). The
binding's gate (``FAVICON_DRIVERS``) is the same set, in the
artwork's role order."""
js = _js()
start = js.index("const FAVICON_DRIVERS = [")
end = js.index("]", start)
drivers = re.findall(r'"([a-z_]+)"', js[start:end])
assert drivers == ["surface", "brand", "brand_ink"]
# The live set: the helper call reads EXACTLY those three
# inputs' CURRENT values (rebuilt, not patched — a surface
# pick repaints with the current brand / brand_ink picks).
# The live call (the helper definition precedes it — search past
# the restore function's definition to land on the call site).
call = js.index("themeFaviconDataUri(", js.index("function restoreFavicon()"))
close = js.index(")", call + len("themeFaviconDataUri("))
names = re.findall(r"inputs\.(\w+)\.value", js[call:close])
assert names == ["surface", "brand", "brand_ink"]
def test_favicon_mirror_matches_the_server_artwork() -> None:
"""The client-side mirror's artwork constants match the
SERVER's (``app/core/theming.py``'s ``favicon_svg`` source —
the ``PAIRS``-mirror house pattern, the divergence guard):
the ``viewBox``, BOTH path ``d``-strings (the hexagon, the
spokes), and the circle ``r`` are extracted from each side's
source and compared — a change on either side fails the pin."""
js = _js()
py = THEMING_PY.read_text(encoding="utf-8")
py_start = py.index("def favicon_svg(")
py_end = py.index("\ndef ", py_start)
py_art = py[py_start:py_end]
class _ArtConstants(TypedDict):
viewBox: str
r: str
d: list[str]
def _art_constants(text: str) -> _ArtConstants:
viewbox = re.search(r'viewBox="([^"]+)"', text)
circle_r = re.search(r'r="([^"]+)"', text)
assert viewbox is not None, "the viewBox constant is missing"
assert circle_r is not None, "the circle r constant is missing"
return {
"viewBox": viewbox.group(1),
"r": circle_r.group(1),
"d": re.findall(r'd="([^"]+)"', text),
}
js_constants = _art_constants(_favicon_helper(js))
py_constants = _art_constants(py_art)
assert js_constants == py_constants, (
f"the client mirror diverged from the server: {js_constants} "
f"vs {py_constants}"
)
# Sanity: BOTH path d-strings (hexagon + spokes) are present.
assert len(js_constants["d"]) == 2
assert js_constants["viewBox"] == "0 0 64 64"
def test_favicon_restore_is_the_route_url() -> None:
"""The restore sends the href back to EXACTLY
``/favicon.svg`` — the themed route (the settled palette
always wins; the route re-reads the DB per request, so the
restored URL is always fresh)."""
js = _js()
start = js.index("function restoreFavicon()")
end = js.index("}\n", start)
body = js[start:end]
assert re.findall(r'href\s*=\s*"([^"]*)"', body) == ["/favicon.svg"]
def test_favicon_restore_wired_to_the_four_settle_points() -> None:
"""The restore is wired to ALL four settle points — and to NO
other point (the count pin: the one definition + EXACTLY four
call sites):
* ``saveTheme()`` success — AFTER the
``applyServedTheme`` reconcile (in the refetch-success
branch — a failed refetch keeps the ``data:`` preview on
purpose: the picks ARE the saved palette);
* ``resetTheme()`` success — AFTER the reconcile;
* the ``resetTheme()`` FAILURE path — AFTER
``clearPreview()`` (the picks are stale);
* the ``bor:view-refresh`` re-show — AFTER its
``applyServedTheme``.
"""
js = _js()
assert js.count("restoreFavicon()") == 5 # definition + 4 call sites
def region(start_marker: str, end_marker: str) -> str:
s = js.index(start_marker)
e = js.index(end_marker, s + len(start_marker))
return js[s:e]
# Save success: after the reconcile.
save = region("async function saveTheme", "async function resetTheme")
assert save.count("restoreFavicon()") == 1
assert save.index("applyServedTheme(settings)") < save.index("restoreFavicon()")
# Reset: the success branch (after the reconcile) AND the
# failure path (after clearPreview — the picks are stale).
reset = region(
"async function resetTheme", 'root.addEventListener("bor:view-refresh"'
)
assert reset.count("restoreFavicon()") == 2
first = reset.index("restoreFavicon()")
second = reset.index("restoreFavicon()", first + 1)
assert reset.index("applyServedTheme(settings)") < first
assert reset.index("clearPreview()") < second
# The re-show (the phase-77 hook) — after its reconcile.
refresh = region(
'root.addEventListener("bor:view-refresh"',
"const settings = await loadSettings()",
)
assert refresh.count("restoreFavicon()") == 1