phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
@@ -28,12 +28,17 @@ import pytest
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
import app.core.caching as caching
|
||||
from app.config import Settings
|
||||
from app.core import theming
|
||||
from app.core.caching import asset_version, rewrite_asset_refs
|
||||
from app.core.security_headers import CSP
|
||||
from app.models import UiSettings
|
||||
|
||||
TOKEN = "abc123"
|
||||
|
||||
@@ -212,6 +217,7 @@ def test_html_pages_include_history() -> None:
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
|
||||
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
|
||||
"/shared.html", # phase 51: the shared page's static path
|
||||
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
|
||||
):
|
||||
@@ -718,3 +724,160 @@ def test_assets_path_keeps_validators_and_304(
|
||||
assert r304.status_code == 304 # versioned-URL 304s stay safe
|
||||
assert r304.content == b""
|
||||
assert r304.headers["cache-control"] == caching.ASSET_CACHE_CONTROL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 91 (task 02): the pre-paint inline theme tag
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The middleware's rewrite branch now ALSO builds the theme tag from the
|
||||
# effective ``ui_settings`` row (task 01's resolver — one short-lived
|
||||
# session per response, no process cache) and inserts it before the
|
||||
# first ``</head>``. Unset/defaults → ``tag == ""`` → the served bytes
|
||||
# are EXACTLY the phase-33/54 rewrite-only output (B4's byte-identical
|
||||
# contract); a DB blip is the same no-op (the page never breaks).
|
||||
|
||||
|
||||
def _theme_page(name: str) -> str:
|
||||
"""The ``text/html`` body the fixture routes below serve."""
|
||||
return (
|
||||
f"<html><head><title>{name}</title>"
|
||||
'<link rel="stylesheet" href="/assets/styles.css">'
|
||||
f"</head><body><main>{name}</main></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _theme_page_app() -> FastAPI:
|
||||
"""A bare app with the middleware: the shell page at ``/`` plus a
|
||||
second known page (``/document.html``) and the phase-51 dynamic
|
||||
``/shared/<token>`` route (the prefix branch) — every served
|
||||
``text/html`` with one versionable asset ref."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return _theme_page("index")
|
||||
|
||||
@app.get("/document.html", response_class=HTMLResponse)
|
||||
def document() -> str:
|
||||
return _theme_page("document")
|
||||
|
||||
@app.get("/shared/{token}", response_class=HTMLResponse)
|
||||
def shared(token: str) -> str:
|
||||
return _theme_page(f"shared-{token}")
|
||||
|
||||
caching.configure_caching(app)
|
||||
return app
|
||||
|
||||
|
||||
def test_middleware_unset_page_is_byte_identical_to_rewrite_only(db: Session) -> None:
|
||||
"""THE byte-identical contract (B4): with NO ``ui_settings`` row the
|
||||
served body is EXACTLY the phase-33/54 rewrite-only output — not a
|
||||
single byte differs, no ``#bor-theme`` anywhere."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
client = TestClient(_theme_page_app())
|
||||
token = caching.asset_version()
|
||||
for path, name in (("/", "index"), ("/document.html", "document")):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
expected = caching.rewrite_asset_refs(_theme_page(name), token)
|
||||
assert r.content == expected.encode("utf-8") # byte-identical
|
||||
assert "bor-theme" not in r.text
|
||||
# No tag → no style-src exemption: the response carries no CSP
|
||||
# of its own (this bare app has no security-headers layer), so
|
||||
# the outer middleware's plain A1 string stands untouched.
|
||||
assert "content-security-policy" not in r.headers
|
||||
|
||||
|
||||
def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> None:
|
||||
"""A ``ui_settings`` row with ONE changed color: every HTML page —
|
||||
``/``, 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``
|
||||
order and the changed value; the ``?v=`` asset rewrite still applies
|
||||
alongside."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.add(UiSettings(id=1, brand="#818cf8"))
|
||||
db.commit()
|
||||
try:
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8" # one changed color, rest built-in
|
||||
tag = theming.theme_style_tag(colors)
|
||||
client = TestClient(_theme_page_app())
|
||||
token = caching.asset_version()
|
||||
shared_token = uuid.uuid4().hex
|
||||
for path in ("/", "/document.html", f"/shared/{shared_token}"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
# Exactly one tag …
|
||||
assert r.text.count('id="bor-theme"') == 1
|
||||
# … with a leading newline immediately before the first </head>
|
||||
# (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.
|
||||
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 "--brand:#818cf8;" in r.text
|
||||
# Phase 91 (task 05): the inline tag is blocked by the
|
||||
# phase-82 CSP in a real browser unless this response also
|
||||
# carries the style-src exemption — the A1 string plus a
|
||||
# sha256 hash of the EXACT tag content (the current theme
|
||||
# is the only inline style ever permitted; no
|
||||
# 'unsafe-inline').
|
||||
assert r.headers["content-security-policy"] == (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
assert "unsafe-inline" not in r.headers["content-security-policy"]
|
||||
# The phase-33/54 asset rewrite is untouched and applies too.
|
||||
assert f'href="/assets/styles.css?v={token}"' in r.text
|
||||
finally:
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("what",),
|
||||
[("session",), ("resolver",)],
|
||||
ids=["session-open-fails", "resolver-fails"],
|
||||
)
|
||||
def test_middleware_db_failure_serves_page_without_tag(
|
||||
monkeypatch: pytest.MonkeyPatch, what: str
|
||||
) -> None:
|
||||
"""A DB blip must NEVER break the page (loadHealth house style):
|
||||
whether the session fails to open or the row read raises, the page
|
||||
still 200s with the byte-identical rewrite-only body (no tag) and
|
||||
the no-cache contract intact — a pre-migration boot is the same
|
||||
path."""
|
||||
if what == "session":
|
||||
|
||||
def _boom_session() -> object:
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(caching, "SessionLocal", _boom_session)
|
||||
else:
|
||||
|
||||
def _boom_resolver(session: object) -> dict[str, str]:
|
||||
raise RuntimeError("select failed")
|
||||
|
||||
monkeypatch.setattr(caching.theming, "effective_settings", _boom_resolver)
|
||||
client = TestClient(_theme_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
token = caching.asset_version()
|
||||
assert r.content == caching.rewrite_asset_refs(
|
||||
_theme_page("index"), token
|
||||
).encode("utf-8")
|
||||
assert "bor-theme" not in r.text
|
||||
# The DB-failure fallback is the UNSET shape: no tag, no style-src
|
||||
# exemption (the plain A1 policy stands — the page degrades to the
|
||||
# built-in palette, never to an inline-style exemption for a tag
|
||||
# that is not there).
|
||||
assert "content-security-policy" not in r.headers
|
||||
|
||||
@@ -481,70 +481,25 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
|
||||
|
||||
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
|
||||
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
|
||||
phase-61 copy is the DEFAULT (composer placeholder + footer line),
|
||||
and an empty theme = the built-in dark-tech palette."""
|
||||
phase-61 copy is the DEFAULT (composer placeholder + footer line).
|
||||
Phase 91 (task 03): the retired CSS-file theme env var is gone —
|
||||
``Settings`` no longer has a theme field at all (a leftover value
|
||||
in a deployment's .env is ignored, not a boot failure)."""
|
||||
s = _settings()
|
||||
assert s.input_placeholder == "Ask me anything…"
|
||||
assert s.footer_text == "Powered by self-hosted models"
|
||||
assert s.theme == ""
|
||||
assert "theme" not in type(s).model_fields
|
||||
|
||||
|
||||
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The three settings honor their ``BOR_`` env vars
|
||||
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``);
|
||||
"""The two string settings honor their ``BOR_`` env vars
|
||||
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``);
|
||||
placeholder/footer accept any string (empty is legal — the brand
|
||||
layer then keeps the template default)."""
|
||||
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
|
||||
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
|
||||
monkeypatch.setenv("BOR_THEME", "indigo.css")
|
||||
s = _settings()
|
||||
assert s.input_placeholder == "Ask the vault…"
|
||||
assert s.footer_text == "Powered by my own models"
|
||||
assert s.theme == "indigo.css"
|
||||
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
|
||||
assert _settings().input_placeholder == "" # empty stands
|
||||
|
||||
|
||||
def test_theme_validator_accepts_empty_and_bare_css_filename() -> None:
|
||||
"""Phase 62 (A5): empty = the built-in palette; a bare lowercase
|
||||
``.css`` filename (the ``indigo.css`` example) is the only
|
||||
non-empty shape — dashes/underscores/digits are legal tokens."""
|
||||
assert _settings().theme == "" # "" passes
|
||||
assert _settings(theme="indigo.css").theme == "indigo.css"
|
||||
assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bad", "match"),
|
||||
[
|
||||
# uppercase — the shape is lowercase-only
|
||||
("Indigo.css", "Indigo.css"),
|
||||
# path escape — a theme is a filename, never a path
|
||||
("../evil.css", r"\.\./evil\.css"),
|
||||
("a/b.css", r"a/b\.css"),
|
||||
("/abs.css", r"got '/abs\.css'"),
|
||||
# a missing extension is not a theme file
|
||||
("indigo", r"got 'indigo'"), # must not match the example text
|
||||
],
|
||||
)
|
||||
def test_theme_validator_rejects_malformed_naming_the_value(
|
||||
bad: str,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""A typo in ``BOR_THEME`` must kill startup, not silently 404 at
|
||||
runtime — the rejection names the offending value (the phase-56
|
||||
fail-loud house style) alongside the allowed shape."""
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
_settings(theme=bad)
|
||||
|
||||
|
||||
def test_bor_theme_env_malformed_fails_startup_naming_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The startup path: a malformed ``BOR_THEME`` in the environment
|
||||
fails Settings construction loudly (the app builds its settings at
|
||||
import time, so this is a refused boot), naming the value — the
|
||||
E2E boots-check lands in task 05."""
|
||||
monkeypatch.setenv("BOR_THEME", "../evil.css")
|
||||
with pytest.raises(ValidationError, match=r"\.\./evil\.css"):
|
||||
_settings()
|
||||
|
||||
@@ -99,14 +99,16 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
|
||||
|
||||
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
|
||||
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||
/api/config answer also drives the three customization keys — the
|
||||
#message-input placeholder, every .footer-text node, and the theme
|
||||
stylesheet link (inserted right after the styles.css link, guarded
|
||||
by #theme-override, degrading with a console.warn on 404 — A5).
|
||||
/api/config answer also drives the two customization STRING keys —
|
||||
the #message-input placeholder and every .footer-text node. Phase
|
||||
91 (task 03): the retired CSS-file theme link (the old step 7)
|
||||
and its id-guard / styles.css-finder / onerror-degrade machinery
|
||||
are GONE — color theming is server-side inline injection
|
||||
(app/core/theming.py), never a brand.js DOM write.
|
||||
No second network call: the keys ride the existing boot fetch."""
|
||||
js = _text(BRAND_JS)
|
||||
assert js.count('= fetch("/api/config"') == 1, (
|
||||
"the three keys must ride the existing boot fetch — no new call"
|
||||
"the keys must ride the existing boot fetch — no new call"
|
||||
)
|
||||
# 5. The composer placeholder (chat page only — the null guard
|
||||
# no-ops on every other page).
|
||||
@@ -116,28 +118,14 @@ def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
|
||||
# textContent: an operator string can't inject markup.
|
||||
assert 'document.querySelectorAll(".footer-text")' in js
|
||||
assert "footer_text" in js
|
||||
# 7. The theme link: /assets/themes/<name>, inserted right after
|
||||
# the styles.css link, tagged #theme-override (the idempotency
|
||||
# guard), with the A5 degradation warn.
|
||||
assert '"/assets/themes/"' in js
|
||||
assert 'link.id = "theme-override"' in js
|
||||
assert 'getElementById("theme-override")' in js
|
||||
assert 'insertAdjacentElement("afterend", link)' in js
|
||||
assert "link.onerror" in js
|
||||
assert '"brand: theme " + themeName' in js
|
||||
# The styles.css finder must survive the phase-33/54 cache-bust
|
||||
# rewrite: the SERVED HTML carries the asset ref with a
|
||||
# ?v=<token> query (and el.href is the absolute URL), so the match
|
||||
# has to run on the RAW attribute path with query/fragment
|
||||
# stripped — el.href.endsWith(…) would silently skip the insertion
|
||||
# (the theme never applied; found by the task-05 E2E).
|
||||
assert 'getAttribute("href")' in js
|
||||
assert "split(/[?#]/)[0]" in js
|
||||
assert 'endsWith("styles.css")' in js
|
||||
assert "el.href.endsWith" not in js
|
||||
# Phase 91 (task 03): the retired theme-link machinery is absent —
|
||||
# no theme key read, no link insertion (the whole step-7 block
|
||||
# lived inside the themeName guard — themeName gone, block gone).
|
||||
assert "themeName" not in js
|
||||
assert 'insertAdjacentElement' not in js
|
||||
# The empty-skip no-op contract: each key is guarded before any
|
||||
# DOM write, so an unset deployment stays byte-identical.
|
||||
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
|
||||
for guard in ("if (placeholder) {", "if (footerText) {"):
|
||||
assert guard in js, (
|
||||
f"an empty value must skip its application ({guard})"
|
||||
)
|
||||
|
||||
@@ -104,8 +104,8 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
"""The VIEW map is pathname → view name: the shell's own two URLs
|
||||
("/" and "/index.html") are the chat view, plus one entry per
|
||||
folded view (tasks 01–03: tuning, rag, git-sources, history;
|
||||
phase 79 task 06: tokens — all five non-chat navbar views are
|
||||
in)."""
|
||||
phase 79 task 06: tokens; phase 91 task 04: theme — all six
|
||||
non-chat navbar views are in)."""
|
||||
js = _js()
|
||||
view_start = js.find("const VIEW = {")
|
||||
assert view_start != -1, "the VIEW map must exist"
|
||||
@@ -123,8 +123,11 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
assert '"/tokens.html": "tokens"' in view_body, (
|
||||
"phase 79 task 06 folds the Tokens view into the shell"
|
||||
)
|
||||
assert '"/theme.html": "theme"' in view_body, (
|
||||
"phase 91 task 04 folds the Theme view into the shell"
|
||||
)
|
||||
# The view names are the #view-<name> section slugs in index.html.
|
||||
for name in ("chat", "tuning", "history", "tokens"):
|
||||
for name in ("chat", "tuning", "history", "tokens", "theme"):
|
||||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||||
|
||||
|
||||
@@ -218,6 +221,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
|
||||
assert 'tokens: () => import("./tokens.js")' in mods_body, (
|
||||
"the Tokens view module is lazy-imported on first show"
|
||||
)
|
||||
assert 'theme: () => import("./theme.js")' in mods_body, (
|
||||
"the Theme view module is lazy-imported on first show (phase 91)"
|
||||
)
|
||||
assert '"chat"' not in mods_body, "the chat view has no lazy module"
|
||||
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
|
||||
|
||||
@@ -275,6 +281,11 @@ def test_router_writes_active_state_title_and_meta() -> None:
|
||||
assert "Saved chats — every conversation is saved automatically, one click back." in js
|
||||
assert 'tokens: "Access tokens · Brain of Reese"' in js
|
||||
assert "Generate and revoke the API tokens that let people use the app." in js
|
||||
assert 'theme: "Theme · Brain of Reese"' in js
|
||||
assert (
|
||||
"Set the palette and branding — the theme is baked into every served page, "
|
||||
"live on the first paint."
|
||||
) in js
|
||||
# The brand composition (phase 39's window.BOR_BRAND, read at
|
||||
# write time — never a hardcoded stamp).
|
||||
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
||||
@@ -351,6 +362,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||||
tokens_link = tokens_match.group(0)
|
||||
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
|
||||
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
|
||||
# The Theme nav link (phase 91 task 04) ships hidden (admin-only)
|
||||
# and UNstamped too — the router is the single writer of the active
|
||||
# state, and a token user (role "user") must never see the link
|
||||
# (header.js reveals it for admin only).
|
||||
theme_match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', html)
|
||||
assert theme_match, "the shell must carry the #nav-theme nav link"
|
||||
theme_link = theme_match.group(0)
|
||||
assert "hidden" in theme_link, "#nav-theme ships hidden (admin-only)"
|
||||
assert "is-active" not in theme_link, "no static active stamp on the Theme link"
|
||||
|
||||
|
||||
# ---------- phase 76 task 04: the header is shell-owned ----------
|
||||
@@ -869,3 +889,153 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
|
||||
# The Actions column header is visually-hidden (the row buttons
|
||||
# carry their own aria-labels — the history-table convention).
|
||||
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
|
||||
|
||||
|
||||
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
|
||||
|
||||
|
||||
def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"""Phase 91 task 04: the shell carries the #view-theme section —
|
||||
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
|
||||
with the #theme-gate (the EXACT #sources-gate pattern, ship-hidden,
|
||||
its Sign in returning to the Theme view via ?next=/theme.html) and
|
||||
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
|
||||
#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
|
||||
(role=alert) — all ship hidden. The editor behavior (populate,
|
||||
live preview, Save/Reset, the contrast warnings) lands in task 05;
|
||||
this pin keeps the E2E-stable skeleton from drifting."""
|
||||
html = _html()
|
||||
view = html.find('<section class="view" id="view-theme"')
|
||||
assert view != -1, "the #view-theme section must be in the shell"
|
||||
tag_end = html.find(">", view)
|
||||
tag = html[view:tag_end]
|
||||
assert "hidden" in tag and "inert" in tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in tag, "the target view is focusable"
|
||||
main_end = html.find("</main>", view)
|
||||
assert view < main_end, "the view section lives inside the single main"
|
||||
body = html[view:main_end]
|
||||
# The gate: the exact #sources-gate pattern (class + ship-hidden +
|
||||
# its ?next= returning to the Theme view — the no-JS fallback).
|
||||
gate = re.search(r'<section[^>]*id="theme-gate"[^>]*>', body)
|
||||
assert gate and "hidden" in gate.group(0), "#theme-gate must ship hidden"
|
||||
assert 'class="sources-gate"' in gate.group(0), (
|
||||
"the gate reuses the .sources-gate visual language"
|
||||
)
|
||||
assert "<h2 id=\"theme-gate-title\">Sign in to change the theme</h2>" in body
|
||||
assert 'href="/login.html?next=/theme.html"' in body, (
|
||||
"the gate's Sign in returns to the Theme view (no-JS fallback)"
|
||||
)
|
||||
# The content ships hidden (theme.js reveals it for admin only —
|
||||
# the #git-sources-content pattern).
|
||||
content = re.search(r'<div[^>]*id="theme-content"[^>]*>', body)
|
||||
assert content and "hidden" in content.group(0), (
|
||||
"#theme-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
# 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
|
||||
# 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"
|
||||
)
|
||||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||
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}"[^>]*maxlength="300"[^>]*>', body
|
||||
), f"#{field_id} must be a text input with maxlength=300"
|
||||
for field_id in (
|
||||
"theme-bg",
|
||||
"theme-surface",
|
||||
"theme-ink",
|
||||
"theme-ink-soft",
|
||||
"theme-line",
|
||||
"theme-brand",
|
||||
"theme-brand-soft",
|
||||
"theme-brand-ink",
|
||||
):
|
||||
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"
|
||||
# Save (primary) + Reset (secondary) — BOTH type="button" (no real
|
||||
# submit; theme.js owns the onsubmit handling + the §7.4 lifecycle).
|
||||
save = re.search(r'<button[^>]*id="theme-save"[^>]*>', body)
|
||||
assert save and 'type="button"' in save.group(0), (
|
||||
"#theme-save must be a type=button (no real submit)"
|
||||
)
|
||||
reset = re.search(r'<button[^>]*id="theme-reset"[^>]*>', body)
|
||||
assert reset and 'type="button"' in reset.group(0), (
|
||||
"#theme-reset must be a type=button (no real submit)"
|
||||
)
|
||||
assert "Save theme" in body, "the Save button's label"
|
||||
assert "Reset to defaults" in body, "the Reset button's label"
|
||||
# The three task-05 feedback lines, all ship hidden.
|
||||
assert re.search(r'<[^>]*id="theme-error"[^>]*role="alert"[^>]*hidden', body)
|
||||
assert re.search(r'<[^>]*id="theme-result"[^>]*role="status"[^>]*hidden', body)
|
||||
assert re.search(r'<[^>]*id="theme-contrast"[^>]*role="alert"[^>]*hidden', body)
|
||||
|
||||
|
||||
def test_theme_nav_link_ships_on_every_page_header() -> None:
|
||||
"""Phase 91 task 04: the phase-34 one-bar contract — the SAME nav
|
||||
ships on every page (test_nav_consistency pins the header inventory
|
||||
PARITY across the shell pages, the document viewer, and the login
|
||||
page), so #nav-theme (ship-hidden, admin-only) must be in the
|
||||
#app-nav of EVERY header-bearing page: the shell + document.html +
|
||||
login.html + shared.html. The doc-edit flow page ships the reduced
|
||||
header (no admin links at all) and is out of the contract."""
|
||||
for page in (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "shared.html",
|
||||
):
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
|
||||
assert match, f"{page.name} must carry the #nav-theme nav link (one-bar)"
|
||||
link = match.group(0)
|
||||
assert 'href="/theme.html"' in link, f"{page.name}: the Theme link's href"
|
||||
assert "hidden" in link, (
|
||||
f"{page.name}: #nav-theme ships hidden (admin-only)"
|
||||
)
|
||||
assert "is-active" not in link, (
|
||||
f"{page.name}: no static active stamp on the Theme link"
|
||||
)
|
||||
|
||||
|
||||
def test_header_js_reveals_the_theme_link_for_admin_only() -> None:
|
||||
"""Phase 91 task 04: header.js reveals #nav-theme for role admin —
|
||||
the same ship-hidden / reveal-for-admin contract as the other
|
||||
admin-only links: the two-line reveal (`hidden = !admin`) sits in
|
||||
initSharedHeader, null-safe (a page without the link is a no-op),
|
||||
and the gate is the `admin` flag (role === "admin") — a token user
|
||||
(role "user") never sees the link."""
|
||||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||||
fn = header_js.find("export async function initSharedHeader")
|
||||
assert fn != -1, "initSharedHeader must exist"
|
||||
body = header_js[fn:]
|
||||
lookup = body.find('document.querySelector("#nav-theme")')
|
||||
assert lookup != -1, "header.js must look up #nav-theme"
|
||||
reveal = body.find("navTheme.hidden = !admin")
|
||||
assert 0 <= lookup < reveal, (
|
||||
"the reveal must be the two-line pattern: null-safe lookup, "
|
||||
"then hidden = !admin (the admin flag — role === \"admin\")"
|
||||
)
|
||||
# The lookup + reveal sit AFTER the whoami resolution (the admin
|
||||
# flag exists only once fetchWhoami has settled).
|
||||
whoami = body.find("const whoami = await fetchWhoami()")
|
||||
admin_flag = body.find('const admin = whoami.role === "admin"')
|
||||
assert 0 <= whoami < admin_flag < lookup, (
|
||||
"the reveal keys off the resolved admin flag"
|
||||
)
|
||||
|
||||
@@ -18,6 +18,33 @@ def test_all_tables_registered() -> None:
|
||||
assert "documents" in tables
|
||||
assert "chunks" in tables
|
||||
assert "query_log" in tables
|
||||
assert "ui_settings" in tables # phase 91: the single-row UI settings
|
||||
|
||||
|
||||
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)."""
|
||||
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",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}
|
||||
pk = settings_table.c["id"]
|
||||
assert pk.primary_key is True, "ui_settings.id must be the PK"
|
||||
assert pk.default is not None, "id needs the Python-side default=1"
|
||||
for name in ("app_name", "input_placeholder", "footer_text"):
|
||||
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",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
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"
|
||||
|
||||
|
||||
def test_chunks_embedding_is_vector_768() -> None:
|
||||
|
||||
@@ -51,11 +51,13 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
|
||||
|
||||
s = _settings()
|
||||
body = app_config(s)
|
||||
# Phase 62 (task 01): the response grew to the six-key set — the
|
||||
# phase-62 UI customization keys ride the SAME endpoint.
|
||||
# Phase 62 (task 01): the response grew to the phase-62 UI
|
||||
# customization keys; phase 91 (task 03) deleted the retired
|
||||
# CSS-file theming's ``theme`` key — the five keys below are the
|
||||
# entire endpoint contract.
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["docs_repo_configured"] is s.docs_configured
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
@@ -149,6 +149,32 @@ def test_404_shaped_response_carries_all_three_headers() -> None:
|
||||
assert body_msg["body"] == b"not found"
|
||||
|
||||
|
||||
def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
|
||||
"""Phase 91 (task 05): the caching middleware publishes, on themed
|
||||
HTML pages only, the A1 string EXTENDED with a ``style-src`` sha256
|
||||
hash for the inline theme tag (the A1 policy would block the tag in
|
||||
every real browser). A CSP an inner layer has already set is that
|
||||
layer's deliberate one and must survive the outer middleware —
|
||||
while the other two headers are still added."""
|
||||
themed = (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
|
||||
)
|
||||
wrapped = SecurityHeadersMiddleware(
|
||||
_plain_app(
|
||||
200,
|
||||
b"<html></html>",
|
||||
headers=[[b"content-security-policy", themed.encode("ascii")]],
|
||||
)
|
||||
)
|
||||
sent = _drive(wrapped, _http_scope())
|
||||
|
||||
start = sent[0]
|
||||
assert _header(start, "content-security-policy") == themed # not clobbered
|
||||
assert _header(start, "x-frame-options") == "DENY"
|
||||
assert _header(start, "x-content-type-options") == "nosniff"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The SSE streaming passthrough pin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and
|
||||
the Containerfile line that ships it (A7).
|
||||
|
||||
No Python logic exists for this task — the mechanism lives in
|
||||
``brand.js`` (pinned by test_frontend_brand.py) and the theme is a
|
||||
drop-in stylesheet. Like the other frontend-adjacent unit files, this
|
||||
module pins the assets as text, so a silent regression (a theme file
|
||||
gaining a selector, a declaration drifting, the Containerfile line
|
||||
vanishing) is caught without a browser. The browser-visible layer
|
||||
(computed ``--brand``, the inserted ``<link>``) is E2E-gated by
|
||||
``tests/e2e/test_ui_customization.py`` (task 05).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = REPO_ROOT / "frontend"
|
||||
THEMES = FRONTEND / "assets" / "themes"
|
||||
INDIGO = THEMES / "indigo.css"
|
||||
GUIDE = THEMES / "README.md"
|
||||
CONTAINERFILE = REPO_ROOT / "Containerfile"
|
||||
|
||||
#: The 8 identity variables a theme may override — and the EXACT set
|
||||
#: indigo.css ships (the semantic families accent/ok/err are states,
|
||||
#: not identity: a theme that overrides them stops being honest).
|
||||
IDENTITY_VARS = (
|
||||
"--bg",
|
||||
"--surface",
|
||||
"--ink",
|
||||
"--ink-soft",
|
||||
"--line",
|
||||
"--brand",
|
||||
"--brand-soft",
|
||||
"--brand-ink",
|
||||
)
|
||||
|
||||
INDIGO_VALUES: dict[str, str] = {
|
||||
"--bg": "#0a0e1a",
|
||||
"--surface": "#111726",
|
||||
"--ink": "#e6e9f0",
|
||||
"--ink-soft": "#a8b0c8",
|
||||
"--line": "#232c44",
|
||||
"--brand": "#818cf8",
|
||||
"--brand-soft": "#1a1f38",
|
||||
"--brand-ink": "#c7d2fe",
|
||||
}
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
"""Drop ``/* … */`` comments — the pins assert against declarations,
|
||||
not prose."""
|
||||
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
|
||||
|
||||
def _declarations(css: str) -> dict[str, str]:
|
||||
"""The ``--name: value`` declarations of the (single) ``:root``
|
||||
block, in file order."""
|
||||
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css))
|
||||
|
||||
|
||||
def test_example_theme_files_exist() -> None:
|
||||
"""The example theme and its authoring guide ship in the static
|
||||
dir (served at /assets/themes/… in dev AND in the image)."""
|
||||
assert INDIGO.is_file(), f"missing example theme: {INDIGO}"
|
||||
assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}"
|
||||
|
||||
|
||||
def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None:
|
||||
"""The whole file is ONE ``:root`` block (the cascade is the entire
|
||||
mechanism): after stripping comments the first non-whitespace
|
||||
content is ``:root``, and no other rule, selector, or declaration
|
||||
exists anywhere in the file."""
|
||||
css = _strip_comments(_text(INDIGO))
|
||||
assert css.lstrip().startswith(":root"), (
|
||||
"indigo.css must start with the :root block (after its header "
|
||||
"comment) — nothing may precede it"
|
||||
)
|
||||
assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, (
|
||||
"indigo.css must be exactly one :root block — no selectors, "
|
||||
"no @media, no nested or extra rules"
|
||||
)
|
||||
|
||||
|
||||
def test_indigo_overrides_exactly_the_eight_identity_variables() -> None:
|
||||
"""EXACTLY the 8 identity overrides with the locked values — no
|
||||
other declarations (a 9th declaration here would be the theme
|
||||
reaching past the palette), and the semantic families
|
||||
(accent/ok/err) must be untouched (they encode states)."""
|
||||
decls = _declarations(_strip_comments(_text(INDIGO)))
|
||||
assert set(decls) == set(IDENTITY_VARS), (
|
||||
f"indigo.css must override exactly the 8 identity variables, got "
|
||||
f"{sorted(decls)}"
|
||||
)
|
||||
for name in IDENTITY_VARS:
|
||||
assert decls[name].strip() == INDIGO_VALUES[name], (
|
||||
f"{name} drifted from the locked value "
|
||||
f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}"
|
||||
)
|
||||
for family in ("--accent-", "--ok-", "--err-"):
|
||||
assert not any(k.startswith(family) for k in decls), (
|
||||
f"semantic {family}* variables must stay the built-in "
|
||||
f"theme (they encode states)"
|
||||
)
|
||||
|
||||
|
||||
def test_indigo_identity_pairs_meet_wcag_aa() -> None:
|
||||
"""The five identity text/background pairs, computed from the file's
|
||||
OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) —
|
||||
AGENTS.md rule 5. The pairs are the ones the layout actually pairs:
|
||||
ink on bg/surface, ink-soft on surface, the dark bg ink on brand
|
||||
(text on brand buttons is --bg, never white — the built-in's
|
||||
documented 3.7:1 trap), brand-ink on surface."""
|
||||
decls = _declarations(_strip_comments(_text(INDIGO)))
|
||||
|
||||
def lum(hexcolor: str) -> float:
|
||||
h = hexcolor.lstrip("#")
|
||||
chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
|
||||
lin = [
|
||||
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
for c in chans
|
||||
]
|
||||
r, g, b = lin
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
def ratio(fg: str, bg: str) -> float:
|
||||
l1, l2 = lum(fg), lum(bg)
|
||||
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||
|
||||
pairs = (
|
||||
("ink on bg", decls["--ink"], decls["--bg"]),
|
||||
("ink on surface", decls["--ink"], decls["--surface"]),
|
||||
("ink-soft on surface", decls["--ink-soft"], decls["--surface"]),
|
||||
("bg ink on brand", decls["--bg"], decls["--brand"]),
|
||||
("brand-ink on surface", decls["--brand-ink"], decls["--surface"]),
|
||||
)
|
||||
for name, fg, bg in pairs:
|
||||
r = ratio(fg, bg)
|
||||
assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)"
|
||||
|
||||
|
||||
def test_containerfile_ships_the_whole_themes_directory() -> None:
|
||||
"""A7: stage 1 copies the WHOLE themes directory (no per-file
|
||||
esbuild — a future theme file needs no Containerfile edit), and it
|
||||
does so AFTER the styles.css minify line (so the served /assets/
|
||||
tree is complete before the pages cp)."""
|
||||
cf = _text(CONTAINERFILE)
|
||||
stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0]
|
||||
lines = stage1.splitlines()
|
||||
cp_idxs = [
|
||||
i
|
||||
for i, ln in enumerate(lines)
|
||||
if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln)
|
||||
]
|
||||
assert len(cp_idxs) == 1, (
|
||||
"stage 1 must ship the themes directory with exactly one "
|
||||
"'cp -r ./assets/themes /out/assets/themes' line"
|
||||
)
|
||||
styles_idxs = [
|
||||
i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln
|
||||
]
|
||||
assert len(styles_idxs) == 1, "stage 1 must minify styles.css"
|
||||
assert cp_idxs[0] > styles_idxs[0], (
|
||||
"the themes cp must come AFTER the styles.css minify line"
|
||||
)
|
||||
cp_line = lines[cp_idxs[0]]
|
||||
assert "--bundle" not in cp_line and "esbuild" not in cp_line, (
|
||||
"A7: the themes directory is copied verbatim — no per-file "
|
||||
"esbuild minify"
|
||||
)
|
||||
|
||||
|
||||
def test_authoring_guide_pins_the_contract() -> None:
|
||||
"""The guide documents the load path (BOR_THEME → /api/config →
|
||||
brand.js link after styles.css), the filename validator regex, the
|
||||
8-variable table, the 4.5:1 bar, the never-white-on-brand trap,
|
||||
and the A7 rebuild story (a new file needs no Containerfile edit)."""
|
||||
guide = _text(GUIDE)
|
||||
for marker in (
|
||||
"BOR_THEME",
|
||||
"/api/config",
|
||||
"styles.css",
|
||||
r"^[a-z0-9_-]+\.css$",
|
||||
"4.5:1",
|
||||
"white-on-brand",
|
||||
"cp -r ./assets/themes /out/assets/themes",
|
||||
):
|
||||
assert marker in guide, f"themes/README.md must document {marker!r}"
|
||||
for var in IDENTITY_VARS:
|
||||
assert var in guide, f"the variable table must list {var}"
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Unit: the built-in identity palette + the effective-settings resolver
|
||||
(phase 91, task 01).
|
||||
|
||||
Covers ``app/core/theming.py`` — the single source of the built-in
|
||||
identity palette (re-homed from the retired phase-62 CSS-file themes'
|
||||
authoring guide before task 03 deleted it) and the DB-over-env /
|
||||
DB-over-built-in resolver shared by ``/api/ui-settings`` and
|
||||
``/api/config``:
|
||||
|
||||
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 8 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``
|
||||
order, lowercased hex);
|
||||
* ``effective_settings`` — missing row → env strings + built-ins; a DB
|
||||
row's set columns win; an empty-string DB string falls back to env
|
||||
(the resolver treats "" as unset, B1). House DB-test pattern (the
|
||||
``test_tokens`` precedent): the real compose Postgres, skipped with
|
||||
clear instructions when the stack is not up.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.core import theming
|
||||
from app.models import UiSettings
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _delete_row() -> Any:
|
||||
from sqlalchemy import delete
|
||||
|
||||
return delete(UiSettings).where(UiSettings.id == 1)
|
||||
|
||||
|
||||
def _root_declarations() -> dict[str, str]:
|
||||
"""The ``--name: value`` declarations of styles.css's (first)
|
||||
``:root`` block, comments stripped, in file order."""
|
||||
css = STYLES_CSS.read_text(encoding="utf-8")
|
||||
match = re.search(r":root\s*\{", css)
|
||||
assert match is not None, "styles.css must have a :root block"
|
||||
block = css[match.end() : css.index("}", match.end())]
|
||||
block = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
|
||||
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
|
||||
|
||||
|
||||
def test_builtin_colors_match_styles_css_root() -> None:
|
||||
"""The drift guard: every built-in equals the stylesheet's ``:root``
|
||||
value for the same variable (and ``BUILTIN_COLORS`` names exactly
|
||||
the 8 identity variables — no more, no fewer)."""
|
||||
decls = _root_declarations()
|
||||
builtin_names = set(theming.BUILTIN_COLORS)
|
||||
assert builtin_names == {
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}, f"BUILTIN_COLORS must name exactly the 8 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}"
|
||||
assert decls[css_name].strip() == value, (
|
||||
f"{css_name} drifted: BUILTIN_COLORS has {value!r}, "
|
||||
f"styles.css has {decls[css_name].strip()!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_color_fields_are_the_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."""
|
||||
assert theming.COLOR_FIELDS == (
|
||||
"bg", "surface", "ink", "ink_soft",
|
||||
"line", "brand", "brand_soft", "brand_ink",
|
||||
)
|
||||
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
|
||||
def _env_settings() -> Settings:
|
||||
"""An explicit env source (the resolver's optional ``settings``
|
||||
parameter) — deterministic values, independent of the local ``.env``
|
||||
(the ``/api/config`` env pins in test_api.py own the env-file
|
||||
behaviour; this unit module only needs stable fallbacks)."""
|
||||
return Settings(
|
||||
app_name="Env Name",
|
||||
input_placeholder="Env placeholder…",
|
||||
footer_text="Env footer",
|
||||
)
|
||||
|
||||
|
||||
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
|
||||
|
||||
|
||||
def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None:
|
||||
"""A missing row (GET creates nothing) means "defaults": the env
|
||||
strings + the built-in palette, all 11 keys."""
|
||||
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 effective["app_name"] == "Env Name"
|
||||
assert effective["input_placeholder"] == "Env placeholder…"
|
||||
assert effective["footer_text"] == "Env footer"
|
||||
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
|
||||
|
||||
|
||||
def test_effective_db_row_wins_column_by_column(db: Session) -> None:
|
||||
"""Set columns win, unset columns fall back — per column, so a
|
||||
partial row (only ``bg`` set) mixes the DB color with the built-ins
|
||||
and the env strings."""
|
||||
db.add(UiSettings(id=1, bg="#111111", app_name="DB Name"))
|
||||
db.commit()
|
||||
try:
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert effective["bg"] == "#111111" # DB wins
|
||||
assert effective["app_name"] == "DB Name" # DB wins
|
||||
# Unset columns: env strings + the built-in colors.
|
||||
assert effective["input_placeholder"] == "Env placeholder…"
|
||||
assert effective["footer_text"] == "Env footer"
|
||||
for key in theming.COLOR_FIELDS:
|
||||
if key != "bg":
|
||||
assert effective[key] == theming.BUILTIN_COLORS[key]
|
||||
finally:
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None:
|
||||
"""B1: an EMPTY string in the DB is unset — the resolver falls back
|
||||
to the env value (a hand-edited row with '' can't blank the UI).
|
||||
Colors: ``None`` → the built-in (an empty color is impossible through
|
||||
the API — the hex validator — the resolver's not-None rule covers
|
||||
the hand-edited edge by returning whatever the row holds)."""
|
||||
db.add(UiSettings(id=1, app_name=""))
|
||||
db.commit()
|
||||
try:
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert effective["app_name"] == "Env Name" # "" → env fallback
|
||||
assert effective["footer_text"] == "Env footer"
|
||||
assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in
|
||||
finally:
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
|
||||
"""``settings=None`` (the design's call shape) resolves the env
|
||||
fallback from the cached :func:`app.config.get_settings` — the
|
||||
values it reports must be real ``str``s for all 11 keys."""
|
||||
from app.config import get_settings
|
||||
|
||||
effective = theming.effective_settings(db)
|
||||
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert effective["app_name"] == get_settings().app_name
|
||||
for field in theming.STRING_FIELDS:
|
||||
assert isinstance(effective[field], str) and effective[field]
|
||||
assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS)
|
||||
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
|
||||
|
||||
|
||||
# ---------- theme_style_tag (pure) ----------
|
||||
|
||||
|
||||
def test_theme_style_tag_all_builtins_is_empty_string() -> None:
|
||||
"""The byte-identical contract: an unset (or "defaults saved")
|
||||
deployment serves NO tag — exactly the pre-phase-91 HTML."""
|
||||
assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == ""
|
||||
# The tag is PURE string-equality: uppercase hex is NOT the built-in
|
||||
# (the API's lowercasing-before-store is what makes stored hex
|
||||
# canonical — pin the renderer's own contract here).
|
||||
colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()}
|
||||
assert theming.theme_style_tag(colors) != ""
|
||||
|
||||
|
||||
def test_theme_style_tag_one_changed_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)."""
|
||||
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;"
|
||||
"}</style>"
|
||||
)
|
||||
# The changed value lands under the dashed CSS name…
|
||||
assert "--brand:#818cf8;" in tag
|
||||
# …and the underscored field (ink_soft) renders as --ink-soft.
|
||||
assert "--ink-soft:#b8a8a8;" in tag
|
||||
assert "--ink_soft" not in tag
|
||||
|
||||
|
||||
def test_theme_style_tag_multiple_changed() -> None:
|
||||
"""Two changed colors: both values present, the rest built-in, order
|
||||
unchanged (the tag is a complete :root override — the page never
|
||||
mixes a partial palette)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["bg"] = "#0a0e1a"
|
||||
colors["brand_ink"] = "#c7d2fe"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
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.
|
||||
names = re.findall(r"--([a-z-]+):", tag)
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
|
||||
|
||||
# ---------- inject_theme (pure — task 02's injection helper) ----------
|
||||
|
||||
_HEAD_HTML = "<html><head><title>t</title></head><body><p>b</p></body></html>"
|
||||
|
||||
|
||||
def test_inject_theme_empty_tag_is_identity() -> None:
|
||||
"""``tag == ""`` (the unset / "defaults saved" deployment — what
|
||||
``theme_style_tag" returns for all-built-in colors) → the html is
|
||||
returned EXACTLY as passed in, byte for byte (B4's no-op
|
||||
contract)."""
|
||||
assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML
|
||||
# Whitespace is NOT an empty tag — a real tag is always inserted.
|
||||
assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML
|
||||
|
||||
|
||||
def test_inject_theme_missing_head_is_identity() -> None:
|
||||
"""No ``</head>`` occurrence → unchanged (nothing to anchor to);
|
||||
the empty string (no ``</head>`` either) is identity too."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
||||
html = "<html><body>no head</body></html>"
|
||||
assert theming.inject_theme(html, tag) == html
|
||||
assert theming.inject_theme("", tag) == ""
|
||||
|
||||
|
||||
def test_inject_theme_exact_placement_before_first_head_close() -> None:
|
||||
"""The tag lands with a leading newline immediately BEFORE the
|
||||
first ``</head>`` — nothing between the tag and the close, nothing
|
||||
moved after it."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
||||
html = "<html><head><title>t</title></head><body>after</body></html>"
|
||||
assert theming.inject_theme(html, tag) == (
|
||||
"<html><head><title>t</title>\n" + tag + "</head><body>after</body></html>"
|
||||
)
|
||||
# A LATER ``</head>``-shaped stretch of text is not the anchor — the
|
||||
# FIRST occurrence wins (the one that closes the real head).
|
||||
html2 = "<head></head><script>if (x) { a() }</head></script></head>"
|
||||
assert theming.inject_theme(html2, tag) == (
|
||||
"<head>\n" + tag + "</head><script>if (x) { a() }</head></script></head>"
|
||||
)
|
||||
|
||||
|
||||
def test_inject_theme_double_injection_is_idempotent() -> None:
|
||||
"""The defensive idempotence rule keys on the id: once a
|
||||
``id="bor-theme"`` tag is present the helper is the identity — the
|
||||
page can never carry two theme tags, even for a different tag."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
||||
once = theming.inject_theme(_HEAD_HTML, tag)
|
||||
assert once.count('id="bor-theme"') == 1
|
||||
assert theming.inject_theme(once, tag) == once
|
||||
other = '<style id="bor-theme">:root{--bg:#222222;}</style>'
|
||||
assert theming.inject_theme(once, other) == once
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the
|
||||
# inline tag's content (the phase-82 CSP would otherwise BLOCK the tag
|
||||
# in every real browser; the caching middleware publishes the hash as a
|
||||
# style-src exemption on themed HTML pages only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_csp_hash_empty_tag_is_empty_string() -> None:
|
||||
"""No tag (unset/defaults deployment) → no hash — the plain A1
|
||||
policy stands and the header stays byte-identical to pre-91."""
|
||||
assert theming.theme_csp_hash("") == ""
|
||||
|
||||
|
||||
def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None:
|
||||
"""CSP3 §13.4: the hash covers the character data BETWEEN the tags
|
||||
(the ``:root{…}`` declarations — the rendered content carries no
|
||||
leading/trailing whitespace, so no stripping applies), base64
|
||||
after SHA-256, ``sha256-`` prefixed."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111}</style>'
|
||||
expected = "sha256-" + base64.b64encode(
|
||||
hashlib.sha256(b":root{--bg:#111111}").digest()
|
||||
).decode("ascii")
|
||||
assert theming.theme_csp_hash(tag) == expected
|
||||
|
||||
|
||||
def test_theme_csp_hash_changes_with_the_palette() -> None:
|
||||
"""A different palette → a different hash: the browser keeps
|
||||
blocking the OLD tag once the theme changes (the exemption always
|
||||
matches exactly the served bytes, never a stale palette)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
first = theming.theme_csp_hash(theming.theme_style_tag(colors))
|
||||
colors["brand"] = "#22c55e"
|
||||
second = theming.theme_csp_hash(theming.theme_style_tag(colors))
|
||||
assert first
|
||||
assert first != second
|
||||
assert first.startswith("sha256-")
|
||||
assert second.startswith("sha256-")
|
||||
@@ -0,0 +1,193 @@
|
||||
"""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:
|
||||
|
||||
* 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 ``#``);
|
||||
* 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 /
|
||||
whitespace-only string is the clear operation (NULL);
|
||||
* GET — the effective merge (a partial row reports the DB values over
|
||||
the env/built-in defaults);
|
||||
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
|
||||
same row (one row, always id 1).
|
||||
|
||||
House pattern (the ``test_tokens_api`` precedent): the real app via
|
||||
TestClient (cookie jar = the house admin-login fixture) against the real
|
||||
compose Postgres; the single row is global state, so an autouse fixture
|
||||
resets it around every test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_ui_settings(db: Session) -> Iterator[None]:
|
||||
"""ui_settings holds ONE row of global state: reset it around every
|
||||
test (the ``clean_tokens`` house pattern, DELETE — the row is
|
||||
created only by the PUT upsert, so "absent" is the natural
|
||||
pristine state)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _row(db: Session) -> UiSettings | None:
|
||||
return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
|
||||
|
||||
def test_put_too_long_string_422_names_the_field(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
|
||||
that field; a rejected PUT half-writes nothing; exactly 300 still
|
||||
passes (the column is VARCHAR(300))."""
|
||||
for field in theming.STRING_FIELDS:
|
||||
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
|
||||
assert r.status_code == 422, (field, r.text)
|
||||
assert r.json()["detail"] == f"{field} is too long (max 300)"
|
||||
# A whitespace-padded 301 is still 301 after the trim…
|
||||
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
|
||||
assert r.status_code == 422, (field, r.text)
|
||||
# No rejected PUT created the row — the upsert runs after validation.
|
||||
assert _row(db) is None
|
||||
# Exactly 300 passes — stored, trimmed.
|
||||
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["app_name"] == "y" * 300
|
||||
|
||||
|
||||
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
|
||||
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:
|
||||
for bad in ("fff", "#ff", "ff00aa", "#12345678", "red"):
|
||||
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"
|
||||
|
||||
|
||||
def test_put_lowercases_colors_on_store(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Uppercase hex passes the validator and is stored LOWERCASE — the
|
||||
canonical form the tag renderer and the drift comparison rely on."""
|
||||
r = admin_client.put("/api/ui-settings", json={"brand": "#818CF8"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["brand"] == "#818cf8"
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.brand == "#818cf8" # the stored column, not just the response
|
||||
|
||||
|
||||
def test_put_built_in_color_is_stored_as_null(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The owner-locked normalization: a color equal to its built-in is
|
||||
stored as NULL — PUTting the whole built-in palette (with one value
|
||||
in uppercase, proving the compare happens AFTER the lowercase)
|
||||
leaves the row COMPLETELY empty: "save the defaults" must keep an
|
||||
unset deployment byte-identical (the no-op injection contract)."""
|
||||
body = dict(ALL_NULL_BODY)
|
||||
for key, value in theming.BUILTIN_COLORS.items():
|
||||
body[key] = value.upper() if key == "brand" else value
|
||||
r = admin_client.put("/api/ui-settings", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
# The response is the effective values — still the built-ins…
|
||||
for key in theming.COLOR_FIELDS:
|
||||
assert r.json()[key] == theming.BUILTIN_COLORS[key]
|
||||
# …and the row itself is empty (the upsert created a row of NULLs).
|
||||
row = _row(db)
|
||||
assert row is not None, "the PUT upsert creates the id-1 row"
|
||||
assert row.id == 1
|
||||
for field in (*theming.STRING_FIELDS, *theming.COLOR_FIELDS):
|
||||
assert getattr(row, field) is None, f"{field} must be stored as NULL"
|
||||
|
||||
|
||||
def test_put_empty_string_is_the_clear_operation(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A whitespace-only (or empty) string trims to empty → NULL — the
|
||||
clear operation, not a 422 and not a stored blank: the effective
|
||||
value falls back to the env default."""
|
||||
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
|
||||
assert r.status_code == 200, r.text
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.app_name is None
|
||||
assert row.footer_text is None
|
||||
# The response reports the effective (env) fallback, not "".
|
||||
assert r.json()["app_name"] == get_settings().app_name
|
||||
assert r.json()["footer_text"] == get_settings().footer_text
|
||||
|
||||
|
||||
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."""
|
||||
db.add(UiSettings(id=1, bg="#123456"))
|
||||
db.commit()
|
||||
r = admin_client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert body["bg"] == "#123456" # the DB value wins
|
||||
for key in theming.COLOR_FIELDS:
|
||||
if key != "bg":
|
||||
assert body[key] == theming.BUILTIN_COLORS[key]
|
||||
assert body["app_name"] == get_settings().app_name
|
||||
assert body["input_placeholder"] == get_settings().input_placeholder
|
||||
assert body["footer_text"] == get_settings().footer_text
|
||||
|
||||
|
||||
def test_upsert_creates_then_updates_the_id_1_row(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The first PUT creates the id-1 row; the second updates the SAME
|
||||
row (still exactly one row, still id 1 — the single-row contract)."""
|
||||
r1 = admin_client.put(
|
||||
"/api/ui-settings", json={"brand": "#123abc", "app_name": "First"}
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
row = _row(db)
|
||||
assert row is not None and row.id == 1
|
||||
assert row.brand == "#123abc"
|
||||
assert row.app_name == "First"
|
||||
|
||||
r2 = admin_client.put(
|
||||
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["brand"] == "#abcdef"
|
||||
assert r2.json()["input_placeholder"] == "Second"
|
||||
|
||||
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||
rows = db.execute(select(UiSettings)).scalars().all()
|
||||
assert len(rows) == 1, "the upsert must never create a second row"
|
||||
assert rows[0].id == 1
|
||||
assert rows[0].brand == "#abcdef" # updated, not appended
|
||||
assert rows[0].input_placeholder == "Second" # the new string landed
|
||||
assert rows[0].app_name is None # absent in the second body → NULL
|
||||
@@ -168,20 +168,23 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
|
||||
|
||||
def test_no_other_hardcoded_46rem_rule_remains() -> None:
|
||||
"""After the switch, the form columns are the ONLY rules with a
|
||||
literal max-width: 46rem: .tuning-shell (phase 27) and
|
||||
literal max-width: 46rem: .tuning-shell (phase 27),
|
||||
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
|
||||
FORM column, not a reading column, so it must not ride
|
||||
--chat-column and phase 58's wide-desktop doubling must never
|
||||
stretch the form). Every reading column rides the token (the
|
||||
--chat-column base declaration is the other non-rule occurrence
|
||||
of 46rem)."""
|
||||
stretch the form), and .theme-shell (phase 91 task 04 — the
|
||||
admin Theme editor is a form column too: the palette grid +
|
||||
fieldsets must never ride the wide-desktop doubling). Every
|
||||
reading column rides the token (the --chat-column base
|
||||
declaration is the other non-rule occurrence of 46rem)."""
|
||||
css = _css()
|
||||
assert css.count("max-width: 46rem") == 2, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell) may "
|
||||
"keep a literal max-width: 46rem"
|
||||
assert css.count("max-width: 46rem") == 3, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell, "
|
||||
".theme-shell) may keep a literal max-width: 46rem"
|
||||
)
|
||||
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".theme-shell")
|
||||
|
||||
|
||||
def test_comments_cite_the_wide_override_with_provenance() -> None:
|
||||
|
||||
Reference in New Issue
Block a user