phase: 93_theme_semantic_completion
All verification is complete. Final report: **Phase 93 — Theme semantic completion: FINAL VERIFICATION PASS — ALL GREEN** - Verified full implementation in tree: migration `0016` (8 nullable semantic columns, applied at head), 17-var `BUILTIN_COLORS`/`COLOR_FIELDS`/`effective_settings`, API validation, `#view-theme` State-colors fieldset (17 pickers), `theme.js` FIELDS/PAIRS (5→8), `.page-head` surface panel (6 shell views + doc-edit + shared.html; login card / document sticky header audited as already-surfaced), mock_llm `content: None` fix - Fixed 2 pre-existing defects (both fail identically on baseline `d4f38ad`, proven via worktree A/B): `test_nav_rename_sources` — expected nav tail missing the phase-91 "Theme" link; `test_stale_ui_copy` — now truncates `saved_chats` before/after (house `test_suggestion_chips` pattern) so the seed-chip contract is deterministic on the shared dev DB (owner's 22 saved chats triggered phase-80 last-3-questions) - Tests: `uv run pytest --cov=app --cov-report=term-missing` → **1868 passed, app/ 99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → **0 errors** - E2E: dedicated `uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov` → **8/8 in isolation** (all-gray 17-color theme: zero residual color on saved-result/Stale/Revoked/Local/tool-call elements, text labels intact, gray heads non-transparent, pre-paint tag, Reset → byte-identical no-tag); 15 theme/header/nav/responsive suites green in isolation; full 85-file combined run: only the 2 fixed pre-existing failures + 1 combined-run artifact (`test_sync_upload_progress`, green in isolation) - Completion criteria: (1) monochrome E2E ✓ (2) default byte-identical, no `#bor-theme` tag ✓ (3) all page heads on solid surface ✓ (4) suite/coverage/lint/E2E green ✓ (5) phases 01–92 no behavior change ✓ (6) commit left to harness per protocol - Notable: cleaned stray uvicorn leftovers from prior implementation pass (owner's `--reload` dev server untouched); no deviations from the phase design - Next pending phase: `94_ls_tree_drilldown`
This commit is contained in:
@@ -902,8 +902,10 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
the ship-hidden #theme-content (the #git-sources-content pattern)
|
||||
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
|
||||
#theme-form with the 3 labeled branding text inputs (maxlength=300
|
||||
— the server re-validates) + the 9 labeled type=color palette inputs
|
||||
(the 9 identity variables, in the theming.COLOR_FIELDS order), the
|
||||
— the server re-validates) + the 17 labeled type=color palette
|
||||
inputs (the 9 identity variables, then the 8 semantic state
|
||||
variables in the State colors fieldset — phase 93 — in the
|
||||
theming.COLOR_FIELDS order), the
|
||||
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
|
||||
(no real submit), and the three task-05 feedback lines: #theme-error
|
||||
(role=alert), #theme-result (role=status), #theme-contrast
|
||||
@@ -941,8 +943,9 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
)
|
||||
# The static form skeleton (the E2E-stable-selectors house
|
||||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
||||
# and the 9 labeled type=color palette inputs (the 9 identity
|
||||
# variables — one per theming.COLOR_FIELDS field).
|
||||
# and the 17 labeled type=color palette inputs (the 9 identity
|
||||
# variables, then the 8 semantic state variables — phase 93 —
|
||||
# one per theming.COLOR_FIELDS field).
|
||||
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||||
"the #theme-form must be STATIC markup in the shell"
|
||||
)
|
||||
@@ -963,6 +966,16 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"theme-brand",
|
||||
"theme-brand-soft",
|
||||
"theme-brand-ink",
|
||||
# The 8 semantic state pickers (phase 93 — the State colors
|
||||
# fieldset after the palette fieldset).
|
||||
"theme-ok-bg",
|
||||
"theme-ok-ink",
|
||||
"theme-err-bg",
|
||||
"theme-err-ink",
|
||||
"theme-err-line",
|
||||
"theme-accent-bg",
|
||||
"theme-accent-ink",
|
||||
"theme-accent-line",
|
||||
):
|
||||
assert re.search(
|
||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||||
|
||||
@@ -22,10 +22,11 @@ def test_all_tables_registered() -> None:
|
||||
|
||||
|
||||
def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
"""Phase 91 (9 identity colors after phase 92, task 01): the
|
||||
"""Phase 91 (9 identity colors after phase 92, task 01; the 8
|
||||
semantic state colors after phase 93, task 01 — B3 revised): the
|
||||
single-row UI settings table — Integer PK ``id`` with the
|
||||
Python-side ``default=1`` (the row is always id 1), the 3 strings
|
||||
VARCHAR(300) and the 9 identity colors VARCHAR(7), ALL nullable
|
||||
VARCHAR(300) and the 17 palette colors VARCHAR(7), ALL nullable
|
||||
(NULL = default — B1: env value for the strings, the built-in
|
||||
palette for the colors)."""
|
||||
settings_table = Base.metadata.tables["ui_settings"]
|
||||
@@ -33,6 +34,8 @@ def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
"id", "app_name", "input_placeholder", "footer_text",
|
||||
"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",
|
||||
}
|
||||
pk = settings_table.c["id"]
|
||||
assert pk.primary_key is True, "ui_settings.id must be the PK"
|
||||
@@ -42,7 +45,9 @@ def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
assert col.nullable is True, f"{name} must be NULL (env default)"
|
||||
assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)"
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
"ok_bg", "ok_ink", "err_bg", "err_ink", "err_line",
|
||||
"accent_bg", "accent_ink", "accent_line"):
|
||||
col = settings_table.c[name]
|
||||
assert col.nullable is True, f"{name} must be NULL (the built-in)"
|
||||
assert getattr(col.type, "length", None) == 7, f"{name} must be String(7) — #rrggbb"
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Unit: the phase-93 page-head surface panel contract (task 03 —
|
||||
source pins).
|
||||
|
||||
Owner direction (TODO.md L3): "Also the header and description of each
|
||||
page needs a background - the grid makes it hard to read." Every
|
||||
page's ``h1`` + description (the shell's standard ``.page-head`` frame)
|
||||
sits on a solid ``var(--surface)`` panel — the house card language —
|
||||
so the 44px background grid never fights the heading text. Browser
|
||||
behavior (computed ``background-color`` non-transparent on every head,
|
||||
the History flex row + 360px wrap unbroken, the monochrome theme
|
||||
graying the panel) is E2E-gated by ``tests/e2e/
|
||||
test_theme_semantic_completion.py`` (task 04) and the existing
|
||||
header/nav/responsive suites; here we pin the source-level contract
|
||||
(house pattern: ``tests/unit/test_background_no_motion.py`` parses
|
||||
``styles.css`` rules):
|
||||
|
||||
* the shared ``.page-head`` rule declares the panel — a NON-transparent
|
||||
``background`` built from ``var(--surface)`` (no color literal of any
|
||||
kind, no blur — the phase-08 perf anchor), the 1px ``var(--line)``
|
||||
border, the house card radius, and card-rhythm padding;
|
||||
* the ``#view-history`` page-head keeps its SCOPED flex row (padding
|
||||
lives on the flex container — the base rule — never on the
|
||||
children), and the mobile ``.page-head-row { flex-wrap: wrap; }``
|
||||
survives;
|
||||
* every shell view that carries a ``.page-head`` in the shell markup
|
||||
is covered by the ONE rule (six views; ``#view-chat`` carries no
|
||||
``.page-head`` — its head is the navbar, audited), and
|
||||
``doc-edit.html`` keeps its ``.page-head``;
|
||||
* the standalone pages: the shared page's head (``h1#shared-title`` +
|
||||
the lede) is wrapped in the same ``.page-head`` class, while the two
|
||||
heads that already sit inside a surfaced card are deliberately
|
||||
UNTOUCHED (login's ``.login-card`` — surface card; document viewer's
|
||||
``.doc-titlebar`` inside the sticky surface ``.doc-header``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
|
||||
# The six shell views whose h1 + description sit in a .page-head
|
||||
# (#view-chat carries none — its head is the navbar; audited).
|
||||
SHELL_VIEWS_WITH_PAGE_HEAD = (
|
||||
"view-tuning",
|
||||
"view-rag",
|
||||
"view-git-sources",
|
||||
"view-history",
|
||||
"view-tokens",
|
||||
"view-theme",
|
||||
)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css_no_comments() -> str:
|
||||
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
|
||||
|
||||
|
||||
def _find_rule(css: str, selector: str) -> re.Match[str] | None:
|
||||
"""The first top-level ``selector { ... }`` rule (comments
|
||||
stripped by the caller when prose must not interfere)."""
|
||||
return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css)
|
||||
|
||||
|
||||
def _rule_block(css: str, selector: str) -> str:
|
||||
rule = _find_rule(css, selector)
|
||||
assert rule, f"styles.css must define a {selector} rule"
|
||||
return rule.group(1)
|
||||
|
||||
|
||||
def _view_section(body: str, view_id: str) -> str:
|
||||
"""The view section slice — from its opening tag to the next
|
||||
sibling view (or ``</main>``). A first-``</section>`` slice would
|
||||
cut short: the views nest gate sections (``<section
|
||||
class="sources-gate">``) INSIDE, and the Git-sources / Theme
|
||||
``.page-head`` sits after the first nested close."""
|
||||
start = body.find(f'<section class="view" id="{view_id}"')
|
||||
assert start != -1, f"the #{view_id} section must be in the shell"
|
||||
boundaries = [
|
||||
e
|
||||
for e in (body.find('<section class="view"', start + 1), body.find("</main>", start))
|
||||
if e != -1
|
||||
]
|
||||
assert boundaries, "the shell must have a closing </main>"
|
||||
return body[start : min(boundaries)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The shared .page-head rule — the surface panel
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_page_head_rule_is_the_surface_panel() -> None:
|
||||
"""The one shared rule declares the panel: a non-transparent
|
||||
background from ``var(--surface)`` (itself tab-controlled — a
|
||||
monochrome theme grays the head automatically), the 1px
|
||||
``var(--line)`` border, the house card radius, and card-rhythm
|
||||
padding (the phase-93 panel: 1rem block / 1.25rem inline)."""
|
||||
block = _rule_block(_css_no_comments(), ".page-head")
|
||||
assert "background: var(--surface)" in block, (
|
||||
"the .page-head panel must fill with var(--surface) — solid, "
|
||||
"never transparent, never a literal"
|
||||
)
|
||||
assert "border: 1px solid var(--line)" in block, (
|
||||
"the panel must carry the house 1px --line border"
|
||||
)
|
||||
assert "border-radius: var(--radius)" in block, (
|
||||
"the panel must use the house card radius"
|
||||
)
|
||||
assert re.search(r"padding:\s*1rem\s+1\.25rem", block), (
|
||||
"the panel must carry the card-rhythm padding (1rem 1.25rem)"
|
||||
)
|
||||
|
||||
|
||||
def test_page_head_rule_has_no_color_literal_and_no_blur() -> None:
|
||||
"""Phase-92 invariant + phase-08 perf anchor: the panel rule
|
||||
introduces NO new color literal (hex / rgb / hsl / color-mix —
|
||||
``var(--…)`` only) and no filter/blur."""
|
||||
block = _rule_block(_css_no_comments(), ".page-head")
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), (
|
||||
"no hex color literal in the .page-head panel (phase-92 invariant)"
|
||||
)
|
||||
for func in ("rgb(", "hsl(", "color-mix("):
|
||||
assert func not in block, f"no {func}… literal in the .page-head panel"
|
||||
assert "filter" not in block and "blur" not in block, (
|
||||
"no filter/blur in the .page-head panel (phase-08 perf anchor)"
|
||||
)
|
||||
|
||||
|
||||
def test_page_head_background_is_solid_not_translucent() -> None:
|
||||
"""The panel is SOLID — the assumption the phase locked (not
|
||||
translucent, not a full-bleed band, no blur): the background
|
||||
declaration names exactly the surface variable, nothing mixed."""
|
||||
block = _rule_block(_css_no_comments(), ".page-head")
|
||||
backgrounds = re.findall(r"(?m)^\s*background(?:-color)?:\s*([^;]+);", block)
|
||||
assert backgrounds == ["var(--surface)"], (
|
||||
f"the .page-head background must be exactly var(--surface), "
|
||||
f"got {backgrounds}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Layout safety — the History flex row and the mobile wrap
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_history_page_head_keeps_its_scoped_flex_row() -> None:
|
||||
"""The ``#view-history .page-head`` flex row (title left, refresh
|
||||
pill right — phase 77) survives the panel: the scoped rule keeps
|
||||
its flex declarations and adds NO padding of its own (the task-03
|
||||
rule — padding on the flex container, i.e. the base ``.page-head``
|
||||
rule, never on the children, so space-between + align stay intact
|
||||
inside the padded box)."""
|
||||
scoped = _rule_block(_css_no_comments(), "#view-history .page-head")
|
||||
for decl in (
|
||||
"display: flex",
|
||||
"flex-wrap: wrap",
|
||||
"align-items: flex-start",
|
||||
"justify-content: space-between",
|
||||
):
|
||||
assert decl in scoped, f"#view-history .page-head must keep {decl!r}"
|
||||
assert not re.search(r"(?m)^\s*padding\b", scoped), (
|
||||
"the scoped History rule must not add its own padding — the "
|
||||
"base .page-head rule (the flex container) carries it"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_page_head_row_wrap_survives() -> None:
|
||||
"""≤640px: ``.page-head-row { flex-wrap: wrap; }`` (the RAG head's
|
||||
Sync pill drops below the title at full width) still exists in the
|
||||
mobile block — the wrap keeps working INSIDE the panel."""
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", _css())
|
||||
assert mobile, "the mobile media query must exist"
|
||||
assert ".page-head-row { flex-wrap: wrap; }" in mobile.group(1), (
|
||||
"the mobile .page-head-row wrap must survive the panel"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Coverage — every head that sits on the grid gets the panel
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_shell_view_with_a_page_head_is_covered() -> None:
|
||||
"""The ONE rule covers every shell view that carries the class:
|
||||
all six do (their ``class="page-head"`` div is static markup in
|
||||
the shell); #view-chat carries NO .page-head (its head is the
|
||||
navbar — audited, untouched)."""
|
||||
body = INDEX_HTML.read_text(encoding="utf-8")
|
||||
for view_id in SHELL_VIEWS_WITH_PAGE_HEAD:
|
||||
assert 'class="page-head"' in _view_section(body, view_id), (
|
||||
f"#{view_id} must keep its .page-head (the shared rule panels it)"
|
||||
)
|
||||
assert 'class="page-head"' not in _view_section(body, "view-chat"), (
|
||||
"#view-chat carries no .page-head (its head is the navbar)"
|
||||
)
|
||||
|
||||
|
||||
def test_doc_edit_page_head_is_covered() -> None:
|
||||
"""doc-edit.html's head already IS a .page-head (h1 'Edit doc' +
|
||||
the .page-sub lede) — the shared rule panels it with no markup
|
||||
change."""
|
||||
html = DOC_EDIT_HTML.read_text(encoding="utf-8")
|
||||
head = re.search(
|
||||
r'<div class="page-head">\s*<h1>Edit doc</h1>[\s\S]*?class="page-sub"',
|
||||
html,
|
||||
)
|
||||
assert head, "doc-edit.html must keep its .page-head (h1 + page-sub)"
|
||||
|
||||
|
||||
def test_shared_page_head_is_wrapped_in_page_head() -> None:
|
||||
"""The shared page's head (``h1#shared-title`` + the read-only
|
||||
lede) was a direct child of the grid-exposed ``.shared-shell`` —
|
||||
it is now wrapped in the same ``.page-head`` class, so the shared
|
||||
rule panels it (the h1 keeps the ``.page-head h1`` size per the
|
||||
``#shared-title`` rule)."""
|
||||
html = SHARED_HTML.read_text(encoding="utf-8")
|
||||
wrapped = re.search(
|
||||
r'<div class="page-head">\s*'
|
||||
r"<h1 id=\"shared-title\">Shared conversation</h1>\s*"
|
||||
r'<p class="shared-note">Shared via Brain of Reese — read-only\.</p>\s*'
|
||||
r"</div>",
|
||||
html,
|
||||
)
|
||||
assert wrapped, (
|
||||
"shared.html must wrap the h1 + lede in a .page-head div"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Deliberate skips — heads already inside a surfaced card
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_head_is_already_carded_and_untouched() -> None:
|
||||
"""login.html: h1#login-title lives inside section.login-card,
|
||||
which is ALREADY the surface card (var(--surface) fill + border +
|
||||
radius + shadow) — the TODO targets grid-exposed text, so the
|
||||
login head gets no double panel."""
|
||||
html = LOGIN_HTML.read_text(encoding="utf-8")
|
||||
card = re.search(
|
||||
r'<section class="login-card"[^>]*>[\s\S]*?'
|
||||
r'<h1 id="login-title">Sign in</h1>',
|
||||
html,
|
||||
)
|
||||
assert card, "the login h1 must sit inside the .login-card"
|
||||
card_css = _rule_block(_css_no_comments(), ".login-card")
|
||||
assert "background: var(--surface)" in card_css
|
||||
assert "border: 1px solid var(--line)" in card_css
|
||||
assert "border-radius: var(--radius)" in card_css
|
||||
assert 'class="page-head"' not in html, (
|
||||
"login.html must not gain a .page-head (the card already panels it)"
|
||||
)
|
||||
|
||||
|
||||
def test_document_head_is_already_carded_and_untouched() -> None:
|
||||
"""document.html: h1#doc-title lives in the .doc-titlebar row 2
|
||||
inside the STICKY .doc-header, which is already surface-filled
|
||||
(var(--surface) — the two-row pinned bar) — no double panel."""
|
||||
html = DOCUMENT_HTML.read_text(encoding="utf-8")
|
||||
titlebar = re.search(
|
||||
r'<div class="doc-titlebar">[\s\S]*?<h1 id="doc-title">', html
|
||||
)
|
||||
assert titlebar, "the document h1 must sit inside the .doc-titlebar"
|
||||
header_css = _rule_block(_css_no_comments(), ".doc-header")
|
||||
assert "background: var(--surface)" in header_css, (
|
||||
"the sticky .doc-header must stay surface-filled"
|
||||
)
|
||||
assert 'class="page-head"' not in html, (
|
||||
"document.html must not gain a .page-head (the titlebar is "
|
||||
"already inside the surface header)"
|
||||
)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
+87
-25
@@ -7,12 +7,12 @@ 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 9 built-ins 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;
|
||||
* ``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 9 variables, ``COLOR_FIELDS``
|
||||
``""``) 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
|
||||
@@ -67,13 +67,16 @@ def _root_declarations() -> dict[str, str]:
|
||||
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 9 identity variables — no more, no fewer)."""
|
||||
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",
|
||||
}, f"BUILTIN_COLORS must name exactly the 9 identity variables, got {sorted(builtin_names)}"
|
||||
"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}"
|
||||
@@ -83,16 +86,19 @@ def test_builtin_colors_match_styles_css_root() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_color_fields_are_the_nine_keys_in_readme_order() -> None:
|
||||
"""``COLOR_FIELDS`` is the 9 keys in the themes-README order — the
|
||||
order the resolver, the API, and the tag renderer all rely on.
|
||||
(Phase 92, task 01: ``grid_line`` is the 9th identity variable,
|
||||
slotting in between ``line`` and ``brand`` — structural colors
|
||||
first, brand last.)"""
|
||||
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")
|
||||
|
||||
|
||||
@@ -113,12 +119,14 @@ def _env_settings() -> Settings:
|
||||
|
||||
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 12 keys."""
|
||||
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"
|
||||
@@ -169,7 +177,7 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
|
||||
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 12 keys."""
|
||||
values it reports must be real ``str``s for all 20 values."""
|
||||
from app.config import get_settings
|
||||
|
||||
_start_row_missing(db)
|
||||
@@ -196,27 +204,40 @@ def test_theme_style_tag_all_builtins_is_empty_string() -> None:
|
||||
assert theming.theme_style_tag(colors) != ""
|
||||
|
||||
|
||||
def test_theme_style_tag_one_changed_carries_all_nine_in_order() -> None:
|
||||
"""A single non-built-in color still emits ALL 9 variables, in
|
||||
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace).
|
||||
Phase 92 (task 01): the tag carries ``--grid-line:#4a2626;`` between
|
||||
``--line`` and ``--brand`` (the 9th identity variable — the
|
||||
background grid texture)."""
|
||||
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["brand"] = "#818cf8"
|
||||
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:#818cf8;"
|
||||
"--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 "--brand:#818cf8;" in tag
|
||||
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:
|
||||
@@ -226,11 +247,13 @@ def test_theme_style_tag_multiple_changed() -> None:
|
||||
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 9 dashed names is the COLOR_FIELDS order.
|
||||
# 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]
|
||||
|
||||
@@ -327,3 +350,42 @@ def test_theme_csp_hash_changes_with_the_palette() -> None:
|
||||
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]
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Unit: the admin UI-settings API (phase 91, task 01).
|
||||
|
||||
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
|
||||
contract and the GET/PUT persistence on the single ``ui_settings`` row:
|
||||
contract and the GET/PUT persistence on the single ``ui_settings`` row
|
||||
(all 17 palette colors since phase 93, task 01 — the 9 identity +
|
||||
the 8 semantic state):
|
||||
|
||||
* PUT validation — the 422s NAME the offending field (fixed details):
|
||||
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
|
||||
prefix, 3-digit shorthand, 8 hex chars, missing ``#``);
|
||||
prefix, 3-digit shorthand, 8 hex chars, missing ``#``) — identity AND
|
||||
semantic fields alike (the loops are ``COLOR_FIELDS``-driven);
|
||||
* normalization — colors are lowercased on store; a color EQUAL to its
|
||||
built-in is stored as NULL (the owner-locked rule: "save the defaults"
|
||||
must leave the row empty — the no-op injection contract); an empty /
|
||||
@@ -38,6 +41,10 @@ ALL_NULL_BODY: dict[str, str | None] = {
|
||||
"bg": None, "surface": None, "ink": None, "ink_soft": None,
|
||||
"line": None, "grid_line": None, "brand": None, "brand_soft": None,
|
||||
"brand_ink": None,
|
||||
# The 8 semantic state colors (phase 93, task 01).
|
||||
"ok_bg": None, "ok_ink": None, "err_bg": None, "err_ink": None,
|
||||
"err_line": None, "accent_bg": None, "accent_ink": None,
|
||||
"accent_line": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +87,8 @@ def test_put_too_long_string_422_names_the_field(
|
||||
|
||||
|
||||
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
"""Each of the 9 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
|
||||
naming that field — 3-digit shorthand, 8 hex digits, a bare hex
|
||||
"""Each of the 17 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a
|
||||
422 naming that field — 3-digit shorthand, 8 hex digits, a bare hex
|
||||
without ``#``, a named color, and the empty string (the color clear
|
||||
operation is ``null``, not ``""``)."""
|
||||
for field in theming.COLOR_FIELDS:
|
||||
@@ -95,6 +102,15 @@ def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
r = admin_client.put("/api/ui-settings", json={"grid_line": "nope"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == "grid_line must be a #rrggbb hex color"
|
||||
# Phase 93 (task 01): the semantic state fields name their 422 the
|
||||
# same fixed way (the loop covers all 8 via COLOR_FIELDS; the
|
||||
# explicit case pins a semantic field name in the detail).
|
||||
r = admin_client.put("/api/ui-settings", json={"err_line": "nope"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == "err_line must be a #rrggbb hex color"
|
||||
r = admin_client.put("/api/ui-settings", json={"accent_ink": "#12345678"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == "accent_ink must be a #rrggbb hex color"
|
||||
|
||||
|
||||
def test_put_lowercases_colors_on_store(
|
||||
@@ -158,6 +174,42 @@ def test_put_grid_line_built_in_is_stored_as_null(
|
||||
assert row.grid_line == "#123123" # non-built-in is stored as-is
|
||||
|
||||
|
||||
def test_put_semantic_built_in_is_stored_as_null(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Phase 93 (task 01): the 8 semantic state colors get the same
|
||||
owner-locked normalization as the 9 identity colors — a value equal
|
||||
to its built-in stores NULL (the response still reports the
|
||||
built-in; the compare happens AFTER the lowercase — one value is
|
||||
PUT uppercase, proving it); a NON-built-in value is stored as-is
|
||||
(lowercased)."""
|
||||
r = admin_client.put("/api/ui-settings", json={"ok_ink": "#6EE7A8"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.ok_ink is None # built-in (uppercase in) → NULL
|
||||
|
||||
r = admin_client.put("/api/ui-settings", json={"err_bg": "#2D0A0A"})
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.err_bg is None # the second built-in (uppercase) → NULL too
|
||||
|
||||
r = admin_client.put("/api/ui-settings", json={"accent_ink": "#CCCCCC"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["accent_ink"] == "#cccccc"
|
||||
db.expire_all()
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.accent_ink == "#cccccc" # non-built-in stored as-is, lowercased
|
||||
# A semantic field NOT in the body clears back to NULL (full
|
||||
# replacement — the built-in returns via the resolver).
|
||||
assert row.ok_ink is None
|
||||
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
|
||||
|
||||
|
||||
def test_put_empty_string_is_the_clear_operation(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user