phase: 177_themed_favicon
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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.
This commit is contained in:
2026-10-11 15:33:39 -04:00
parent dd119d534a
commit 36eddce672
40 changed files with 2351 additions and 16 deletions
+701
View File
@@ -0,0 +1,701 @@
"""Phase 177 E2E (Playwright): the tab icon carries the effective
palette — the route, the pre-paint tag, the live preview, the
restore contract.
Source: TODO.md L3 — "We need the favicon to change colors to
match the theme." (owner roadmap confirmation 2026-10-13, A1–A4.)
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_default_state_serves_the_golden_route`` — the default
(no ``ui_settings`` row) deployment: ``GET /favicon.svg`` is 200
``image/svg+xml`` + ``no-cache`` with the body byte-identical
to the golden art (``FAVICON_GOLDEN`` from
``tests.unit.test_theming`` — ONE source); the five pages'
served HTML reference the route (``href="/favicon.svg"`` —
bare, never ``/assets/favicon.svg``); the deleted static file
is 404.
2. ``test_themed_state_serves_the_effective_palette`` — with a
saved palette (the 3 driving colors distinct): the route's SVG
carries the three hexes in their EXACT roles (attribute-parsed,
never substring) and is byte-equal to the server renderer's
output; a fresh page load STILL injects the ``#bor-theme`` tag
immediately before ``</head>`` with the full 17-var content +
the phase-91 CSP hash (the tag/CSP contract is untouched by the
favicon work), and the browser's first paint is the themed
paint (the computed ``--surface`` / ``--brand``).
3. ``test_live_preview_repaints_the_favicon`` — an admin's live
(unsaved) pick of the three driving fields sets
``link[rel="icon"]`` to a ``data:image/svg+xml`` URI that,
decoded, carries the three picked hexes in their exact roles
(attribute-parsed). Headless Chromium never fetches favicons
(probe-verified — zero request events, not even for the
initial ``/favicon.svg``), so the suite pins the href state +
the decoded art as the repaint contract and guards the CSP
path NEGATIVELY: no CSP violation is ever reported against a
``data:`` URI (the phase-123 ``img-src 'self' data:``
carve-out makes a ``data:`` favicon legal — CSP3 governs
``link[rel=icon]`` by ``img-src``; a blocked fetch would
surface as a ``requestfailed`` with a CSP error or a console
violation, which the suite asserts does not happen). A
non-driving pick (``#theme-bg``) never touches the href.
4. ``test_save_restores_the_route_url`` — Save: the href is
EXACTLY ``/favicon.svg`` and the route now serves the saved
palette's bytes (the settled palette governs).
5. ``test_reset_restores_the_route_url`` — after a Save, Reset:
the href is ``/favicon.svg`` and the route serves the built-in
golden bytes.
6. ``test_view_refresh_restores_the_route_url`` — a user-initiated
re-show (the router's ``bor:view-refresh`` dispatch — the
phase-77 hook) restores the href after a live pick.
Zero external requests across the whole suite (AGENTS rule 6 — the
house E2E assertion idiom): a ``page.on("request")`` collector
asserts every http(s) URL is the local origin (a ``data:`` URI is
exempt by design — the phase-123 carve-out).
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware + the favicon route read for
EVERY request — a leftover themed row would repaint other suites'
pages and icons) and ``api_tokens`` rows across suites. An autouse
fixture truncates ``ui_settings`` and deletes the ``e2e-``-labeled
tokens before AND after every test (never a TRUNCATE on
``api_tokens`` — the shared DB may hold the owner's real tokens).
Per-module app env (the tuning/tokens/archive-upload pattern): the
module-scoped ``app_server`` override boots the same env block as
the shared conftest server with the branding vars pinned to the
CODE defaults (an operator's local ``.env`` may carry the owner's
name/placeholder/footer, and "the effective strings start at the
template defaults" must hold regardless — the phase-61/62
leak-guard pattern, extended to ``BOR_APP_NAME``) and
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
not render as env rows in this suite's app). The phase-92 file
(test_theme_save_and_coverage.py) is the copy source — the two
modules' scaffolds stay in lockstep so a future conftest refactor
touches both at once.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from urllib.parse import unquote
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.core import theming
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from tests.unit.test_theming import FAVICON_GOLDEN
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The three driving fields (A1 — the artwork's color roles: hexagon
# fill, hexagon stroke + center dot, 4 spokes) with E2E values
# distinct from every built-in.
DRIVING: dict[str, str] = {
"surface": "#102030",
"brand": "#4f46e5",
"brand_ink": "#a5b4fc",
}
#: The E2E-stable color-input ids (the form's own markup — the
#: static E2E-stable-selectors house convention).
INPUT_IDS = {
field: f"#theme-{field.replace('_', '-')}" for field in theming.COLOR_FIELDS
}
#: The five templates whose served HTML must reference the route.
PAGES = ("/", "/document.html", "/login.html", "/shared.html", "/doc-edit.html")
#: The data: URI prefix the live preview sets (the phase-123
#: img-src 'self' data: carve-out makes it CSP-legal).
DATA_PREFIX = "data:image/svg+xml,"
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
order, no whitespace (the byte the middleware injects)."""
declarations = "".join(
f"--{k.replace('_', '-')}:{colors[k]};" for k in theming.COLOR_FIELDS
)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
def _expected_favicon_bytes(colors: dict[str, str]) -> bytes:
"""The route's body for a palette: the SERVER renderer's output
(imported — ONE source, never a test-side mirror of the art)."""
return theming.favicon_svg(colors).encode("utf-8")
def _attr(element: ET.Element, name: str) -> str:
"""An SVG attribute, asserted non-null (a missing role attribute
is a test failure, not a parseable value)."""
value = element.get(name)
assert value is not None, f"missing the {name} attribute"
return value
def _parse_roles(svg: str) -> dict[str, str]:
"""The three color roles, ATTRIBUTE-parsed (the unit pin's
idiom — never substring): the hexagon's fill (surface), the
hexagon's stroke (brand), the center circle's fill (brand),
the spokes' stroke (brand_ink)."""
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(svg)
assert root.tag == f"{ns}svg", root.tag
hexagon, circle, spokes = list(root)
return {
"surface": _attr(hexagon, "fill"),
"brand": _attr(hexagon, "stroke"),
"brand_circle": _attr(circle, "fill"),
"brand_ink": _attr(spokes, "stroke"),
}
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from
the class fields — never drifts from ``app/config.py``; the
module server pins the same values, so the effective strings
start exactly here)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate
is the last visible step — the app-name input carries the
effective default (the static markup ships it empty, so only a
settled ``loadSettings`` can have written it)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _favicon_href(page: Page) -> str:
"""The tab icon's current href (the attribute's raw state)."""
return page.evaluate(
"() => document.querySelector('link[rel=icon]').getAttribute('href')"
)
def _wait_data_href(page: Page) -> None:
"""The href is a ``data:`` URI (the live preview has landed)."""
page.wait_for_function(
f"() => (document.querySelector('link[rel=icon]')"
f".getAttribute('href') || '').startsWith('{DATA_PREFIX}')",
timeout=15_000,
)
def _wait_route_href(page: Page) -> None:
"""The href is EXACTLY the route URL (the restore has landed —
the settled palette governs)."""
page.wait_for_function(
"() => (document.querySelector('link[rel=icon]')"
'.getAttribute(\'href\') || \'\') === \'/favicon.svg\'',
timeout=15_000,
)
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the 3 driving colors
(the rest null → the built-ins — the route's bytes need only
the three colors in the artwork)."""
body: dict[str, object] = {}
for field in theming.STRING_FIELDS:
body[field] = None
for field in theming.COLOR_FIELDS:
body[field] = None
body.update(DRIVING)
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
# ---------------------------------------------------------------------------
# Per-module app env (the tuning/tokens/archive-upload pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the branding vars
are pinned to the CODE defaults (the effective strings start at
the template defaults regardless of an operator's local
``.env`` — the phase-61/62 leak-guard pattern the shared
conftest server applies to its two string vars; this one pins
all three, including ``BOR_APP_NAME``, which the shared server
leaves to the process) and ``BOR_GIT_SOURCES`` is forced empty
(the dev ``.env``'s git repo must not render as env rows in
this suite's app)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated threshold (conftest pattern) — no chat turn is
# ever sent in this suite, but the app boots with the same shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
# The branding vars: "unset" = the template defaults (the code
# defaults, derived from the class fields — the local ``.env``
# may carry the owner's values, and this suite's assertions need
# the TEMPLATE defaults, not the owner's).
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
env["BOR_INPUT_PLACEHOLDER"] = (
Settings.model_fields["input_placeholder"].default
)
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _clean_ui_state() -> None:
"""Fresh theme + token state per test: truncate the single-row
``ui_settings`` (the middleware + the favicon route read it for
EVERY request — a leftover themed row would repaint other
suites' pages and icons) and delete this suite's issued tokens
(label-scoped on ``e2e-`` — never a TRUNCATE: the shared DB
may hold the owner's real tokens)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_ui_state()
yield
_clean_ui_state()
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (the test's API
side sees exactly what that browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _zero_external(
page: Page,
) -> tuple[list[str], list[str], list[str], list[str]]:
"""AGENTS rule 6 (the house E2E assertion idiom): every http(s)
request is the local origin; ``data:`` URIs are exempt by
design (the phase-123 ``img-src 'self' data:`` carve-out).
Also collects the CSP-failure signals: a failed ``data:``
fetch (a blocked fetch carries a CSP error in its failure
message) and console CSP violations ("Refused to …" / "CSP"
against a ``data:`` URI). Returns (externals, data_requests,
failed_data, csp_violations). Headless Chromium never fetches
favicons (probe-verified), so ``data_requests`` is expected to
stay empty — the negative assertions (``failed_data`` +
``csp_violations``) are the CSP guard: a future change that
blocks the ``data:`` URI surfaces here, and a real
(non-headless) browser run additionally sees the fetch in
``data_requests``."""
externals: list[str] = []
data_requests: list[str] = []
failed_data: list[str] = []
csp_violations: list[str] = []
def _watch(request: object) -> None:
url = getattr(request, "url", "")
if url.startswith("data:"):
data_requests.append(url)
return
if url.startswith(("http://", "https://")) and "127.0.0.1" not in url:
externals.append(url)
def _watch_failed(request: object) -> None:
url = getattr(request, "url", "")
failure = getattr(request, "failure", None)
if url.startswith("data:") and failure:
failed_data.append(str(failure))
def _watch_console(message: object) -> None:
msg = getattr(message, "text", "")
if ("CSP" in msg or "Refused to" in msg) and "data:" in msg:
csp_violations.append(msg)
page.on("request", _watch)
page.on("requestfailed", _watch_failed)
page.on("console", _watch_console)
return externals, data_requests, failed_data, csp_violations
def _assert_zero_external(
externals: list[str], failed_data: list[str], csp_violations: list[str]
) -> None:
"""The suite's tail assertion: zero external requests, no
``data:`` URI CSP-blocked (a blocked fetch carries a CSP error
in its failure message), and no CSP violation reported
against a ``data:`` URI (the phase-123 carve-out holds)."""
assert not externals, f"external requests: {externals}"
assert not failed_data, f"data: URIs blocked (CSP?): {failed_data}"
assert not csp_violations, f"CSP violations: {csp_violations}"
# ---------------------------------------------------------------------------
# 1. Default state: the golden route, the five page references, the
# deleted static file
# ---------------------------------------------------------------------------
def test_default_state_serves_the_golden_route(app_url: str, db_ready: None) -> None:
"""The default (no ``ui_settings`` row) deployment: the route
is byte-identical to the golden art (imported — ONE source),
with the svg media type + ``no-cache``; the five pages' served
HTML reference the route (bare — no ``?v=``, never
``/assets/favicon.svg``); the deleted static file is 404."""
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/svg+xml")
assert r.headers["cache-control"] == "no-cache"
assert r.content == FAVICON_GOLDEN.encode("utf-8")
for path in PAGES:
page = httpx.get(app_url + path, timeout=10)
assert page.status_code == 200, path
assert (
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
in page.text
), f"{path} must reference the themed route"
assert "/assets/favicon.svg" not in page.text, (
f"{path} must never reference the deleted static file"
)
assert "favicon.svg?v=" not in page.text, (
f"{path} must keep the favicon link bare (no ?v=)"
)
gone = httpx.get(app_url + "/assets/favicon.svg", timeout=10)
assert gone.status_code == 404
# ---------------------------------------------------------------------------
# 2. Themed state: the route carries the effective palette; the
# pre-paint tag + the first paint are untouched (phase 91)
# ---------------------------------------------------------------------------
def test_themed_state_serves_the_effective_palette(
page: Page, app_url: str, db_ready: None
) -> None:
"""With a saved palette (the 3 driving colors distinct): the
route's SVG carries the three hexes in their EXACT roles
(attribute-parsed) and is byte-equal to the server renderer;
a fresh page load STILL injects the ``#bor-theme`` tag
(17-var content, immediately before ``</head>`` + the phase-91
CSP hash — the tag/CSP contract is untouched by the favicon
work) and the browser's first paint is the themed paint."""
page.set_default_timeout(30_000)
login(page, app_url)
_seed_theme_via_api(app_url, _cookies(page))
# The route: the three hexes in their exact roles…
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
roles = _parse_roles(r.text)
assert roles["surface"] == DRIVING["surface"]
assert roles["brand"] == DRIVING["brand"]
assert roles["brand_circle"] == DRIVING["brand"]
assert roles["brand_ink"] == DRIVING["brand_ink"]
# …and byte-equal to the server renderer's output (ONE source).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
assert r.content == _expected_favicon_bytes(effective)
# The pre-paint tag non-regression (phase 91): the RAW served
# HTML carries the 17-var tag immediately before ``</head>``…
tag = _expected_tag(effective)
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
assert r.text.count(tag) == 1, "the 17-var tag must be injected exactly once"
start = r.text.index(tag)
assert start + len(tag) == r.text.index("</head>"), (
"the tag must end exactly where </head> begins"
)
# …with the phase-91 CSP hash (the favicon work never touched
# the tag/CSP contract).
assert "style-src 'self' 'sha256-" in r.headers.get(
"content-security-policy", ""
), "the themed page must carry the phase-91 CSP hash"
# A fresh page load: the first paint IS the themed paint.
page.goto(app_url + "/")
page.wait_for_function(
"""(d) => {
const cs = getComputedStyle(document.documentElement);
return cs.getPropertyValue('--surface').trim() === d.surface
&& cs.getPropertyValue('--brand').trim() === d.brand;
}""",
arg=DRIVING,
timeout=15_000,
)
# ---------------------------------------------------------------------------
# 3. Live preview: an unsaved pick repaints the favicon via the
# CSP-legal data: URI; a non-driving pick never touches it
# ---------------------------------------------------------------------------
def test_live_preview_repaints_the_favicon(
page: Page, app_url: str, db_ready: None
) -> None:
"""An admin's live (unsaved) pick of the three driving fields
sets ``link[rel="icon"]`` to a ``data:`` URI that, decoded,
carries the three picked hexes in their exact roles; the
browser FETCHES the ``data:`` URI (the phase-123 carve-out —
a blocked fetch would be a ``requestfailed`` with a CSP error);
a non-driving pick (``#theme-bg``) never touches the href."""
page.set_default_timeout(30_000)
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A clean start: the href is the route (no row → the golden
# art served by the route).
assert _favicon_href(page) == "/favicon.svg"
# Set the three driving fields (page.fill fires the input event
# — the house _fill_theme_form idiom).
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# The href is a data: URI, and — decoded — carries the three
# picked hexes in their exact roles (attribute-parsed).
href = _favicon_href(page)
assert href.startswith(DATA_PREFIX)
svg = unquote(href[len(DATA_PREFIX):])
roles = _parse_roles(svg)
assert roles["surface"] == DRIVING["surface"]
assert roles["brand"] == DRIVING["brand"]
assert roles["brand_circle"] == DRIVING["brand"]
assert roles["brand_ink"] == DRIVING["brand_ink"]
# A non-driving pick never touches the href.
page.fill(INPUT_IDS["bg"], "#000000")
assert _favicon_href(page) == href, "a non-driving pick must not touch the href"
# The CSP guard (negative — headless never fetches favicons):
# no failed data: fetch, no CSP violation against the URI.
_assert_zero_external(externals, failed_data, csp_violations)
# ---------------------------------------------------------------------------
# 4. Restore: Save — the href is EXACTLY /favicon.svg and the route
# serves the saved palette's bytes (the settled palette governs)
# ---------------------------------------------------------------------------
def test_save_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a live pick, Save: the §7.4 lifecycle lands and the
href is EXACTLY ``/favicon.svg``; the route now serves the
saved palette's bytes (the settled palette governs — the route
re-reads the DB per request, no process cache)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A live pick first — so the save has a data: href to restore.
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# Save through the real form.
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
# The restore: the href is EXACTLY the route URL.
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: the route serves the saved
# palette's bytes (the PUT saved all 20 form values — the 3
# driving colors + the 14 built-ins (→ NULL) + the 3 template
# strings; the effective palette is the 3 + the built-ins).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == _expected_favicon_bytes(effective)
_assert_zero_external(externals, failed_data, csp)
# ---------------------------------------------------------------------------
# 5. Restore: Reset — the href is /favicon.svg and the route serves
# the built-in golden bytes
# ---------------------------------------------------------------------------
def test_reset_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a Save (the route is themed), a fresh live pick, then
Reset: the §7.4 lifecycle lands and the href is
``/favicon.svg``; the route serves the built-in golden bytes
(the reset nulled the row — the built-ins govern)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# Save first — the route is themed (the reset then has a real
# state to settle back from).
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
_wait_route_href(page)
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.content == _expected_favicon_bytes(
{**theming.BUILTIN_COLORS, **DRIVING}
)
# A fresh live pick — the href is the data: one the reset
# restores (a distinct value, so the repaint is guaranteed).
page.fill(INPUT_IDS["surface"], "#0a1525")
_wait_data_href(page)
# Reset to defaults: the §7.4 lifecycle (the all-null PUT — the
# API's documented "defaults" operation).
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
# The restore: the href is EXACTLY the route URL…
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# …and the route serves the built-in golden bytes.
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == FAVICON_GOLDEN.encode("utf-8")
_assert_zero_external(externals, failed_data, csp)
# ---------------------------------------------------------------------------
# 6. Restore: the bor:view-refresh re-show (the phase-77 hook)
# ---------------------------------------------------------------------------
def test_view_refresh_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a live pick, a user-initiated re-show (leave the Theme
view, come back — the router dispatches ``bor:view-refresh``
on the already-mounted section): the handler re-runs the load,
reconciles, and the href is restored to ``/favicon.svg`` (the
settled palette governs — in this test's row-less state, the
golden bytes)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A live pick — the href is the data: one the re-show restores.
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# A user-initiated re-show: away (the RAG view — a real view
# switch), then back — the router's phase-77 hook dispatches
# bor:view-refresh on the mounted Theme section.
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible(timeout=15_000)
page.click("#nav-theme")
expect(page.locator("#view-theme")).to_be_visible(timeout=15_000)
# The restore: the href is EXACTLY the route URL.
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: no row was saved, so the route
# still serves the golden bytes.
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == FAVICON_GOLDEN.encode("utf-8")
_assert_zero_external(externals, failed_data, csp)
+335
View File
@@ -0,0 +1,335 @@
"""Integration: the themed favicon route — ``GET /favicon.svg`` (phase
177, task 02, D2; A2/A3 owner-confirmed) + the template pins (task 03,
D3 — the route is the SINGLE source; the static file is deleted).
The route's contract, driven through the real app (TestClient + the
house ``db`` fixture — the ``test_ui_settings_api`` pattern):
* **default state** (no ``ui_settings`` row): 200 ``image/svg+xml`` +
``Cache-Control: no-cache``, the body byte-identical to the golden
art (``FAVICON_GOLDEN`` from ``tests.unit.test_theming`` — ONE
source, so the route and the renderer can never serve different
bytes), the CSP header present (the ``SecurityHeadersMiddleware``
pass-through — no new header wiring was needed);
* **themed state**: a set row's ``surface``/``brand``/``brand_ink``
land in their exact SVG attribute roles (asserted by PARSING the
attributes — the ``test_theming`` unit-pin idiom, not substring) and
none of the other 14 built-ins appears in the body;
* **no process cache**: a row update is visible on the very next
request — the palette is re-read per request (the runtime-change
contract; a theme change never needs a restart to reach the tab
icon);
* **DB-failure state**: ``effective_settings`` raising (a DB blip / a
pre-migration boot) → 200 with the built-in golden bytes (NEVER 500
— the never-break contract) + the fallback log line;
* **HEAD parity**: the same headers as GET, an empty body (the
phase-76 static-file parity idiom — the pre-phase static file
answered both methods);
* **routes-first**: a file named ``favicon.svg`` in the static dir
could NOT shadow the route (the registration position holds);
* **task 03 (template pins)**: all five served page bodies link the
EXACT bare ``<link … href="/favicon.svg">`` (NO ``?v=`` — the href
is not under ``/assets/``, so ``CachingMiddleware``'s
``_ASSET_REF_RE`` can never touch it; the pin keeps that contract
explicit against a future regex drift) and contain no
``/assets/favicon.svg`` reference; ``GET /assets/favicon.svg``
404s (the file is deleted); a tree scan of ``frontend/`` finds zero
remaining ``/assets/favicon.svg`` references.
The route is deliberately OUTSIDE ``/api/*`` (no per-response
byte-budget line — AGENTS rule 10 is ``/api/*``-only) and carries NO
auth dependency (the favicon is chrome — public like the login page):
every test below is the anonymous ``client``.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text, update
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.models import UiSettings
from tests.unit.test_theming import FAVICON_GOLDEN
# Phase 177 (task 03, D3): the template pins — every page links the
# BARE route; the static file is gone.
#: The EXACT favicon link tag every template must carry (bare — no
#: ``?v=``: the href is not under ``/assets/``).
FAVICON_LINK_TAG = (
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
)
#: The five L8 pages — the shell + the four standalone pages.
PAGE_PATHS: tuple[str, ...] = (
"/",
"/document.html",
"/login.html",
"/shared.html",
"/doc-edit.html",
)
#: The frontend tree root (the house tree-scan idiom).
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""The single ui_settings row is global state — wiped so every
test starts from the state it asserts (the house DELETE reset
pattern, ``test_ui_settings_api``)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def _assert_roles(
svg_text: str, *, surface: str, brand: str, brand_ink: str
) -> None:
"""The three hexes in their EXACT SVG attribute roles — asserted by
PARSING the attributes (the ``test_theming`` unit-pin idiom, not
substring) — the geometry invariants along the way (the hexagon
``d`` path, the three element order), and none of the other 14
built-in hexes anywhere in the body (a themed favicon must not
leak a built-in color)."""
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(svg_text)
assert root.tag == f"{ns}svg"
hexagon, circle, spokes = list(root)
assert hexagon.tag == f"{ns}path"
assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z"
assert hexagon.get("fill") == surface
assert hexagon.get("stroke") == brand
assert circle.tag == f"{ns}circle"
assert circle.get("fill") == brand
assert spokes.tag == f"{ns}path"
assert spokes.get("stroke") == brand_ink
for key, value in theming.BUILTIN_COLORS.items():
if value not in (surface, brand, brand_ink):
assert value not in svg_text, (
f"built-in {key} ({value}) must not appear in a themed favicon"
)
def test_default_state_serves_the_golden_bytes(
client: TestClient, db: Session
) -> None:
"""No ui_settings row: the BUILT-IN palette renders — 200
``image/svg+xml`` + ``Cache-Control: no-cache``, the body
byte-identical to the golden art (the D1 contract through the
wire), and the CSP header is present on the response (the
``SecurityHeadersMiddleware`` pass-through — no header change was
needed; the phase-123 ``img-src 'self' data:`` carve-out is part
of the policy that covers task 04's ``data:`` preview)."""
assert (
db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0
), "the test starts from a row-missing state"
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert r.text == FAVICON_GOLDEN
assert "content-security-policy" in r.headers
assert "default-src 'self'" in r.headers["content-security-policy"]
assert "img-src 'self' data:" in r.headers["content-security-policy"]
def test_themed_state_carries_the_three_roles(
client: TestClient, db: Session
) -> None:
"""A set row (distinct ``surface``/``brand``/``brand_ink``): the
three hexes land in their exact SVG attribute roles (parsed, not
substring) and none of the other 14 built-ins appears in the
body; the header contract (no-cache + content-type) is unchanged
by the palette."""
db.add(
UiSettings(
id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
)
db.commit()
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
_assert_roles(
r.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
def test_themed_state_is_live_no_process_cache(
client: TestClient, db: Session
) -> None:
"""The runtime-change contract (D2): the palette is re-read per
request — NO process cache, so a row update is visible on the very
next request (a theme change never needs a restart to reach the
tab icon), and the OLD palette is gone from the body (nothing
stale is served)."""
db.add(
UiSettings(
id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
)
db.commit()
r1 = client.get("/favicon.svg")
assert r1.status_code == 200
_assert_roles(
r1.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
db.execute(
update(UiSettings)
.where(UiSettings.id == 1)
.values(surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd")
)
db.commit()
r2 = client.get("/favicon.svg")
assert r2.status_code == 200
_assert_roles(
r2.text, surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd"
)
for old in ("#102030", "#4f46e5", "#a5b4fc"):
assert old not in r2.text
def test_db_failure_serves_the_builtin_art_with_200(
client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A DB blip / pre-migration boot (``effective_settings`` raising):
the handler's fallback serves the BUILT-IN golden bytes with 200
(NEVER 500 — the never-break contract) and logs the failure via
``logger.exception`` (the ``caching.py`` DB-blip pattern)."""
def _boom(_session: Session) -> dict[str, str]:
raise RuntimeError("db blip (test)")
monkeypatch.setattr(theming, "effective_settings", _boom)
with caplog.at_level(logging.ERROR, logger="app"):
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert r.text == FAVICON_GOLDEN
assert any(
"favicon: theme read failed" in record.message for record in caplog.records
), "the fallback path must log the failure (logger.exception)"
def test_head_parity_matches_get(client: TestClient, db: Session) -> None:
"""``HEAD /favicon.svg``: 200 with the SAME headers as GET
(content-type, no-cache, the CSP pass-through) and an empty body
(the phase-76 static-file parity idiom — the pre-phase static
file answered both methods, the dynamic route keeps that)."""
r = client.head("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert "content-security-policy" in r.headers
assert r.content == b""
def test_route_wins_over_the_static_mount(client: TestClient) -> None:
"""Routes-first: a file named ``favicon.svg`` in the static dir
could NOT shadow the route — the file is created with
SHADOW-ATTEMPT bytes (and deleted again in the ``finally``), and
the response is still the golden art (the registration position —
after the router includes, before the static catch-all mount —
holds)."""
shadow = Path(get_settings().static_dir).resolve() / "favicon.svg"
assert not shadow.exists(), (
"the test must start without a static-dir favicon.svg"
)
shadow.write_text("SHADOW ATTEMPT", encoding="utf-8")
try:
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.text == FAVICON_GOLDEN
assert "SHADOW ATTEMPT" not in r.text
finally:
shadow.unlink()
def test_route_is_public_no_auth_dependency(client: TestClient) -> None:
"""The favicon is chrome — public like the login page: the
anonymous (unsigned-in) client gets 200, and the route carries no
``/api/`` prefix (deliberately outside ``/api/*`` — no
per-response byte-budget line, AGENTS rule 10's scope)."""
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
r = client.get("/favicon.svg")
assert r.status_code == 200
# ---------- Phase 177 (task 03, D3): the template pins ----------
@pytest.mark.parametrize("path", PAGE_PATHS)
def test_served_page_links_the_bare_route(
client: TestClient, path: str
) -> None:
"""Each of the five served page bodies carries the EXACT bare
favicon link (task 03, D3 — ``href="/favicon.svg"``) and contains
NO ``/assets/favicon.svg`` reference of any kind. The link stays
BARE — no ``?v=`` (the href is not under ``/assets/``, so
``CachingMiddleware``'s ``_ASSET_REF_RE`` can never touch it;
the pin keeps that contract explicit against a future regex
drift)."""
r = client.get(path)
assert r.status_code == 200
assert FAVICON_LINK_TAG in r.text, f"{path}: favicon link pin missing"
assert "/assets/favicon.svg" not in r.text
assert "/favicon.svg?v=" not in r.text, (
f"{path}: the favicon link must stay bare (no ?v= version token)"
)
def test_the_static_favicon_file_is_gone(client: TestClient) -> None:
"""The single-source decision (task 03, D3): ``frontend/assets/
favicon.svg`` no longer exists — on disk AND over the wire (the
static catch-all 404s it). Nothing is lost: the built-in bytes
stay pinned by ``FAVICON_GOLDEN`` in ``test_theming`` (task 01's
golden constant is the durable contract), and ``GET
/favicon.svg`` still serves the golden art (the route is the
single source now)."""
assert not (FRONTEND / "assets" / "favicon.svg").exists()
r = client.get("/assets/favicon.svg")
assert r.status_code == 404
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.text == FAVICON_GOLDEN
def test_tree_scan_has_zero_assets_favicon_references() -> None:
"""A tree scan of ``frontend/`` (the house idiom — every file,
markup + scripts + styles, comments included): ZERO remaining
references to ``/assets/favicon.svg`` (the deletion is complete —
no template, script, or stylesheet still asks for the retired
URL)."""
files = [
p
for p in sorted(FRONTEND.rglob("*"))
if p.is_file()
]
assert files, "frontend/ contains no files — test is blind"
for path in files:
assert "/assets/favicon.svg" not in path.read_text(encoding="utf-8"), (
f"/assets/favicon.svg survived in {path.relative_to(FRONTEND)}"
)
+188 -1
View File
@@ -32,12 +32,23 @@ frontend sources and asserts on their mechanisms):
``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).
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
@@ -276,3 +287,179 @@ def test_state_colors_fieldset_after_the_palette() -> None:
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
+113
View File
@@ -29,12 +29,21 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
ellipsis. The :root 17-variable set itself stays pinned by
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
phase adds no palette variable, B3).
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
renderer, byte-identical for the built-in palette (the golden
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
``<svg>`` element — the file is deleted in task 03, so the
constant is the durable contract); the three color roles per A1
(hexagon fill → ``surface``, hexagon stroke + center dot →
``brand``, spokes → ``brand_ink``); the 8 semantic state colors
are NOT in the artwork (a favicon has no state meaning).
"""
from __future__ import annotations
import base64
import hashlib
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
@@ -572,3 +581,107 @@ def test_cell_clip_ellipsis_pin() -> None:
f".cell-clip white-space drifted: {rule.get('white-space')!r} — "
f"the ellipsis needs the nowrap"
)
# ---------------------------------------------------------------------------
# Phase 177 (task 01): favicon_svg — the pure palette→artwork renderer,
# byte-identical for the built-in palette (A1). The GOLDEN constant is
# the durable contract: it holds the pre-phase static file's ``<svg>``
# element (the file's comment header is file packaging, not art, and is
# NOT part of the contract; the file itself is deleted in task 03).
# ---------------------------------------------------------------------------
#: The pre-phase ``frontend/assets/favicon.svg`` ``<svg>`` element —
#: hexagon + center dot + 4 spokes, ``viewBox 0 0 64 64``, lowercase
#: hex, original attribute order. ``favicon_svg(BUILTIN_COLORS)`` must
#: equal this EXACTLY (341 bytes, single line, no comment header).
FAVICON_GOLDEN = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" '
'stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/>'
'<circle cx="32" cy="32" r="6.5" fill="#f43f5e"/>'
'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" '
'stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/>'
"</svg>"
)
def _mask_color_attrs(svg_text: str) -> str:
"""The document with its EXACTLY FOUR color attributes (the
``fill="`` / ``stroke="`` values) masked — the rest (geometry,
``d`` paths, circle ``cx/cy/r``, stroke widths, ``linejoin`` /
``linecap``, attribute order) is then comparable byte for byte.
(``stroke-width`` / ``stroke-linejoin`` / ``stroke-linecap`` never
match — they carry ``-`` before the ``="``.)"""
return re.sub(r'(fill|stroke)="#[0-9a-f]{6}"', r'\1="COLOR"', svg_text)
def test_favicon_svg_builtins_is_byte_identical_to_the_static_art() -> None:
"""The byte-identical contract (D1): for the built-in palette the
output is EXACTLY the static file's ``<svg>`` element — lowercase
hex, same attribute order — pinned by the golden constant (the
durable contract; the static file is deleted in task 03, so the
constant, not the file, keeps the bytes alive)."""
out = theming.favicon_svg(dict(theming.BUILTIN_COLORS))
assert out == FAVICON_GOLDEN
# Single-line element, no comment header (the file's comment is
# file packaging, not art), exact extent.
assert "\n" not in out
assert out.startswith('<svg xmlns="http://www.w3.org/2000/svg"')
assert out.endswith("</svg>")
# The three built-in role values land in the golden's roles.
assert 'fill="#1a0f0f"' in out # surface
assert out.count('"#f43f5e"') == 2 # brand (hexagon stroke + circle)
assert 'stroke="#fca5a5"' in out # brand_ink
def test_favicon_svg_themed_carries_the_three_roles() -> None:
"""A palette with distinct ``surface``/``brand``/``brand_ink``
(plus the other 14 built-ins): the three hexes appear in their
exact attribute roles — asserted by PARSING the SVG attributes
(not by substring) — and none of the other 14 built-in hexes
appears anywhere in the output."""
colors = dict(theming.BUILTIN_COLORS)
colors["surface"] = "#102030"
colors["brand"] = "#4f46e5"
colors["brand_ink"] = "#a5b4fc"
out = theming.favicon_svg(colors)
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(out)
assert root.tag == f"{ns}svg"
assert root.get("viewBox") == "0 0 64 64"
hexagon, circle, spokes = list(root)
# Hexagon: fill ← surface, stroke ← brand (geometry unchanged).
assert hexagon.tag == f"{ns}path"
assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z"
assert hexagon.get("fill") == "#102030"
assert hexagon.get("stroke") == "#4f46e5"
# Center dot: fill ← brand.
assert circle.tag == f"{ns}circle"
assert circle.get("fill") == "#4f46e5"
# Spokes: stroke ← brand_ink.
assert spokes.tag == f"{ns}path"
assert spokes.get("stroke") == "#a5b4fc"
# None of the other 14 built-in hexes appears in the output.
for key, value in theming.BUILTIN_COLORS.items():
if key not in ("surface", "brand", "brand_ink"):
assert value not in out, (
f"built-in {key} value {value} must not appear in a themed favicon"
)
def test_favicon_svg_geometry_is_invariant() -> None:
"""Geometry invariance: with a themed palette, everything EXCEPT
the four color attributes is byte-equal to the built-in output
(viewBox, both path ``d``s, circle ``cx/cy/r``, stroke widths,
``linejoin``/``linecap``, attribute order — the mask leaves only
the four ``fill="…"`` / ``stroke="…"`` values to differ)."""
colors = dict(theming.BUILTIN_COLORS)
colors["surface"] = "#102030"
colors["brand"] = "#4f46e5"
colors["brand_ink"] = "#a5b4fc"
themed = theming.favicon_svg(colors)
builtin = theming.favicon_svg(dict(theming.BUILTIN_COLORS))
assert _mask_color_attrs(themed) == _mask_color_attrs(builtin)
# Sanity: the mask masked exactly the four color attributes.
assert _mask_color_attrs(builtin).count('"COLOR"') == 4