Files
brain-of-reese/tests/unit/test_theme_frontend.py
T
ducoterra c6ffa050a6
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 190_app_name_settings_fix
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`.
2026-10-18 02:59:19 -04:00

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