"""Unit: the built-in identity palette + the effective-settings resolver (phase 91, task 01). Covers ``app/core/theming.py`` — the single source of the built-in identity palette (re-homed from the retired phase-62 CSS-file themes' authoring guide before task 03 deleted it) and the DB-over-env / DB-over-built-in resolver shared by ``/api/ui-settings`` and ``/api/config``: * ``BUILTIN_COLORS`` — the DRIFT GUARD: the 17 built-ins (9 identity + 8 semantic state, phase 93) must equal the values parsed straight out of ``frontend/assets/styles.css``'s ``:root`` block, so the Python palette and the stylesheet can never silently diverge; * ``theme_style_tag`` — the byte-identical contract (all built-in → ``""``) and the exact tag shape (all 17 variables, ``COLOR_FIELDS`` order, lowercased hex); * ``effective_settings`` — missing row → env strings + built-ins; a DB row's set columns win; an empty-string DB string falls back to env (the resolver treats "" as unset, B1). House DB-test pattern (the ``test_tokens`` precedent): the real compose Postgres, skipped with clear instructions when the stack is not up. """ from __future__ import annotations import base64 import hashlib import re from pathlib import Path from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session from app.config import Settings from app.core import theming from app.models import UiSettings REPO_ROOT = Path(__file__).resolve().parents[2] STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css" def _delete_row() -> Any: from sqlalchemy import delete return delete(UiSettings).where(UiSettings.id == 1) def _start_row_missing(db: Session) -> None: """The single row is global state: wipe it so every DB test starts from the row-missing state it asserts (a stale row from an earlier interrupted run must not break them).""" db.execute(_delete_row()) db.commit() def _root_declarations() -> dict[str, str]: """The ``--name: value`` declarations of styles.css's (first) ``:root`` block, comments stripped, in file order.""" 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)) def test_builtin_colors_match_styles_css_root() -> None: """The drift guard: every built-in equals the stylesheet's ``:root`` value for the same variable (and ``BUILTIN_COLORS`` names exactly the 17 palette variables — the 9 identity + the 8 semantic state, no more, no fewer; phase 93, task 01).""" decls = _root_declarations() builtin_names = set(theming.BUILTIN_COLORS) assert builtin_names == { "bg", "surface", "ink", "ink_soft", "line", "grid_line", "brand", "brand_soft", "brand_ink", "ok_bg", "ok_ink", "err_bg", "err_ink", "err_line", "accent_bg", "accent_ink", "accent_line", }, f"BUILTIN_COLORS must name exactly the 17 palette variables, got {sorted(builtin_names)}" for name, value in theming.BUILTIN_COLORS.items(): css_name = f"--{name.replace('_', '-')}" assert css_name in decls, f"styles.css :root is missing {css_name}" assert decls[css_name].strip() == value, ( f"{css_name} drifted: BUILTIN_COLORS has {value!r}, " f"styles.css has {decls[css_name].strip()!r}" ) def test_color_fields_are_the_17_keys_in_order() -> None: """``COLOR_FIELDS`` is the 17 keys — the 9 identity in the themes-README order (phase 92: ``grid_line`` between ``line`` and ``brand``), then the 8 semantic state variables (phase 93: ok, err, accent — identity, brand, then state) — the order the resolver, the API, and the tag renderer all rely on.""" assert theming.COLOR_FIELDS == ( "bg", "surface", "ink", "ink_soft", "line", "grid_line", "brand", "brand_soft", "brand_ink", "ok_bg", "ok_ink", "err_bg", "err_ink", "err_line", "accent_bg", "accent_ink", "accent_line", ) assert len(theming.COLOR_FIELDS) == 17 assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text") def _env_settings() -> Settings: """An explicit env source (the resolver's optional ``settings`` parameter) — deterministic values, independent of the local ``.env`` (the ``/api/config`` env pins in test_api.py own the env-file behaviour; this unit module only needs stable fallbacks).""" return Settings( app_name="Env Name", input_placeholder="Env placeholder…", footer_text="Env footer", ) # ---------- effective_settings (real Postgres — house DB-test pattern) ---------- def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None: """A missing row (GET creates nothing) means "defaults": the env strings + the built-in palette, all 20 values (3 strings + 17 colors — phase 93, task 01).""" _start_row_missing(db) row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() assert row is None, "the test starts from a row-missing state" effective = theming.effective_settings(db, _env_settings()) assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) assert len(effective) == 20 # 3 strings + 17 colors (9 identity + 8 semantic) assert effective["app_name"] == "Env Name" assert effective["input_placeholder"] == "Env placeholder…" assert effective["footer_text"] == "Env footer" assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS def test_effective_db_row_wins_column_by_column(db: Session) -> None: """Set columns win, unset columns fall back — per column, so a partial row (only ``bg`` set) mixes the DB color with the built-ins and the env strings.""" _start_row_missing(db) db.add(UiSettings(id=1, bg="#111111", app_name="DB Name")) db.commit() try: effective = theming.effective_settings(db, _env_settings()) assert effective["bg"] == "#111111" # DB wins assert effective["app_name"] == "DB Name" # DB wins # Unset columns: env strings + the built-in colors. assert effective["input_placeholder"] == "Env placeholder…" assert effective["footer_text"] == "Env footer" for key in theming.COLOR_FIELDS: if key != "bg": assert effective[key] == theming.BUILTIN_COLORS[key] finally: db.execute(_delete_row()) db.commit() def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None: """B1: an EMPTY string in the DB is unset — the resolver falls back to the env value (a hand-edited row with '' can't blank the UI). Colors: ``None`` → the built-in (an empty color is impossible through the API — the hex validator — the resolver's not-None rule covers the hand-edited edge by returning whatever the row holds).""" _start_row_missing(db) db.add(UiSettings(id=1, app_name="")) db.commit() try: effective = theming.effective_settings(db, _env_settings()) assert effective["app_name"] == "Env Name" # "" → env fallback assert effective["footer_text"] == "Env footer" assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in finally: db.execute(_delete_row()) db.commit() def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None: """``settings=None`` (the design's call shape) resolves the env fallback from the cached :func:`app.config.get_settings` — the values it reports must be real ``str``s for all 20 values.""" from app.config import get_settings _start_row_missing(db) effective = theming.effective_settings(db) assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) assert effective["app_name"] == get_settings().app_name for field in theming.STRING_FIELDS: assert isinstance(effective[field], str) and effective[field] assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS) assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS # ---------- theme_style_tag (pure) ---------- def test_theme_style_tag_all_builtins_is_empty_string() -> None: """The byte-identical contract: an unset (or "defaults saved") deployment serves NO tag — exactly the pre-phase-91 HTML.""" assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == "" # The tag is PURE string-equality: uppercase hex is NOT the built-in # (the API's lowercasing-before-store is what makes stored hex # canonical — pin the renderer's own contract here). colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()} assert theming.theme_style_tag(colors) != "" def test_theme_style_tag_one_changed_semantic_carries_all_17_in_order() -> None: """A single NON-BUILT-IN SEMANTIC variable (phase 93, task 01) still emits ALL 17 declarations, in ``COLOR_FIELDS`` order, with the exact tag shape (no whitespace): the 9 identity variables keep their built-ins, the 8 semantic variables carry ``--ok-ink:#444444;`` (the change) plus the 7 other semantic built-ins — and the CSP hash matches the tag's content (the runtime exemption contract).""" colors = dict(theming.BUILTIN_COLORS) colors["ok_ink"] = "#444444" # one non-default SEMANTIC var tag = theming.theme_style_tag(colors) assert tag == ( '" ) # The changed value lands under the dashed CSS name… assert "--ok-ink:#444444;" in tag # …and the underscored field (ink_soft) renders as --ink-soft. assert "--ink-soft:#b8a8a8;" in tag assert "--ink_soft" not in tag # Exactly 17 declarations, COLOR_FIELDS order. names = re.findall(r"--([a-z-]+):", tag) assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS] # The hash is computed from the tag's EXACT content (CSP3 §13.4). content = tag.split(">", 1)[1].rsplit("", 1)[0] expected = "sha256-" + base64.b64encode( hashlib.sha256(content.encode("utf-8")).digest() ).decode("ascii") assert theming.theme_csp_hash(tag) == expected def test_theme_style_tag_multiple_changed() -> None: """Two changed colors: both values present, the rest built-in, order unchanged (the tag is a complete :root override — the page never mixes a partial palette).""" colors = dict(theming.BUILTIN_COLORS) colors["bg"] = "#0a0e1a" colors["brand_ink"] = "#c7d2fe" colors["accent_ink"] = "#cccccc" # a semantic var joins the mix too tag = theming.theme_style_tag(colors) assert tag.startswith('") # The order of the 17 dashed names is the COLOR_FIELDS order. names = re.findall(r"--([a-z-]+):", tag) assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS] # ---------- inject_theme (pure — task 02's injection helper) ---------- _HEAD_HTML = "t

b

" def test_inject_theme_empty_tag_is_identity() -> None: """``tag == ""`` (the unset / "defaults saved" deployment — what ``theme_style_tag" returns for all-built-in colors) → the html is returned EXACTLY as passed in, byte for byte (B4's no-op contract).""" assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML # Whitespace is NOT an empty tag — a real tag is always inserted. assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML def test_inject_theme_missing_head_is_identity() -> None: """No ```` occurrence → unchanged (nothing to anchor to); the empty string (no ```` either) is identity too.""" tag = '' html = "no head" assert theming.inject_theme(html, tag) == html assert theming.inject_theme("", tag) == "" def test_inject_theme_exact_placement_before_first_head_close() -> None: """The tag lands with a leading newline immediately BEFORE the first ```` — nothing between the tag and the close, nothing moved after it.""" tag = '' html = "tafter" assert theming.inject_theme(html, tag) == ( "t\n" + tag + "after" ) # A LATER ````-shaped stretch of text is not the anchor — the # FIRST occurrence wins (the one that closes the real head). html2 = "" assert theming.inject_theme(html2, tag) == ( "\n" + tag + "" ) def test_inject_theme_double_injection_is_idempotent() -> None: """The defensive idempotence rule keys on the id: once a ``id="bor-theme"`` tag is present the helper is the identity — the page can never carry two theme tags, even for a different tag.""" tag = '' once = theming.inject_theme(_HEAD_HTML, tag) assert once.count('id="bor-theme"') == 1 assert theming.inject_theme(once, tag) == once other = '' assert theming.inject_theme(once, other) == once # --------------------------------------------------------------------------- # Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the # inline tag's content (the phase-82 CSP would otherwise BLOCK the tag # in every real browser; the caching middleware publishes the hash as a # style-src exemption on themed HTML pages only). # --------------------------------------------------------------------------- def test_theme_csp_hash_empty_tag_is_empty_string() -> None: """No tag (unset/defaults deployment) → no hash — the plain A1 policy stands and the header stays byte-identical to pre-91.""" assert theming.theme_csp_hash("") == "" def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None: """CSP3 §13.4: the hash covers the character data BETWEEN the tags (the ``:root{…}`` declarations — the rendered content carries no leading/trailing whitespace, so no stripping applies), base64 after SHA-256, ``sha256-`` prefixed.""" tag = '' expected = "sha256-" + base64.b64encode( hashlib.sha256(b":root{--bg:#111111}").digest() ).decode("ascii") assert theming.theme_csp_hash(tag) == expected def test_theme_csp_hash_changes_with_the_palette() -> None: """A different palette → a different hash: the browser keeps blocking the OLD tag once the theme changes (the exemption always matches exactly the served bytes, never a stale palette).""" colors = dict(theming.BUILTIN_COLORS) colors["brand"] = "#818cf8" first = theming.theme_csp_hash(theming.theme_style_tag(colors)) colors["brand"] = "#22c55e" second = theming.theme_csp_hash(theming.theme_style_tag(colors)) assert first assert first != second assert first.startswith("sha256-") assert second.startswith("sha256-") # --------------------------------------------------------------------------- # Phase 93 (task 02): the docstring ↔ theme.js PAIRS mirror — the # authoritative eight-pair table and the client-side warning list must # NEVER diverge (the docstring names the mirror; this test pins it). # --------------------------------------------------------------------------- def _theme_js_pairs() -> list[tuple[str, str]]: """The (foreground, background) entries of ``theme.js``'s ``PAIRS`` array, in order (frontend source read as text — the house pattern).""" js = (REPO_ROOT / "frontend" / "assets" / "theme.js").read_text(encoding="utf-8") start = js.index("const PAIRS = [") end = js.index("];", start) return re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end]) def test_docstring_pair_table_matches_theme_js_pairs() -> None: """The mirror contract: ``theme.js``'s ``PAIRS`` is exactly the EIGHT pairs the module docstring's authoritative table names — every PAIRS entry appears in the docstring as ``fg`` on ``bg`` (and the list has exactly eight entries, so a pair silently added to ONE side fails).""" # Line-wrap-tolerant: the docstring table wraps at 79 columns # (``ink_soft``\non ``surface``), so newlines become spaces. doc = (theming.__doc__ or "").replace("\n", " ") pairs = _theme_js_pairs() assert len(pairs) == 8, f"PAIRS must hold exactly 8 pairs, got {pairs}" for fg, bg in pairs: assert f"``{fg}`` on ``{bg}``" in doc, ( f"the docstring's authoritative pair table must name " f"``{fg}`` on ``{bg}`` (the theme.js mirror)" ) # The two *_line state variables stay EXCLUDED from the warning # surface in BOTH places (decorative borders — no contrast duty). assert "err_line" not in [f for f, _ in pairs] assert "accent_line" not in [f for f, _ in pairs]