All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
505 lines
23 KiB
Python
505 lines
23 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) and NOTHING else since phase 190 (task 02, A1: the admin
|
|
Settings panel is the single writable owner of the 3 brand strings, so
|
|
the tab's "Branding" fieldset, its three string inputs and the string
|
|
half of its PUT body are gone). 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 17 entries (the colors ONLY — no
|
|
``kind: "string"`` entry may come back) in the FORM's order, the
|
|
server's ``theming.COLOR_FIELDS`` order (identity, brand, then
|
|
state): everything that iterates FIELDS (live preview,
|
|
``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``'
|
|
tag content) covers the 8 semantic pickers automatically only if this
|
|
order holds — and the body ``collectBody`` builds carries no string
|
|
key either (the phase-190 pin: an old cached client's save can no
|
|
longer re-bake a brand string);
|
|
* ``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`` — the 17 color inputs carry E2E-stable
|
|
ids + visible labels, and the three removed string inputs stay absent
|
|
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
|
|
the phase-190 A1 strip); the 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 17 color form fields, in order ----------
|
|
|
|
|
|
def test_fields_lists_the_17_colors_and_no_strings() -> None:
|
|
"""``FIELDS`` has EXACTLY 17 entries — the color fields ONLY — 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).
|
|
|
|
Phase 190 (task 02, A1) is what makes this a CONTRACT and not a
|
|
coincidence: the admin Settings panel is the single writable owner
|
|
of ``app_name`` / ``input_placeholder`` / ``footer_text``, so no
|
|
entry may carry ``kind: "string"`` — an entry means a populated
|
|
input AND a PUT-body key, and a string key is exactly how the
|
|
Theme tab used to bake the then-effective name into its row and
|
|
shadow every later panel change (``TODO.md`` L3)."""
|
|
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) == 17, f"FIELDS must list exactly 17 entries, got {len(fields)}"
|
|
assert [(f, i) for f, i, _ in fields] == [
|
|
("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 17 color fields in the form's order"
|
|
assert all(kind == "color" for _, _, kind in fields), (
|
|
"every palette entry is kind color — no string field is back in the tab"
|
|
)
|
|
for name in theming.STRING_FIELDS:
|
|
assert f'{{ field: "{name}"' not in js[start:end], (
|
|
f"{name} left the Theme tab in phase 190 — the panel owns it"
|
|
)
|
|
|
|
|
|
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"
|
|
)
|
|
|
|
|
|
def test_collectbody_builds_the_17_colors_and_no_string_keys() -> None:
|
|
"""``collectBody`` — the PUT body the Save button sends — is
|
|
FIELDS-driven and therefore carries the 17 colors ONLY: no string
|
|
key appears anywhere in its source (phase 190, A1), and the old
|
|
trimmed-string branch (``value.trim() || null`` — the bake) is gone.
|
|
The server ignores body strings defensively too
|
|
(``app/api/ui_settings.py``), so this is the client half of "a
|
|
Theme-tab save can never shadow the Settings panel again"."""
|
|
js = _js()
|
|
start = js.index("function collectBody()")
|
|
end = js.index("\n }", start)
|
|
body_src = js[start:end]
|
|
assert body_src, "collectBody must exist"
|
|
for name in theming.STRING_FIELDS:
|
|
assert name not in body_src, f"{name} must never ride the PUT body"
|
|
assert "value.trim()" not in body_src, "the string-trim bake branch must be gone"
|
|
assert "for (const f of FIELDS)" in body_src, (
|
|
"the body stays FIELDS-driven — one ordering source for the PUT"
|
|
)
|
|
assert "isHex(value)" in body_src, "the colors keep their hex-or-null rule"
|
|
|
|
|
|
# ---------- 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 (17 color inputs) ----------
|
|
|
|
|
|
def test_theme_view_carries_the_17_labeled_color_inputs() -> None:
|
|
"""The 17 color inputs are STATIC markup in the shell (the
|
|
E2E-stable selectors convention), each with a visible
|
|
``<label for>`` and ``type="color"`` — and NO string input is in the
|
|
view (phase 190, task 02: the "Branding" fieldset and its
|
|
``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer``
|
|
inputs are gone, so the tab can neither show nor send a brand
|
|
string; the same absence is asserted in the browser by
|
|
``tests/e2e/test_admin_theme_tab.py``)."""
|
|
body = _theme_view(_html())
|
|
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
|
assert f'id="{field_id}"' not in body, (
|
|
f"#{field_id} must be gone from the Theme tab (phase 190 A1)"
|
|
)
|
|
assert f'for="{field_id}"' not in body, f"#{field_id}'s label must be gone"
|
|
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
|