phase: 92_theme_save_and_coverage
**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix. - Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated. - `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings - `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site) - `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** - Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions. - Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04). - Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
This commit is contained in:
@@ -17,8 +17,8 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
|
||||
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
|
||||
admin sees the "Theme" nav link and the form (gate hidden); the
|
||||
11 inputs show the effective defaults (the 3 template strings +
|
||||
the 8 built-in hexes parsed out of ``styles.css``'s ``:root``
|
||||
12 inputs show the effective defaults (the 3 template strings +
|
||||
the 9 built-in hexes parsed out of ``styles.css``'s ``:root``
|
||||
IN-TEST — the suite can never drift from the stylesheet); Save
|
||||
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
|
||||
held, then restored) and lands the role=status "Theme saved.";
|
||||
@@ -29,7 +29,7 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
|
||||
should load immediately, not pop in": after a save, the RAW
|
||||
served HTML of ``/`` carries exactly one ``<style
|
||||
id="bor-theme">`` with all 8 vars = the saved hexes, placed
|
||||
id="bor-theme">`` with all 9 vars = the saved hexes, placed
|
||||
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
|
||||
anonymous context — and the computed ``:root`` custom properties
|
||||
equal the saved hexes at load (the inline tag precedes every
|
||||
@@ -44,7 +44,7 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
|
||||
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
|
||||
the role=status "Reset to the built-in theme.", re-populates the
|
||||
11 defaults, serves NO theme tag, and the served bytes equal a
|
||||
12 defaults, serves NO theme tag, and the served bytes equal a
|
||||
row-less deployment byte for byte (the no-op injection
|
||||
contract).
|
||||
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
|
||||
@@ -103,7 +103,9 @@ from e2e.conftest import (
|
||||
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
# The distinct E2E palette (task 06): a full non-built-in indigo set —
|
||||
# The distinct E2E palette (task 06, extended in phase 92 task 05:
|
||||
# grid_line — the 9th identity var, distinct from its built-in
|
||||
# #4a2626 and from line #232a4a): a full non-built-in indigo set —
|
||||
# every value differs from its built-in, so the tag is non-empty and
|
||||
# every saved color is stored as-is (no built-in→NULL collapse).
|
||||
PALETTE: dict[str, str] = {
|
||||
@@ -112,6 +114,7 @@ PALETTE: dict[str, str] = {
|
||||
"ink": "#e6e9f5",
|
||||
"ink_soft": "#a8b0d0",
|
||||
"line": "#232a4a",
|
||||
"grid_line": "#2b3550",
|
||||
"brand": "#4f46e5",
|
||||
"brand_soft": "#1e2447",
|
||||
"brand_ink": "#c7d2fe",
|
||||
@@ -142,7 +145,7 @@ COLOR_INPUT_IDS: dict[str, str] = {
|
||||
|
||||
|
||||
def _builtin_colors() -> dict[str, str]:
|
||||
"""The 8 built-in identity hexes parsed OUT of
|
||||
"""The 9 built-in identity hexes parsed OUT of
|
||||
``frontend/assets/styles.css``'s ``:root`` in-test — the single
|
||||
source of truth, so the suite can't drift from the stylesheet it
|
||||
asserts on."""
|
||||
@@ -173,7 +176,7 @@ def _template_defaults() -> dict[str, str]:
|
||||
|
||||
def _expected_tag(colors: dict[str, str]) -> str:
|
||||
"""The EXACT inline tag ``theme_style_tag`` renders for
|
||||
``colors``: one ``:root`` override, all 8 vars in COLOR_FIELDS
|
||||
``colors``: one ``:root`` override, all 9 vars in COLOR_FIELDS
|
||||
order, no whitespace (the byte the middleware injects)."""
|
||||
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
|
||||
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||
@@ -323,8 +326,8 @@ def _fill_theme_form(
|
||||
palette: dict[str, str],
|
||||
strings: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Fill the 11 inputs: the 3 text fields (``strings``, default
|
||||
the E2E set) + the 8 color pickers (``palette``)."""
|
||||
"""Fill the 12 inputs: the 3 text fields (``strings``, default
|
||||
the E2E set) + the 9 color pickers (``palette``)."""
|
||||
text_values = strings if strings is not None else SAVED_STRINGS
|
||||
page.fill("#theme-app-name", text_values["app_name"])
|
||||
page.fill("#theme-placeholder", text_values["input_placeholder"])
|
||||
@@ -334,7 +337,7 @@ def _fill_theme_form(
|
||||
|
||||
|
||||
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
|
||||
"""Assert all 11 inputs show the given effective values."""
|
||||
"""Assert all 12 inputs show the given effective values."""
|
||||
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
|
||||
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
|
||||
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
|
||||
@@ -344,7 +347,7 @@ def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, s
|
||||
|
||||
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
|
||||
"""The RAW served HTML carries exactly one inline theme tag, with
|
||||
all 8 vars = the given hexes, placed IMMEDIATELY before
|
||||
all 9 vars = the given hexes, placed IMMEDIATELY before
|
||||
``</head>`` (``inject_theme``'s exact placement: the tag ends
|
||||
exactly where ``</head>`` begins and carries the injector's
|
||||
single leading newline)."""
|
||||
@@ -357,7 +360,7 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
|
||||
|
||||
|
||||
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
|
||||
"""The first-paint proof: all 8 computed ``:root`` custom
|
||||
"""The first-paint proof: all 9 computed ``:root`` custom
|
||||
properties equal the given hexes. The inline tag precedes every
|
||||
stylesheet application, so a themed deployment resolves them
|
||||
from the first style pass — no red flash, no pop-in (custom
|
||||
@@ -399,8 +402,8 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||
expect(page.locator("#theme-gate")).to_be_hidden()
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The 11 inputs show the EFFECTIVE defaults: the 3 template
|
||||
# strings + the 8 built-in hexes parsed straight out of
|
||||
# The 12 inputs show the EFFECTIVE defaults: the 3 template
|
||||
# strings + the 9 built-in hexes parsed straight out of
|
||||
# styles.css's :root (the resolver's missing-row branch).
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
|
||||
@@ -432,7 +435,7 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# save's refetch is the canonical state).
|
||||
_expect_form_values(page, SAVED_STRINGS, PALETTE)
|
||||
|
||||
# The row landed in Postgres (the id-1 single row, all 11 values
|
||||
# The row landed in Postgres (the id-1 single row, all 12 values
|
||||
# — every palette color differs from its built-in, so nothing
|
||||
# collapsed to NULL).
|
||||
with SessionLocal() as db:
|
||||
@@ -472,7 +475,7 @@ def test_saved_theme_is_pre_paint_for_everyone(
|
||||
_seed_theme_via_api(app_url, _cookies(page))
|
||||
|
||||
# The RAW served HTML (httpx — no JS at all, the server's own
|
||||
# bytes): exactly one inline theme tag, all 8 vars = the saved
|
||||
# bytes): exactly one inline theme tag, all 9 vars = the saved
|
||||
# hexes, immediately before </head> (the pre-paint mechanism the
|
||||
# middleware unit tests pin — this is its observable
|
||||
# consequence).
|
||||
@@ -603,7 +606,7 @@ def test_anonymous_and_token_user_are_walled(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and
|
||||
# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and
|
||||
# byte-identical served HTML (the no-op injection contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -650,7 +653,7 @@ def test_reset_restores_the_builtin_byte_identical(
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# The form re-populates to the 11 defaults (the env/built-in
|
||||
# The form re-populates to the 12 defaults (the env/built-in
|
||||
# merge, re-rendered from the refetch)…
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
# …and the WCAG warning is gone (the built-in palette passes all
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
"""Phase 92 E2E (Playwright): Save/Reset repaint the OPEN page without a
|
||||
reload, and the Theme tab's variables drive the ENTIRE site.
|
||||
|
||||
Source: owner chat defect report (post-phase-91): (1) "Clicking 'save
|
||||
theme' reverts the theme back to the previous theme, a refresh is
|
||||
required to see the new theme."; (2) "Not everything is controllable
|
||||
via the theme controls. Certain buttons and text are still light pink
|
||||
on highlight, for example. The background grid never changes color." —
|
||||
"The theme controls should allow manipulating the entire site's theme."
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov
|
||||
|
||||
Test → contract mapping (one story, one phase, one isolated file):
|
||||
|
||||
1. ``test_save_applies_live_without_reload`` — defect 1 (Save): the
|
||||
§7.4 save lifecycle lands, and — with NO navigation — the OPEN
|
||||
document mirrors the saved state: the computed ``:root`` palette is
|
||||
the saved 9 hexes, the ``#bor-theme`` tag's DOM text is the exact
|
||||
9-var ``:root{…}`` the next load would serve, and ``<html>``'s
|
||||
inline style holds EXACTLY the 9 saved custom properties (no stale
|
||||
pick). The server agrees (the raw ``/`` carries the 9-var tag), and
|
||||
an SPA nav to Chat (same document) keeps the saved palette on the
|
||||
computed ``--brand`` and the ``.send-btn`` fill.
|
||||
2. ``test_reset_applies_live_without_reload`` — defect 1 (Reset): on a
|
||||
THEMED load (the served tag is present), the §7.4 reset lifecycle
|
||||
lands, and — with NO navigation — the ``#bor-theme`` tag is REMOVED
|
||||
from the live document, the computed ``:root`` palette is the 9
|
||||
built-ins (parsed from ``styles.css`` in-test), ``<html>`` carries
|
||||
no overrides, the served HTML has no tag, and the with-row bytes
|
||||
equal a row-less deployment byte for byte (the no-op contract end
|
||||
to end, now 9-wide).
|
||||
3. ``test_theme_controls_drive_the_whole_site`` — defect 2: with the
|
||||
palette seeded, a fresh load's first paint is the themed paint
|
||||
(the raw HTML carries the 9-var tag immediately before ``</head>``
|
||||
+ the phase-91 CSP ``style-src 'self' 'sha256-…'``), and the
|
||||
browser-COMPUTED values prove every themed surface follows the tab:
|
||||
the background grid texture (``body::before`` — the owner's named
|
||||
defect) at 60% ``--grid-line``, ``::selection`` at 45% ``--brand``,
|
||||
the button hovers at the derived ``--brand-hover`` (and NOT the
|
||||
legacy indigo ``#7d88f5``), the nav-link wash at the saved
|
||||
``--brand-soft`` (exact), and the wordmark at the saved
|
||||
``--surface`` (exact).
|
||||
|
||||
CSP reality (why the open-page repaint rides the ``<html>`` overrides
|
||||
— the one deviation from the ``00_phase.md`` design): the repo's strict
|
||||
policy (phase 82/91 — A1 + ``style-src 'self' 'sha256-<served tag>'``,
|
||||
no ``'unsafe-inline'``) makes the design's "sync the tag, then clear
|
||||
the preview" shape impossible in a real browser: Chromium re-checks
|
||||
``style-src`` on EVERY DOM-API content change to a ``<style>`` element
|
||||
(``textContent`` on the served tag, ``createElement`` +
|
||||
``appendChild``, ``replaceChildren`` — all blocked for a fresh
|
||||
palette; verified by a standalone probe against this repo's CSP
|
||||
shape), so a cleared preview would fall back to the STALE served tag —
|
||||
the original defect. The only CSP-clean repaint path is CSSOM
|
||||
``setProperty`` on ``<html>`` (the phase-91 live preview's mechanism —
|
||||
an unchecked mutation), which task 04's ``applyServedTheme`` uses as
|
||||
the PAINT half, while the tag's DOM TEXT is still synced (the MIRROR
|
||||
half — what the next load serves, inert until the reload that serves
|
||||
matching content + hash). Test 1 therefore asserts the paint half as
|
||||
"the ``<html>`` overrides are EXACTLY the saved palette" (not an empty
|
||||
style attribute, as the cleared-preview shape would leave — on a
|
||||
reset the attribute IS empty, and test 2 asserts exactly that).
|
||||
Computed-value assertions read the browser's SERIALIZED colors
|
||||
(``rgb(…)`` for plain-var surfaces, ``color(srgb …)`` for
|
||||
``color-mix()`` results — custom properties return tokens, USED
|
||||
properties resolve) with the ±1/channel (±1/255 float) tolerance; the
|
||||
tolerance absorbs un-pinned browser rounding yet still fails any
|
||||
legacy hardcoded value by orders of magnitude.
|
||||
|
||||
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
|
||||
single row the caching middleware reads for EVERY served page — a
|
||||
leftover themed row would repaint other suites' pages) and
|
||||
``api_tokens`` rows across suites. An autouse fixture truncates
|
||||
``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND
|
||||
after every test (never a TRUNCATE on ``api_tokens`` — the shared
|
||||
DB may hold the owner's real tokens).
|
||||
|
||||
Per-module app env (the tuning/tokens/archive-upload pattern): the
|
||||
module-scoped ``app_server`` override boots the same env block as
|
||||
the shared conftest server with the branding vars pinned to the
|
||||
CODE defaults (an operator's local ``.env`` may carry the owner's
|
||||
name/placeholder/footer, and "the effective strings start at the
|
||||
template defaults" must hold regardless — the phase-61/62
|
||||
leak-guard pattern, extended to ``BOR_APP_NAME``) and
|
||||
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
|
||||
not render as env rows in this suite's app). The phase-91 file is the
|
||||
copy source — the two modules' scaffolds stay in lockstep so a
|
||||
future conftest refactor touches both at once.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.theming import COLOR_FIELDS
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
APP_PORT,
|
||||
REPO,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
# The distinct E2E palette: the phase-91 8-value indigo set PLUS the
|
||||
# 9th identity var (phase 92, task 01) — grid_line #2b3550, distinct
|
||||
# from its built-in #4a2626 and from line #232a4a. Every value differs
|
||||
# from its built-in, so the tag is non-empty and every saved color is
|
||||
# stored as-is (no built-in→NULL collapse).
|
||||
PALETTE: dict[str, str] = {
|
||||
"bg": "#0b1020",
|
||||
"surface": "#111730",
|
||||
"ink": "#e6e9f5",
|
||||
"ink_soft": "#a8b0d0",
|
||||
"line": "#232a4a",
|
||||
"grid_line": "#2b3550",
|
||||
"brand": "#4f46e5",
|
||||
"brand_soft": "#1e2447",
|
||||
"brand_ink": "#c7d2fe",
|
||||
}
|
||||
APP_NAME = "Theme E2E"
|
||||
PLACEHOLDER = "Ask the themed brain…"
|
||||
FOOTER = "E2E footer"
|
||||
SAVED_STRINGS: dict[str, str] = {
|
||||
"app_name": APP_NAME,
|
||||
"input_placeholder": PLACEHOLDER,
|
||||
"footer_text": FOOTER,
|
||||
}
|
||||
|
||||
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
|
||||
#: own markup — the static E2E-stable-selectors house convention).
|
||||
COLOR_INPUT_IDS: dict[str, str] = {
|
||||
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-test constants (single sources of truth — never duplicated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _builtin_colors() -> dict[str, str]:
|
||||
"""The 9 built-in identity hexes parsed OUT of
|
||||
``frontend/assets/styles.css``'s ``:root`` in-test — the single
|
||||
source of truth, so the suite can't drift from the stylesheet it
|
||||
asserts on."""
|
||||
css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
|
||||
root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
|
||||
assert root is not None, "styles.css must open with its :root block"
|
||||
colors: dict[str, str] = {}
|
||||
for name in COLOR_FIELDS:
|
||||
match = re.search(
|
||||
rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
|
||||
)
|
||||
assert match is not None, f"--{name} missing from styles.css :root"
|
||||
colors[name] = match.group(1).lower()
|
||||
return colors
|
||||
|
||||
|
||||
def _expected_tag(colors: dict[str, str]) -> str:
|
||||
"""The EXACT inline tag ``theme_style_tag`` renders for
|
||||
``colors``: one ``:root`` override, all 9 vars in COLOR_FIELDS
|
||||
order, no whitespace (the byte the middleware injects)."""
|
||||
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
|
||||
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||
|
||||
|
||||
def _expected_tag_content(colors: dict[str, str]) -> str:
|
||||
"""The tag's INNER ``:root{…}`` string — the ``#bor-theme``
|
||||
element's ``textContent`` after a settled save (task 04's mirror
|
||||
half is byte-identical to it: same 9 fields, same order, the
|
||||
resolver's lowercased hexes)."""
|
||||
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
|
||||
return f":root{{{declarations}}}"
|
||||
|
||||
|
||||
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
|
||||
"""All 9 computed ``:root`` custom properties equal the given
|
||||
hexes. The inline tag precedes every stylesheet application, so a
|
||||
themed deployment resolves them from the first style pass — no red
|
||||
flash, no pop-in (custom properties return the specified token, so
|
||||
the string compare is stable — the ``.trim()`` rides out any token
|
||||
whitespace)."""
|
||||
expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
|
||||
page.wait_for_function(
|
||||
"""(expected) => {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
return Object.entries(expected).every(
|
||||
([k, v]) => cs.getPropertyValue(k).trim() === v
|
||||
);
|
||||
}""",
|
||||
arg=expected,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _template_defaults() -> dict[str, str]:
|
||||
"""The 3 template strings from the CODE defaults (derived from
|
||||
the class fields — never drifts from ``app/config.py``; the
|
||||
module server pins the same values, so the effective strings
|
||||
start exactly here)."""
|
||||
return {
|
||||
"app_name": Settings.model_fields["app_name"].default,
|
||||
"input_placeholder": Settings.model_fields["input_placeholder"].default,
|
||||
"footer_text": Settings.model_fields["footer_text"].default,
|
||||
}
|
||||
|
||||
|
||||
def _wait_mount_settled(page: Page) -> None:
|
||||
"""The theme view's mount-time load has SETTLED: its populate is
|
||||
the last visible step — the app-name input carries the effective
|
||||
default (the static markup ships it empty, so only a settled
|
||||
``loadSettings`` can have written it). From here on, no in-flight
|
||||
mount load can race the test's own save/reset refetch (an
|
||||
out-of-order settle would re-reconcile the open document onto a
|
||||
stale read)."""
|
||||
expect(page.locator("#theme-app-name")).to_have_value(
|
||||
_template_defaults()["app_name"], timeout=15_000
|
||||
)
|
||||
|
||||
|
||||
def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]:
|
||||
"""The ``<html>`` inline custom properties task 04's
|
||||
``applyServedTheme`` leaves after settling on ``colors`` (the
|
||||
CSSOM PAINT half — the only CSP-clean repaint path, see the module
|
||||
docstring): exactly the vars that differ from their built-in, at
|
||||
the saved values (empty for the built-in palette)."""
|
||||
return {
|
||||
f"--{field.replace('_', '-')}": value
|
||||
for field, value in colors.items()
|
||||
if value != builtins[field]
|
||||
}
|
||||
|
||||
|
||||
def _wait_settled_open_document(
|
||||
page: Page, tag_text: str | None, overrides: dict[str, str]
|
||||
) -> None:
|
||||
"""The OPEN document mirrors the settled state (defect 1): the
|
||||
``#bor-theme`` tag's DOM text is ``tag_text`` (``None`` = the tag
|
||||
is REMOVED — the no-op/reset case) and ``<html>``'s inline style
|
||||
holds EXACTLY the ``overrides`` custom properties (no stale pick —
|
||||
the pre-task-04 code fails this wait: its save cleared the preview
|
||||
onto the stale served tag, leaving neither the synced tag text
|
||||
nor the paint-half overrides). The absent-tag case rides the ``""``
|
||||
sentinel: Playwright's wait_for_function serializes a Python
|
||||
``None`` arg as JS ``undefined`` (not ``null`` — probe-verified),
|
||||
and the real tag content is never empty anyway."""
|
||||
page.wait_for_function(
|
||||
"""(expected) => {
|
||||
const el = document.getElementById('bor-theme');
|
||||
if (expected.tag === '') {
|
||||
if (el !== null) return false;
|
||||
} else if (el === null || el.textContent !== expected.tag) {
|
||||
return false;
|
||||
}
|
||||
const s = document.documentElement.style;
|
||||
const actual = {};
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const p = s[i];
|
||||
if (p.startsWith('--')) actual[p] = s.getPropertyValue(p);
|
||||
}
|
||||
const keys = Object.keys(actual).sort();
|
||||
const expKeys = Object.keys(expected.overrides).sort();
|
||||
if (keys.length !== expKeys.length) return false;
|
||||
return keys.every(
|
||||
(k, i) => k === expKeys[i] && actual[k] === expected.overrides[k]
|
||||
);
|
||||
}""",
|
||||
arg={"tag": "" if tag_text is None else tag_text, "overrides": overrides},
|
||||
timeout=15_000,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# color-mix resolution (the browser's sRGB interpolation, for the
|
||||
# used-surface assertions — the serialized strings are compared with
|
||||
# the ±1/255 tolerance, never raw color-mix(…) tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The modern serialization the browser uses for color-mix() results:
|
||||
#: ``color(srgb R G B[/ A])`` — 0..1 float channels, alpha optional
|
||||
#: (opaque). Plain-var surfaces serialize as ``rgb(R, G, B)`` (8-bit).
|
||||
_COLOR_SRGB = re.compile(
|
||||
r"color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*/\s*([0-9.]+))?"
|
||||
)
|
||||
#: The ±1/channel tolerance (±1/255 in the 0..1 float space) — browser
|
||||
#: rounding is not pinned by the spec, and the window still fails any
|
||||
#: legacy hardcoded value by orders of magnitude.
|
||||
_TOL = 1.0 / 255.0 + 1e-9
|
||||
|
||||
|
||||
def _hex_channels(hex_str: str) -> tuple[float, float, float]:
|
||||
"""``#rrggbb`` → (r, g, b) in 0..1 floats."""
|
||||
return (
|
||||
int(hex_str[1:3], 16) / 255.0,
|
||||
int(hex_str[3:5], 16) / 255.0,
|
||||
int(hex_str[5:7], 16) / 255.0,
|
||||
)
|
||||
|
||||
|
||||
def _mix(
|
||||
a_hex: str, percent: float, b_hex: str | None = None
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""The browser's ``color-mix(in srgb, A p%, B)`` — CSS Color 4:
|
||||
sRGB interpolation is PREMULTIPLIED (with ``B = transparent`` =
|
||||
(0,0,0,0) — ``b_hex=None`` — the result is simply A at alpha
|
||||
``p/100``). Returns (r, g, b, a) in 0..1 straight channels."""
|
||||
a = (*_hex_channels(a_hex), 1.0)
|
||||
b: tuple[float, float, float, float] = (
|
||||
(0.0, 0.0, 0.0, 0.0)
|
||||
if b_hex is None
|
||||
else (*_hex_channels(b_hex), 1.0)
|
||||
)
|
||||
w = percent / 100.0
|
||||
alpha = a[3] * w + b[3] * (1.0 - w)
|
||||
if alpha == 0.0:
|
||||
return (0.0, 0.0, 0.0, 0.0)
|
||||
prem = tuple(a[i] * a[3] * w + b[i] * b[3] * (1.0 - w) for i in range(3))
|
||||
return (prem[0] / alpha, prem[1] / alpha, prem[2] / alpha, alpha)
|
||||
|
||||
|
||||
def _parse_color_srgb(ser: str) -> tuple[float, float, float, float]:
|
||||
"""Parse the browser's ``color(srgb R G B[/ A])`` serialization
|
||||
(the color-mix() result form) into 0..1 floats (opaque → a=1)."""
|
||||
match = _COLOR_SRGB.search(ser)
|
||||
assert match is not None, f"no color(srgb …) serialization in {ser!r}"
|
||||
r, g, b = (float(match.group(i)) for i in (1, 2, 3))
|
||||
a = float(match.group(4)) if match.group(4) is not None else 1.0
|
||||
return (r, g, b, a)
|
||||
|
||||
|
||||
def _close(got: tuple[float, float, float, float], want: tuple[float, float, float, float]) -> None:
|
||||
"""±1/channel (float) on r/g/b, ~exact on alpha (the browser
|
||||
serializes the exact mix alpha)."""
|
||||
for i in range(3):
|
||||
assert abs(got[i] - want[i]) <= _TOL, (
|
||||
f"channel {i}: {got[i]} !~ {want[i]} (full: {got} vs {want})"
|
||||
)
|
||||
assert abs(got[3] - want[3]) <= 1e-4, f"alpha: {got[3]} != {want[3]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-module app env (the tuning/tokens/archive-upload pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: the branding vars
|
||||
are pinned to the CODE defaults (the effective strings start at
|
||||
the template defaults regardless of an operator's local
|
||||
``.env`` — the phase-61/62 leak-guard pattern the shared conftest
|
||||
server applies to its two string vars; this one pins all three,
|
||||
including ``BOR_APP_NAME``, which the shared server leaves to the
|
||||
process) and ``BOR_GIT_SOURCES`` is forced empty (the dev
|
||||
``.env``'s git repo must not render as env rows in this
|
||||
suite's app)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern) — no chat turn is
|
||||
# ever sent in this suite, but the app boots with the same shape.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
Settings.model_fields["suggestions"].default
|
||||
)
|
||||
# The branding vars: "unset" = the template defaults (the code
|
||||
# defaults, derived from the class fields — the local ``.env`` may
|
||||
# carry the owner's values, and this suite's assertions need the
|
||||
# TEMPLATE defaults, not the owner's).
|
||||
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
|
||||
env["BOR_INPUT_PLACEHOLDER"] = (
|
||||
Settings.model_fields["input_placeholder"].default
|
||||
)
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB isolation + helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_ui_state() -> None:
|
||||
"""Fresh theme + token state per test: truncate the single-row
|
||||
``ui_settings`` (the middleware reads it for EVERY page — a
|
||||
leftover themed row would repaint other suites' pages) and
|
||||
delete this suite's issued tokens (label-scoped on ``e2e-`` —
|
||||
never a TRUNCATE: the shared DB may hold the owner's real
|
||||
tokens)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE ui_settings"))
|
||||
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db_ready: None) -> Iterator[None]:
|
||||
_clean_ui_state()
|
||||
yield
|
||||
_clean_ui_state()
|
||||
|
||||
|
||||
def _cookies(page: Page) -> dict[str, str]:
|
||||
"""The session cookies the browser context holds (the test's API
|
||||
side sees exactly what that browser sees)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
|
||||
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
|
||||
seed — the UI save itself is test 1's job)."""
|
||||
body = {**PALETTE, **SAVED_STRINGS}
|
||||
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == body, "the PUT must echo the new effective values"
|
||||
|
||||
|
||||
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
|
||||
"""Intercept ``PUT /api/ui-settings`` and hold it for
|
||||
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
|
||||
while it is held, the page's fetch is guaranteed pending, so the
|
||||
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
|
||||
labels) is observable deterministically — a localhost PUT
|
||||
settles in milliseconds, so without the hold the window is a
|
||||
race. GETs (the load + the save's refetch) pass straight
|
||||
through."""
|
||||
|
||||
def handle(route: Route) -> None:
|
||||
if route.request.method == "PUT":
|
||||
time.sleep(hold_s)
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/ui-settings", handle)
|
||||
|
||||
|
||||
def _release_theme_puts(page: Page) -> None:
|
||||
page.unroute("**/api/ui-settings")
|
||||
|
||||
|
||||
def _fill_theme_form(
|
||||
page: Page,
|
||||
palette: dict[str, str],
|
||||
strings: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Fill the 12 inputs: the 3 text fields (``strings``, default
|
||||
the E2E set) + the 9 color pickers (``palette``)."""
|
||||
text_values = strings if strings is not None else SAVED_STRINGS
|
||||
page.fill("#theme-app-name", text_values["app_name"])
|
||||
page.fill("#theme-placeholder", text_values["input_placeholder"])
|
||||
page.fill("#theme-footer", text_values["footer_text"])
|
||||
for field, value in palette.items():
|
||||
page.fill(COLOR_INPUT_IDS[field], value)
|
||||
|
||||
|
||||
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
|
||||
"""The RAW served HTML carries exactly one inline theme tag, with
|
||||
all 9 vars = the given hexes, placed IMMEDIATELY before
|
||||
``</head>`` (``inject_theme``'s exact placement: the tag ends
|
||||
exactly where ``</head>`` begins and carries the injector's
|
||||
single leading newline)."""
|
||||
tag = _expected_tag(colors)
|
||||
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
|
||||
start = raw.index(tag)
|
||||
head = raw.index("</head>")
|
||||
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
|
||||
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Defect 1 (Save): the §7.4 lifecycle lands and the OPEN page
|
||||
# repaints the saved palette — no navigation, no reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
|
||||
builtin = _builtin_colors()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/theme.html")
|
||||
expect(page.locator("#theme-gate")).to_be_hidden()
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
# The mount's initial load has settled (the row-less state — no
|
||||
# tag, no overrides) before the test touches the form: no
|
||||
# in-flight load can race the save's own refetch.
|
||||
_wait_mount_settled(page)
|
||||
|
||||
# Fill the 12 inputs (the 3 strings + the 9-color palette) and Save
|
||||
# through the real form — the PUT held so the §7.4 in-flight state
|
||||
# is observable deterministically (the same lifecycle assertions
|
||||
# as the phase-91 suite — the save's contract is unchanged).
|
||||
_fill_theme_form(page, PALETTE)
|
||||
_hold_theme_puts(page)
|
||||
try:
|
||||
page.click("#theme-save")
|
||||
expect(page.locator("#theme-save")).to_be_disabled()
|
||||
expect(page.locator("#theme-save")).to_have_text("Saving…")
|
||||
expect(page.locator("#theme-reset")).to_be_disabled()
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Theme saved.", timeout=30_000
|
||||
)
|
||||
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
|
||||
expect(page.locator("#theme-save")).to_have_text("Save theme")
|
||||
expect(page.locator("#theme-save")).to_be_enabled()
|
||||
expect(page.locator("#theme-reset")).to_be_enabled()
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# NO navigation (the URL never leaves the Theme view): the OPEN
|
||||
# document mirrors the settled save — the tag's DOM text is the
|
||||
# exact 9-var :root{…} the next load would serve (task 04's mirror
|
||||
# half) and <html>'s inline style holds EXACTLY the 9 saved
|
||||
# custom properties (the paint half — no stale pick; the pre-fix
|
||||
# code cleared the preview onto the STALE served tag and fails
|
||||
# this wait).
|
||||
expect(page).to_have_url(APP_URL + "/theme.html")
|
||||
_wait_settled_open_document(
|
||||
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
|
||||
)
|
||||
# …and all 9 computed :root custom properties ARE the saved hexes
|
||||
# (the open page painted the saved palette — defect 1 gone).
|
||||
_wait_theme_computed(page, PALETTE)
|
||||
|
||||
# The server agrees: the RAW served HTML (a fresh request) carries
|
||||
# the 9-var tag immediately before </head>.
|
||||
r = httpx.get(app_url + "/", timeout=10)
|
||||
assert r.status_code == 200
|
||||
_assert_raw_tag(r.text, PALETTE)
|
||||
|
||||
# SPA navigation (the router's view switch — same document, NO
|
||||
# reload): the theme view hides, the chat view shows, and the
|
||||
# saved palette survives on the live computed values.
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page.locator("#view-theme")).to_be_hidden()
|
||||
expect(page.locator("#view-chat")).to_be_visible(timeout=15_000)
|
||||
assert (
|
||||
page.evaluate(
|
||||
"() => getComputedStyle(document.documentElement)"
|
||||
".getPropertyValue('--brand').trim()"
|
||||
)
|
||||
== PALETTE["brand"]
|
||||
)
|
||||
# .send-btn { background: var(--brand) } — the used color is the
|
||||
# saved brand, exact (8-bit hex → rgb() serialization).
|
||||
expect(page.locator(".send-btn")).to_have_css(
|
||||
"background-color", "rgb(79, 70, 229)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Defect 1 (Reset): on a themed load, Reset removes the tag from
|
||||
# the live document and paints the built-ins — no navigation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reset_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
|
||||
builtin = _builtin_colors()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/theme.html")
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# Seed the theme via the API, THEN load the shell: the served
|
||||
# document carries the 9-var tag (a THEMED load — the reset must
|
||||
# remove it from the live document, not just stop serving it).
|
||||
_seed_theme_via_api(app_url, _cookies(page))
|
||||
page.goto(app_url + "/theme.html")
|
||||
# Served-state sanity: the first paint IS the themed paint.
|
||||
_wait_theme_computed(page, PALETTE)
|
||||
# AND the mount's initial load has settled: its applyServedTheme
|
||||
# has run (the <html> overrides exist — the document mirrors the
|
||||
# served theme). From here on, only the reset's own refetch can
|
||||
# re-reconcile the open document.
|
||||
_wait_settled_open_document(
|
||||
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
|
||||
)
|
||||
|
||||
# Reset to defaults: the §7.4 lifecycle (the all-null PUT — the
|
||||
# API's documented "defaults" operation).
|
||||
_hold_theme_puts(page)
|
||||
try:
|
||||
page.click("#theme-reset")
|
||||
expect(page.locator("#theme-reset")).to_be_disabled()
|
||||
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
|
||||
expect(page.locator("#theme-save")).to_be_disabled()
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Reset to the built-in theme.", timeout=30_000
|
||||
)
|
||||
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
|
||||
expect(page.locator("#theme-reset")).to_be_enabled()
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# NO navigation: the #bor-theme tag is REMOVED from the live
|
||||
# document (effective = the built-ins → content null → the tag
|
||||
# goes) and the page paints the 9 built-ins (parsed from
|
||||
# styles.css in-test) with <html> carrying no overrides at all —
|
||||
# the reset is the one case where the cleared-preview shape and
|
||||
# the overrides shape agree: the style attribute is empty.
|
||||
expect(page).to_have_url(APP_URL + "/theme.html")
|
||||
_wait_settled_open_document(page, None, {})
|
||||
_wait_theme_computed(page, builtin)
|
||||
assert (
|
||||
page.evaluate("() => (document.documentElement.getAttribute('style') || '').trim()")
|
||||
== ""
|
||||
)
|
||||
|
||||
# The server agrees: no tag served (the all-NULL row is the
|
||||
# no-op)…
|
||||
r = httpx.get(app_url + "/", timeout=10)
|
||||
assert "bor-theme" not in r.text
|
||||
# …and the byte-identical contract end to end: the with-row bytes
|
||||
# equal a ROW-LESS deployment byte for byte (now 9-wide — a
|
||||
# defaults-saved row never adds a byte).
|
||||
with_row = r.content
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE ui_settings"))
|
||||
db.commit()
|
||||
without_row = httpx.get(app_url + "/", timeout=10).content
|
||||
assert with_row == without_row, (
|
||||
"a defaults-saved row must serve byte-identical HTML"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Defect 2: the tab's variables drive EVERY themed surface — the
|
||||
# grid, the selection, the hovers, the wordmark (computed values)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_controls_drive_the_whole_site(page: Page, app_url: str, db_ready: None) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
_seed_theme_via_api(app_url, _cookies(page))
|
||||
|
||||
# Pre-paint with the 9th var: the raw served HTML carries the
|
||||
# 9-var tag immediately before </head>, permitted in a real
|
||||
# browser only via the phase-91 CSP hash (now 9-wide).
|
||||
r = httpx.get(app_url + "/", timeout=10)
|
||||
assert r.status_code == 200
|
||||
_assert_raw_tag(r.text, PALETTE)
|
||||
csp = r.headers.get("content-security-policy", "")
|
||||
assert "style-src 'self' 'sha256-" in csp, csp
|
||||
|
||||
# Fresh load: the first paint is the themed paint.
|
||||
page.goto(app_url + "/")
|
||||
_wait_theme_computed(page, PALETTE)
|
||||
|
||||
# The background grid (the owner's named defect: "the background
|
||||
# grid never changes color") — body::before's 1px line stops are
|
||||
# color-mix(in srgb, var(--grid-line) 60%, transparent), which the
|
||||
# browser serializes as color(srgb … / 0.6) at the grid line's
|
||||
# channels (premultiplied sRGB with transparent = the source at
|
||||
# the mix alpha).
|
||||
grid_image = page.evaluate(
|
||||
"() => getComputedStyle(document.body, '::before').backgroundImage"
|
||||
)
|
||||
want = _mix(PALETTE["grid_line"], 60.0, None)
|
||||
stops = [
|
||||
(float(m.group(1)), float(m.group(2)), float(m.group(3)),
|
||||
float(m.group(4)) if m.group(4) is not None else 1.0)
|
||||
for m in _COLOR_SRGB.finditer(grid_image)
|
||||
]
|
||||
assert any(
|
||||
abs(stop[3] - want[3]) <= 1e-4
|
||||
and all(
|
||||
abs(got - want_c) <= _TOL
|
||||
for got, want_c in zip(stop[:3], want[:3], strict=True)
|
||||
)
|
||||
for stop in stops
|
||||
), f"the 60% --grid-line stop {want} is not in the grid: {grid_image!r}"
|
||||
|
||||
# ::selection — color-mix(in srgb, var(--brand) 45%, transparent):
|
||||
# the brand's channels at alpha exactly 0.45.
|
||||
selection = page.evaluate(
|
||||
"() => getComputedStyle(document.documentElement, '::selection').backgroundColor"
|
||||
)
|
||||
_close(_parse_color_srgb(selection), _mix(PALETTE["brand"], 45.0, None))
|
||||
|
||||
# The button hovers (the "light pink on highlight" defect) — the
|
||||
# derived --brand-hover: the brand at 86% toward white.
|
||||
page.hover(".new-chat-btn")
|
||||
new_chat_bg = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('.new-chat-btn')).backgroundColor"
|
||||
)
|
||||
_close(_parse_color_srgb(new_chat_bg), _mix(PALETTE["brand"], 86.0, "#ffffff"))
|
||||
# Explicitly NOT the legacy indigo hover (#7d88f5 — the incoherent
|
||||
# pre-phase-92 literal that survived under the rose brand):
|
||||
assert new_chat_bg != "rgb(125, 136, 245)", new_chat_bg
|
||||
assert abs(_parse_color_srgb(new_chat_bg)[0] - 125 / 255.0) > _TOL
|
||||
|
||||
page.hover(".send-btn")
|
||||
send_bg = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('.send-btn')).backgroundColor"
|
||||
)
|
||||
_close(_parse_color_srgb(send_bg), _mix(PALETTE["brand"], 86.0, "#ffffff"))
|
||||
|
||||
# The house nav-link hover wash — the saved --brand-soft, EXACT
|
||||
# (a plain var resolves to the 8-bit hex serialization).
|
||||
page.hover("#nav-sources")
|
||||
nav_bg = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('#nav-sources')).backgroundColor"
|
||||
)
|
||||
assert nav_bg == "rgb(30, 36, 71)", nav_bg
|
||||
|
||||
# The wordmark (the static brand-mark SVG that "never themes") —
|
||||
# its first path's fill is var(--surface): the saved surface,
|
||||
# EXACT.
|
||||
fill = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('.brand-mark path')).fill"
|
||||
)
|
||||
assert fill == "rgb(17, 23, 48)", fill
|
||||
@@ -27,12 +27,13 @@ from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import UiSettings
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
|
||||
@@ -120,6 +121,37 @@ def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
|
||||
assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"]
|
||||
|
||||
|
||||
def test_admin_grid_line_validation_and_normalization(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Phase 92 (task 01): the 9th identity color against the LIVE API —
|
||||
a bad hex is a 422 naming ``grid_line`` (same fixed detail as the
|
||||
other 8); the built-in value stores NULL (the response still
|
||||
reports the built-in — the no-op normalization); a non-built-in
|
||||
value is stored and reported back. The admin gate itself is pinned
|
||||
unchanged by the tests above (router-wide ``require_admin``)."""
|
||||
client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
|
||||
r = 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"
|
||||
|
||||
r = client.put("/api/ui-settings", json={"grid_line": "#4a2626"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"]
|
||||
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
assert row is not None, "the PUT upsert creates the id-1 row"
|
||||
assert row.grid_line is None # built-in → NULL normalization
|
||||
|
||||
r = client.put("/api/ui-settings", json={"grid_line": "#123123"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["grid_line"] == "#123123"
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["grid_line"] == "#123123" # the stored value reads back
|
||||
assert len(r.json()) == 12 # the 12-key response shape (9 colors + 3 strings)
|
||||
|
||||
|
||||
def _config_keys() -> set[str]:
|
||||
"""The /api/config key set after task 03: the five phase-39/59/62
|
||||
keys — the retired CSS-file theming's ``theme`` key is gone."""
|
||||
|
||||
@@ -108,17 +108,21 @@ def test_no_background_layer_declares_animation() -> None:
|
||||
|
||||
def test_grid_layer_is_static_and_unchanged() -> None:
|
||||
"""The owner removed the animated part, not the grid: body::before
|
||||
keeps 44px cells, 1px lines at the fixed 60% line alpha (warm
|
||||
rebrand tone), and the widened radial mask (both the -webkit- and
|
||||
standard mask properties) — and carries NO animation."""
|
||||
keeps 44px cells, 1px lines at 60% of the grid line color, and the
|
||||
widened radial mask (both the -webkit- and standard mask
|
||||
properties) — and carries NO animation. Phase 92 (task 02): the
|
||||
line color is the 9th identity variable --grid-line at 60% (the
|
||||
built-in #4a2626 reproduces the old warm tone exactly — and the
|
||||
tab's Grid lines picker now repaints this texture)."""
|
||||
block = _rule_block(_css(), GRID_LAYER)
|
||||
grid = "color-mix(in srgb, var(--grid-line) 60%, transparent)"
|
||||
assert "background-size: 44px 44px" in block
|
||||
assert (
|
||||
"linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
|
||||
), "grid must keep horizontal 1px lines at 60% line alpha"
|
||||
assert (
|
||||
"linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
|
||||
), "grid must keep vertical 1px lines at 60% line alpha"
|
||||
assert f"linear-gradient(to right, {grid} 1px, transparent 1px)" in block, (
|
||||
"grid must keep horizontal 1px lines at 60% --grid-line"
|
||||
)
|
||||
assert f"linear-gradient(to bottom, {grid} 1px, transparent 1px)" in block, (
|
||||
"grid must keep vertical 1px lines at 60% --grid-line"
|
||||
)
|
||||
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
|
||||
assert f"-webkit-mask-image: {mask};" in block
|
||||
assert f"mask-image: {mask};" in block
|
||||
|
||||
@@ -796,7 +796,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
|
||||
``/``, the non-shell ``/document.html``, and the dynamic
|
||||
``/shared/<token>`` (the prefix branch) — carries EXACTLY ONE
|
||||
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
|
||||
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
|
||||
newline, nothing between), with all 9 ``--*`` vars in ``COLOR_FIELDS``
|
||||
order and the changed value; the ``?v=`` asset rewrite still applies
|
||||
alongside."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
@@ -819,7 +819,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
|
||||
# (nothing between the tag and the close).
|
||||
assert "\n" + tag + "</head>" in r.text
|
||||
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
|
||||
# All 8 vars, COLOR_FIELDS order, the changed value present.
|
||||
# All 9 vars, COLOR_FIELDS order, the changed value present.
|
||||
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
|
||||
assert declared is not None
|
||||
names = re.findall(r"--([a-z-]+):", declared.group(1))
|
||||
@@ -842,6 +842,40 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_middleware_grid_only_change_tag_carries_grid_line(db: Session) -> None:
|
||||
"""Phase 92 (task 01): the 9th identity variable — the OTHER 8 colors
|
||||
at built-in + ONLY ``grid_line`` set still breaks the no-op contract:
|
||||
the tag is NON-empty and carries ALL 9 vars (``--grid-line:`` with
|
||||
the changed value, the rest their built-ins, ``COLOR_FIELDS`` order)
|
||||
with the matching style-src CSP hash."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.add(UiSettings(id=1, grid_line="#123123"))
|
||||
db.commit()
|
||||
try:
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["grid_line"] = "#123123" # one changed color, rest built-in
|
||||
tag = theming.theme_style_tag(colors)
|
||||
assert tag != "" # the no-op contract holds ONLY for all-built-in
|
||||
assert "--grid-line:#123123;" in tag
|
||||
client = TestClient(_theme_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.text.count('id="bor-theme"') == 1
|
||||
assert "\n" + tag + "</head>" in r.text
|
||||
# All 9 vars, COLOR_FIELDS order (grid_line between line and brand).
|
||||
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
|
||||
assert declared is not None
|
||||
names = re.findall(r"--([a-z-]+):", declared.group(1))
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
assert names.index("grid-line") == 5
|
||||
assert r.headers["content-security-policy"] == (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
finally:
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("what",),
|
||||
[("session",), ("resolver",)],
|
||||
|
||||
@@ -245,7 +245,9 @@ def test_new_chat_button_style_contract() -> None:
|
||||
assert "background: var(--brand)" in body, "solid brand fill (the rebrand)"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
hover = re.search(r"\.new-chat-btn:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert hover and "var(--brand-hover)" in hover.group(1), (
|
||||
"hover lightens the brand fill (phase 92: --brand-hover)"
|
||||
)
|
||||
# Mobile (≤640px): the button sits in .chat-shell, not the navbar —
|
||||
# the label stays visible and the plus icon is hidden (room in the
|
||||
# body); the pill stays ≥44px via min-height.
|
||||
|
||||
@@ -97,15 +97,19 @@ def test_reduced_motion_calm_not_removed() -> None:
|
||||
def test_busy_button_style_tokens() -> None:
|
||||
"""Phase 48 (revised contract, owner-locked 2026-08-29): in flight
|
||||
the button is the enabled Stop control — "Stop" label, .is-stop
|
||||
class (rose treatment, 6.3:1 with the #fff label), spinner hidden;
|
||||
idle/error keep the brand Send button (dark ink on brand 5.2:1).
|
||||
class (--brand-stop treatment — the brand darkened toward --bg,
|
||||
5.8:1 with the white label at the built-in default, phase 92),
|
||||
spinner hidden; idle/error keep the brand Send button
|
||||
(dark ink on brand 5.2:1).
|
||||
The spinner element stays in the markup + CSS (16px dark arc — the
|
||||
reduced-motion pin below) but the state machine never shows it: the
|
||||
Stop label + treatment carry the in-flight state."""
|
||||
css = _css()
|
||||
js = _js()
|
||||
assert ".send-btn.is-stop" in css
|
||||
assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)"
|
||||
assert "background: var(--brand-stop)" in css, (
|
||||
"the stop background: the brand darkened toward --bg (5.8:1 with the white label)"
|
||||
)
|
||||
assert ".send-btn.is-stop:hover" in css, "the darker hover step"
|
||||
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
|
||||
|
||||
@@ -759,7 +759,7 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
|
||||
assert "min-height: 44px" in body, "the comfortable touch target"
|
||||
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
|
||||
assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css
|
||||
assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css
|
||||
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
|
||||
"the in-flight disabled state is dimmed (the house language)"
|
||||
)
|
||||
@@ -902,8 +902,8 @@ 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 8 labeled type=color palette inputs
|
||||
(the 8 identity variables, in the theming.COLOR_FIELDS order), the
|
||||
— the server re-validates) + the 9 labeled type=color palette inputs
|
||||
(the 9 identity variables, 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,7 +941,7 @@ 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 8 labeled type=color palette inputs (the 8 identity
|
||||
# and the 9 labeled type=color palette inputs (the 9 identity
|
||||
# variables — 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"
|
||||
@@ -959,6 +959,7 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"theme-ink",
|
||||
"theme-ink-soft",
|
||||
"theme-line",
|
||||
"theme-grid-line",
|
||||
"theme-brand",
|
||||
"theme-brand-soft",
|
||||
"theme-brand-ink",
|
||||
|
||||
@@ -22,15 +22,16 @@ def test_all_tables_registered() -> None:
|
||||
|
||||
|
||||
def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
"""Phase 91: 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 8 identity colors VARCHAR(7), ALL
|
||||
nullable (NULL = default — B1: env value for the strings, the
|
||||
built-in palette for the colors)."""
|
||||
"""Phase 91 (9 identity colors after phase 92, task 01): 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
|
||||
(NULL = default — B1: env value for the strings, the built-in
|
||||
palette for the colors)."""
|
||||
settings_table = Base.metadata.tables["ui_settings"]
|
||||
assert set(settings_table.c.keys()) == {
|
||||
"id", "app_name", "input_placeholder", "footer_text",
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}
|
||||
pk = settings_table.c["id"]
|
||||
@@ -40,7 +41,7 @@ def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
col = settings_table.c[name]
|
||||
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",
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
col = settings_table.c[name]
|
||||
assert col.nullable is True, f"{name} must be NULL (the built-in)"
|
||||
|
||||
@@ -453,7 +453,9 @@ def test_share_button_css_is_the_exact_save_family() -> None:
|
||||
assert "background: var(--brand)" in body, "same solid brand fill as Save"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert hover and "var(--brand-hover)" in hover.group(1), (
|
||||
"hover lightens the brand fill (phase 92: --brand-hover)"
|
||||
)
|
||||
svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css)
|
||||
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)"
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
@@ -1013,7 +1015,9 @@ def test_stale_banner_css_is_the_kb_banner_family() -> None:
|
||||
):
|
||||
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
|
||||
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert hover and "var(--brand-hover)" in hover.group(1), (
|
||||
"hover lightens the brand fill (phase 92: --brand-hover)"
|
||||
)
|
||||
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
|
||||
"the redo glyph rides the 16px pill size"
|
||||
)
|
||||
|
||||
@@ -600,8 +600,9 @@ def test_sync_result_is_styled() -> None:
|
||||
|
||||
|
||||
def test_sync_modal_css_error_palette_and_stacking() -> None:
|
||||
""".sync-modal-backdrop: fixed, full-viewport, rgba dim, z-index
|
||||
above the sticky header; .sync-modal: the centered ≈28rem panel on
|
||||
""".sync-modal-backdrop: fixed, full-viewport, the --bg-82% dim
|
||||
(phase 92: color-mix of the identity variable), z-index above the
|
||||
sticky header; .sync-modal: the centered ≈28rem panel on
|
||||
the phase-08 error palette (panel on --err-bg, 1px --err-line
|
||||
border, --err-ink error text, --ink title — all computed ≥4.5:1);
|
||||
open/close via .is-open (visibility/opacity)."""
|
||||
@@ -612,7 +613,9 @@ def test_sync_modal_css_error_palette_and_stacking() -> None:
|
||||
assert "position: fixed" in b
|
||||
assert "inset: 0" in b
|
||||
assert "z-index: 1000" in b, "above the sticky header (20) + skip-link (100)"
|
||||
assert "rgba(" in b, "the dim over the page"
|
||||
assert "color-mix(in srgb, var(--bg) 82%, transparent)" in b, (
|
||||
"the dim over the page (phase 92: --bg at 82%)"
|
||||
)
|
||||
open_state = re.search(r"\.sync-modal-backdrop\.is-open\s*\{([^}]*)\}", css)
|
||||
assert open_state, ".is-open must be the open state"
|
||||
assert "visibility: visible" in open_state.group(1)
|
||||
|
||||
+35
-16
@@ -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 8 built-ins must equal the
|
||||
* ``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;
|
||||
* ``theme_style_tag`` — the byte-identical contract (all built-in →
|
||||
``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS``
|
||||
``""``) and the exact tag shape (all 9 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
|
||||
@@ -45,6 +45,14 @@ def _delete_row() -> Any:
|
||||
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."""
|
||||
@@ -59,13 +67,13 @@ 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 8 identity variables — no more, no fewer)."""
|
||||
the 9 identity variables — no more, no fewer)."""
|
||||
decls = _root_declarations()
|
||||
builtin_names = set(theming.BUILTIN_COLORS)
|
||||
assert builtin_names == {
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}"
|
||||
}, f"BUILTIN_COLORS must name exactly the 9 identity 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}"
|
||||
@@ -75,12 +83,15 @@ def test_builtin_colors_match_styles_css_root() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_color_fields_are_the_eight_keys_in_readme_order() -> None:
|
||||
"""``COLOR_FIELDS`` is the 8 keys in the themes-README order — the
|
||||
order the resolver, the API, and the tag renderer all rely on."""
|
||||
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.)"""
|
||||
assert theming.COLOR_FIELDS == (
|
||||
"bg", "surface", "ink", "ink_soft",
|
||||
"line", "brand", "brand_soft", "brand_ink",
|
||||
"line", "grid_line", "brand", "brand_soft", "brand_ink",
|
||||
)
|
||||
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
@@ -102,7 +113,8 @@ 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 11 keys."""
|
||||
strings + the built-in palette, all 12 keys."""
|
||||
_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())
|
||||
@@ -117,6 +129,7 @@ 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:
|
||||
@@ -140,6 +153,7 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
|
||||
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:
|
||||
@@ -155,9 +169,10 @@ 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 11 keys."""
|
||||
values it reports must be real ``str``s for all 12 keys."""
|
||||
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
|
||||
@@ -181,16 +196,20 @@ 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_eight_in_order() -> None:
|
||||
"""A single non-built-in color still emits ALL 8 variables, in
|
||||
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace)."""
|
||||
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)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
assert tag == (
|
||||
'<style id="bor-theme">:root{'
|
||||
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
|
||||
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
|
||||
"--line:#2d1a1a;--grid-line:#4a2626;--brand:#818cf8;"
|
||||
"--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
|
||||
"}</style>"
|
||||
)
|
||||
# The changed value lands under the dashed CSS name…
|
||||
@@ -211,7 +230,7 @@ def test_theme_style_tag_multiple_changed() -> None:
|
||||
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
|
||||
assert "--brand-ink:#c7d2fe;" in tag
|
||||
assert tag.endswith("}</style>")
|
||||
# The order of the 8 dashed names is the COLOR_FIELDS order.
|
||||
# The order of the 9 dashed names is the COLOR_FIELDS order.
|
||||
names = re.findall(r"--([a-z-]+):", tag)
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ from app.models import UiSettings
|
||||
ALL_NULL_BODY: dict[str, str | None] = {
|
||||
"app_name": None, "input_placeholder": None, "footer_text": None,
|
||||
"bg": None, "surface": None, "ink": None, "ink_soft": None,
|
||||
"line": None, "brand": None, "brand_soft": None, "brand_ink": None,
|
||||
"line": None, "grid_line": None, "brand": None, "brand_soft": None,
|
||||
"brand_ink": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +80,7 @@ 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 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
|
||||
"""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
|
||||
without ``#``, a named color, and the empty string (the color clear
|
||||
operation is ``null``, not ``""``)."""
|
||||
@@ -88,6 +89,12 @@ def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
r = admin_client.put("/api/ui-settings", json={field: bad})
|
||||
assert r.status_code == 422, (field, bad, r.text)
|
||||
assert r.json()["detail"] == f"{field} must be a #rrggbb hex color"
|
||||
# Phase 92 (task 01): the 9th identity color names its 422 the same
|
||||
# fixed way as the other 8 (the loop above already covers it via
|
||||
# COLOR_FIELDS; the explicit case pins the field name in the detail).
|
||||
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"
|
||||
|
||||
|
||||
def test_put_lowercases_colors_on_store(
|
||||
@@ -127,6 +134,30 @@ def test_put_built_in_color_is_stored_as_null(
|
||||
assert getattr(row, field) is None, f"{field} must be stored as NULL"
|
||||
|
||||
|
||||
def test_put_grid_line_built_in_is_stored_as_null(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Phase 92 (task 01): the 9th identity color gets the same
|
||||
owner-locked normalization as the other 8 — PUTting the built-in
|
||||
grid hex stores NULL (the response still reports the built-in, and
|
||||
the row's grid column stays empty); a NON-built-in value is stored
|
||||
as-is (lowercased)."""
|
||||
r = admin_client.put("/api/ui-settings", json={"grid_line": "#4a2626"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"]
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.grid_line is None # built-in → NULL
|
||||
|
||||
r = admin_client.put("/api/ui-settings", json={"grid_line": "#123123"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["grid_line"] == "#123123"
|
||||
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.grid_line == "#123123" # non-built-in is stored as-is
|
||||
|
||||
|
||||
def test_put_empty_string_is_the_clear_operation(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
@@ -147,7 +178,7 @@ def test_put_empty_string_is_the_clear_operation(
|
||||
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
|
||||
"""GET reports the DB values over the defaults, column by column: a
|
||||
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
|
||||
built-ins and the env strings — all 11 keys, no nulls."""
|
||||
built-ins and the env strings — all 12 keys, no nulls."""
|
||||
db.add(UiSettings(id=1, bg="#123456"))
|
||||
db.commit()
|
||||
r = admin_client.get("/api/ui-settings")
|
||||
|
||||
Reference in New Issue
Block a user