"""Drift-detection: brand palette in `app/branding.py` must match the CSS. Both the website (cassandra.css) and the email templates use the same palette. The CSS hand-authors the values in :root and [data-theme="light"] blocks; this test parses those blocks and asserts every variable matches its counterpart in branding.py. If a colour changes, both must change. """ from __future__ import annotations import re from pathlib import Path import pytest from app import branding CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "cassandra.css" def _extract_vars(css: str, selector: str) -> dict[str, str]: """Parse `--name: value;` declarations inside the first matching selector block. Strips whitespace; lowercases hex values.""" # Match the selector followed by its block. Non-greedy on the body to # stop at the first closing brace at the same depth (these blocks # don't nest in cassandra.css). pattern = re.escape(selector) + r"\s*\{([^}]*)\}" m = re.search(pattern, css) if not m: raise AssertionError(f"selector {selector!r} not found in CSS") body = m.group(1) out: dict[str, str] = {} for line in body.splitlines(): decl = re.match(r"\s*--([a-z0-9-]+)\s*:\s*([^;]+);", line) if not decl: continue name, value = decl.group(1), decl.group(2).strip().lower() out[name] = value return out @pytest.fixture(scope="module") def css_text() -> str: return CSS_PATH.read_text(encoding="utf-8") def test_light_palette_matches_css(css_text): # Light is the default — lives in `:root`. Dark is opt-in via the # data-theme attribute toggle. css_light = _extract_vars(css_text, ":root") for key, expected in branding.LIGHT.items(): actual = css_light.get(key) assert actual == expected.lower(), ( f"LIGHT[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}" ) def test_dark_palette_matches_css(css_text): css_dark = _extract_vars(css_text, '[data-theme="dark"]') for key, expected in branding.DARK.items(): actual = css_dark.get(key) assert actual == expected.lower(), ( f"DARK[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}" ) def test_palette_keys_match_between_themes(): """If a colour is defined in dark, it must also be defined in light (and vice versa) — otherwise the theme switch leaves elements unstyled.""" assert set(branding.DARK.keys()) == set(branding.LIGHT.keys()) def test_email_uses_branding_palette(): """Sanity: the rendered OTP HTML should contain at least one of each theme's key colours, confirming the substitution actually wired up.""" from app.services.email_service import render_otp_email _, _, html = render_otp_email("123456", 15) assert branding.LIGHT["accent"] in html assert branding.DARK["accent"] in html assert branding.LIGHT["bg"] in html assert branding.DARK["bg"] in html