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.
466 lines
20 KiB
Python
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
|