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.
688 lines
32 KiB
Python
688 lines
32 KiB
Python
"""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.
|
|
* Phase 156 (task 03): the catalogue-table CSS contract pins, parsed
|
|
straight out of ``styles.css`` (the :root-drift idiom extended — a
|
|
later "cleanup" cannot silently unstick the header, unbound the
|
|
panel, or transparent the thead): the bounded ``.table-wrap``
|
|
panel, the sticky opaque ``thead th``, the
|
|
``#git-sources-table-wrap`` one-rule harmony, the
|
|
``.md-table-wrap`` byte-unchanged scope guard, the ``.cell-clip``
|
|
ellipsis. The :root 17-variable set itself stays pinned by
|
|
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
|
|
phase adds no palette variable, B3).
|
|
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
|
|
renderer, byte-identical for the built-in palette (the golden
|
|
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
|
|
``<svg>`` element — the file is deleted in task 03, so the
|
|
constant is the durable contract); the three color roles per A1
|
|
(hexagon fill → ``surface``, hexagon stroke + center dot →
|
|
``brand``, spokes → ``brand_ink``); the 8 semantic state colors
|
|
are NOT in the artwork (a favicon has no state meaning).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
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 == (
|
|
'<style id="bor-theme">:root{'
|
|
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
|
|
"--line:#2d1a1a;--grid-line:#4a2626;--brand:#f43f5e;"
|
|
"--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
|
|
"--ok-bg:#10241b;--ok-ink:#444444;--err-bg:#2d0a0a;--err-ink:#fca5a5;"
|
|
"--err-line:#ef4444;--accent-bg:#2b2110;--accent-ink:#fbbf24;"
|
|
"--accent-line:#f59e0b;"
|
|
"}</style>"
|
|
)
|
|
# 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("</style>", 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('<style id="bor-theme">:root{--bg:#0a0e1a;')
|
|
assert "--brand-ink:#c7d2fe;" in tag
|
|
assert "--accent-ink:#cccccc;" in tag
|
|
assert tag.endswith("}</style>")
|
|
# 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 = "<html><head><title>t</title></head><body><p>b</p></body></html>"
|
|
|
|
|
|
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 ``</head>`` occurrence → unchanged (nothing to anchor to);
|
|
the empty string (no ``</head>`` either) is identity too."""
|
|
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
|
html = "<html><body>no head</body></html>"
|
|
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 ``</head>`` — nothing between the tag and the close, nothing
|
|
moved after it."""
|
|
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
|
html = "<html><head><title>t</title></head><body>after</body></html>"
|
|
assert theming.inject_theme(html, tag) == (
|
|
"<html><head><title>t</title>\n" + tag + "</head><body>after</body></html>"
|
|
)
|
|
# A LATER ``</head>``-shaped stretch of text is not the anchor — the
|
|
# FIRST occurrence wins (the one that closes the real head).
|
|
html2 = "<head></head><script>if (x) { a() }</head></script></head>"
|
|
assert theming.inject_theme(html2, tag) == (
|
|
"<head>\n" + tag + "</head><script>if (x) { a() }</head></script></head>"
|
|
)
|
|
|
|
|
|
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 = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
|
once = theming.inject_theme(_HEAD_HTML, tag)
|
|
assert once.count('id="bor-theme"') == 1
|
|
assert theming.inject_theme(once, tag) == once
|
|
other = '<style id="bor-theme">:root{--bg:#222222;}</style>'
|
|
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 = '<style id="bor-theme">:root{--bg:#111111}</style>'
|
|
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]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 156 (task 03): the catalogue-table CSS contract pins — the
|
|
# bounded-panel sticky header + the clipped-label ellipsis, parsed
|
|
# straight out of styles.css (the :root-drift idiom extended: a later
|
|
# "cleanup" cannot silently unstick the header, unbound the panel, or
|
|
# transparent the thead). The :root 17-variable set itself is pinned
|
|
# by test_builtin_colors_match_styles_css_root above (UNCHANGED —
|
|
# this phase adds no palette variable, B3: the 17-variable set stays
|
|
# byte-identical).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _css_rule(selector: str) -> dict[str, str] | None:
|
|
"""The ``prop: value`` declarations (comments stripped) of the FIRST
|
|
styles.css rule whose selector ends with ``selector`` (a bare
|
|
``.table-wrap`` never matches ``.table-wrap table thead th`` —
|
|
the selector must end right at the rule's ``{``), or ``None``
|
|
when the selector is absent from the file."""
|
|
css = re.sub(r"/\*.*?\*/", "", STYLES_CSS.read_text(encoding="utf-8"), flags=re.S)
|
|
match = re.search(re.escape(selector) + r"(?![\w.#-])\s*\{", css)
|
|
if match is None:
|
|
return None
|
|
block = css[match.end() : css.index("}", match.end())]
|
|
return dict(re.findall(r"([a-z-]+)\s*:\s*([^;]+);", block))
|
|
|
|
|
|
def _assert_opaque_background(value: str, root: dict[str, str]) -> None:
|
|
"""The sticky th's OPACITY pin (the exact token is the phase's
|
|
choice — ``var(--brand-soft)`` today; the opacity is the pin):
|
|
the background token must paint a solid, alpha-less color —
|
|
``transparent``/``none`` never, a ``var(--x)`` token must resolve
|
|
to a solid six-hex ``:root`` value, a literal must carry no
|
|
alpha channel (``rgba(`` / ``hsla(`` / 8-digit hex all let rows
|
|
show through the pinned header)."""
|
|
v = value.strip()
|
|
assert v.lower() not in {"transparent", "none", "inherit", "unset", "initial"}, (
|
|
f"the sticky th background is {value!r} — rows would show through"
|
|
)
|
|
var = re.fullmatch(r"var\(\s*(--[a-z-]+)\s*\)", v)
|
|
if var:
|
|
name = var.group(1)
|
|
assert name in root, f"the sticky th background {value!r} names an unknown {name}"
|
|
resolved = root[name]
|
|
assert re.fullmatch(r"#[0-9a-f]{6}", resolved), (
|
|
f"the sticky th background {value!r} resolves to {resolved!r} — it must "
|
|
f"be a solid alpha-less hex (OPAQUE)"
|
|
)
|
|
return
|
|
assert not re.search(r"rgba?\(\s|hsla?\(\s|#[0-9a-f]{8}\b", v), (
|
|
f"the sticky th background {value!r} carries an alpha channel — "
|
|
f"it must be OPAQUE"
|
|
)
|
|
|
|
|
|
def test_table_wrap_bounded_panel_pin() -> None:
|
|
"""The ONE rule all three catalogue tables share (the RAG
|
|
``.docs-table``, the Sources ``.git-sources-table``, the History
|
|
``.history-table``): the card is the vertical scroll region —
|
|
``max-height: min(70vh, 64rem)`` (the exact A2 value pinned: a
|
|
15-row table scrolls while a 3-row one fits without a scrollbar)
|
|
+ ``overflow: auto`` (both axes — the tables' min-width still
|
|
triggers the in-card horizontal scroll at narrow widths). The
|
|
pre-phase ``overflow-x: auto`` is SUPERSEDED — its return would
|
|
unbound the panel's vertical contract (a sticky header in an
|
|
unbounded card scrolls away with the page)."""
|
|
rule = _css_rule(".table-wrap")
|
|
assert rule is not None, "styles.css must define .table-wrap"
|
|
assert rule.get("max-height") == "min(70vh, 64rem)", (
|
|
f".table-wrap max-height drifted: {rule.get('max-height')!r} — the "
|
|
f"bounded-panel pin is min(70vh, 64rem)"
|
|
)
|
|
assert rule.get("overflow") == "auto", (
|
|
f".table-wrap overflow drifted: {rule.get('overflow')!r} — the card "
|
|
f"must be the sticky th's scrolling ancestor (overflow: auto)"
|
|
)
|
|
assert "overflow-x" not in rule, (
|
|
".table-wrap must not carry a bare overflow-x — the pre-phase "
|
|
"overflow-x: auto is superseded by overflow: auto (both axes)"
|
|
)
|
|
|
|
|
|
def test_table_wrap_thead_sticky_pin() -> None:
|
|
"""The ONE sticky-header rule for every catalogue table:
|
|
``position: sticky; top: 0; z-index: 1`` (the header row pinned
|
|
to the card top while the card's own vertical scroll moves, the
|
|
pinned th above the scrolled cells) + an OPAQUE background — the
|
|
parse asserts the background is a non-``transparent`` token (the
|
|
exact token is the phase's choice — ``var(--brand-soft)`` today;
|
|
the OPACITY is the pin: a ``var`` token must resolve to a solid
|
|
alpha-less :root hex, a literal must carry no alpha channel —
|
|
rows must never show through the pinned header)."""
|
|
rule = _css_rule(".table-wrap table thead th")
|
|
assert rule is not None, "styles.css must define .table-wrap table thead th"
|
|
assert rule.get("position") == "sticky", (
|
|
f"the catalogue thead th must stay position: sticky, "
|
|
f"got {rule.get('position')!r}"
|
|
)
|
|
assert rule.get("top") == "0", (
|
|
f"the catalogue thead th must pin at top: 0, got {rule.get('top')!r}"
|
|
)
|
|
assert rule.get("z-index") == "1", (
|
|
f"the catalogue thead th must sit at z-index: 1, got {rule.get('z-index')!r}"
|
|
)
|
|
assert "background" in rule, (
|
|
"the catalogue thead th must carry an explicit background "
|
|
"(the opaque pin — the default is transparent)"
|
|
)
|
|
_assert_opaque_background(rule["background"], _root_declarations())
|
|
|
|
|
|
def test_git_sources_table_wrap_no_divergent_override() -> None:
|
|
"""The one-rule contract: the Sources card
|
|
(``#git-sources-table-wrap``) must not carry a DIVERGENT UNBOUNDED
|
|
override — if the id rule exists it carries the SAME
|
|
``max-height: min(70vh, 64rem)`` + ``overflow: auto`` as the base
|
|
``.table-wrap`` (a divergent ``overflow-x: auto`` without the
|
|
bound would make the Sources table scroll away with the page
|
|
while the other two stick). The current form (task 01): the id
|
|
rule is REMOVED — the wrapper carries ``class="table-wrap"`` and
|
|
inherits the base card's bound + overflow + sticky rule, so the
|
|
rule's absence IS the harmony."""
|
|
rule = _css_rule("#git-sources-table-wrap")
|
|
if rule is None:
|
|
return # the one-rule contract: the id carries no rule of its own
|
|
assert rule.get("max-height") == "min(70vh, 64rem)", (
|
|
f"#git-sources-table-wrap max-height diverges: "
|
|
f"{rule.get('max-height')!r} — the one-rule contract pins min(70vh, 64rem)"
|
|
)
|
|
assert rule.get("overflow") == "auto", (
|
|
f"#git-sources-table-wrap overflow diverges: {rule.get('overflow')!r} — "
|
|
f"the one-rule contract pins overflow: auto"
|
|
)
|
|
|
|
|
|
def test_md_table_wrap_byte_unchanged_pin() -> None:
|
|
"""The scope guard: the CHAT view's markdown table wrapper
|
|
(``.md-table-wrap`` — the shared renderer's horizontal scroller)
|
|
is a DIFFERENT class from the catalogue card — UNTOUCHED by this
|
|
phase (no card, no height bound; the bounded-panel design is for
|
|
the catalogue tables only). The pin asserts the rule's VERBATIM
|
|
pre-phase text (byte-identical)."""
|
|
css = STYLES_CSS.read_text(encoding="utf-8")
|
|
assert ".md-table-wrap { overflow-x: auto; }" in css, (
|
|
".md-table-wrap must stay byte-identical to its pre-phase rule "
|
|
"(.md-table-wrap { overflow-x: auto; }) — the chat scope guard"
|
|
)
|
|
|
|
|
|
def test_cell_clip_ellipsis_pin() -> None:
|
|
"""The clipped-label ellipsis (A3): the ``.cell-clip`` label span
|
|
(the five control families — the Sources Hidden checkbox label +
|
|
Ignore paths + Remove, the RAG folder table's Edit, the History
|
|
Copy / Unshare / Create link / Delete) shows "…" instead of the
|
|
card edge's hard cut: ``text-overflow: ellipsis`` +
|
|
``overflow: hidden`` + ``white-space: nowrap`` (the house
|
|
.sync-label shrinkable-item mechanism; the max-width clamp
|
|
engages only in the clipped case)."""
|
|
rule = _css_rule(".cell-clip")
|
|
assert rule is not None, "styles.css must define .cell-clip"
|
|
assert rule.get("text-overflow") == "ellipsis", (
|
|
f".cell-clip text-overflow drifted: {rule.get('text-overflow')!r} — "
|
|
f"the clipped label must show the ellipsis hint"
|
|
)
|
|
assert rule.get("overflow") == "hidden", (
|
|
f".cell-clip overflow drifted: {rule.get('overflow')!r} — "
|
|
f"the ellipsis needs the hidden overflow"
|
|
)
|
|
assert rule.get("white-space") == "nowrap", (
|
|
f".cell-clip white-space drifted: {rule.get('white-space')!r} — "
|
|
f"the ellipsis needs the nowrap"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 177 (task 01): favicon_svg — the pure palette→artwork renderer,
|
|
# byte-identical for the built-in palette (A1). The GOLDEN constant is
|
|
# the durable contract: it holds the pre-phase static file's ``<svg>``
|
|
# element (the file's comment header is file packaging, not art, and is
|
|
# NOT part of the contract; the file itself is deleted in task 03).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: The pre-phase ``frontend/assets/favicon.svg`` ``<svg>`` element —
|
|
#: hexagon + center dot + 4 spokes, ``viewBox 0 0 64 64``, lowercase
|
|
#: hex, original attribute order. ``favicon_svg(BUILTIN_COLORS)`` must
|
|
#: equal this EXACTLY (341 bytes, single line, no comment header).
|
|
FAVICON_GOLDEN = (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" '
|
|
'stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/>'
|
|
'<circle cx="32" cy="32" r="6.5" fill="#f43f5e"/>'
|
|
'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" '
|
|
'stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/>'
|
|
"</svg>"
|
|
)
|
|
|
|
|
|
def _mask_color_attrs(svg_text: str) -> str:
|
|
"""The document with its EXACTLY FOUR color attributes (the
|
|
``fill="`` / ``stroke="`` values) masked — the rest (geometry,
|
|
``d`` paths, circle ``cx/cy/r``, stroke widths, ``linejoin`` /
|
|
``linecap``, attribute order) is then comparable byte for byte.
|
|
(``stroke-width`` / ``stroke-linejoin`` / ``stroke-linecap`` never
|
|
match — they carry ``-`` before the ``="``.)"""
|
|
return re.sub(r'(fill|stroke)="#[0-9a-f]{6}"', r'\1="COLOR"', svg_text)
|
|
|
|
|
|
def test_favicon_svg_builtins_is_byte_identical_to_the_static_art() -> None:
|
|
"""The byte-identical contract (D1): for the built-in palette the
|
|
output is EXACTLY the static file's ``<svg>`` element — lowercase
|
|
hex, same attribute order — pinned by the golden constant (the
|
|
durable contract; the static file is deleted in task 03, so the
|
|
constant, not the file, keeps the bytes alive)."""
|
|
out = theming.favicon_svg(dict(theming.BUILTIN_COLORS))
|
|
assert out == FAVICON_GOLDEN
|
|
# Single-line element, no comment header (the file's comment is
|
|
# file packaging, not art), exact extent.
|
|
assert "\n" not in out
|
|
assert out.startswith('<svg xmlns="http://www.w3.org/2000/svg"')
|
|
assert out.endswith("</svg>")
|
|
# The three built-in role values land in the golden's roles.
|
|
assert 'fill="#1a0f0f"' in out # surface
|
|
assert out.count('"#f43f5e"') == 2 # brand (hexagon stroke + circle)
|
|
assert 'stroke="#fca5a5"' in out # brand_ink
|
|
|
|
|
|
def test_favicon_svg_themed_carries_the_three_roles() -> None:
|
|
"""A palette with distinct ``surface``/``brand``/``brand_ink``
|
|
(plus the other 14 built-ins): the three hexes appear in their
|
|
exact attribute roles — asserted by PARSING the SVG attributes
|
|
(not by substring) — and none of the other 14 built-in hexes
|
|
appears anywhere in the output."""
|
|
colors = dict(theming.BUILTIN_COLORS)
|
|
colors["surface"] = "#102030"
|
|
colors["brand"] = "#4f46e5"
|
|
colors["brand_ink"] = "#a5b4fc"
|
|
out = theming.favicon_svg(colors)
|
|
ns = "{http://www.w3.org/2000/svg}"
|
|
root = ET.fromstring(out)
|
|
assert root.tag == f"{ns}svg"
|
|
assert root.get("viewBox") == "0 0 64 64"
|
|
hexagon, circle, spokes = list(root)
|
|
# Hexagon: fill ← surface, stroke ← brand (geometry unchanged).
|
|
assert hexagon.tag == f"{ns}path"
|
|
assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z"
|
|
assert hexagon.get("fill") == "#102030"
|
|
assert hexagon.get("stroke") == "#4f46e5"
|
|
# Center dot: fill ← brand.
|
|
assert circle.tag == f"{ns}circle"
|
|
assert circle.get("fill") == "#4f46e5"
|
|
# Spokes: stroke ← brand_ink.
|
|
assert spokes.tag == f"{ns}path"
|
|
assert spokes.get("stroke") == "#a5b4fc"
|
|
# None of the other 14 built-in hexes appears in the output.
|
|
for key, value in theming.BUILTIN_COLORS.items():
|
|
if key not in ("surface", "brand", "brand_ink"):
|
|
assert value not in out, (
|
|
f"built-in {key} value {value} must not appear in a themed favicon"
|
|
)
|
|
|
|
|
|
def test_favicon_svg_geometry_is_invariant() -> None:
|
|
"""Geometry invariance: with a themed palette, everything EXCEPT
|
|
the four color attributes is byte-equal to the built-in output
|
|
(viewBox, both path ``d``s, circle ``cx/cy/r``, stroke widths,
|
|
``linejoin``/``linecap``, attribute order — the mask leaves only
|
|
the four ``fill="…"`` / ``stroke="…"`` values to differ)."""
|
|
colors = dict(theming.BUILTIN_COLORS)
|
|
colors["surface"] = "#102030"
|
|
colors["brand"] = "#4f46e5"
|
|
colors["brand_ink"] = "#a5b4fc"
|
|
themed = theming.favicon_svg(colors)
|
|
builtin = theming.favicon_svg(dict(theming.BUILTIN_COLORS))
|
|
assert _mask_color_attrs(themed) == _mask_color_attrs(builtin)
|
|
# Sanity: the mask masked exactly the four color attributes.
|
|
assert _mask_color_attrs(builtin).count('"COLOR"') == 4
|