phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+408
View File
@@ -0,0 +1,408 @@
"""Unit: the live-brand-on-save marker pins (phase 190, tasks 01 + 03).
Defect (b) of the phase (``TODO.md`` L3, the stale half): a save in
either admin surface never re-applied the brand strings to the OPEN
document. ``settings.js`` re-GETed the catalogue after a save but never
touched the brand; ``theme.js``'s save ran ``applyServedTheme`` (the
phase-92 COLOR reconcile) and stopped there; ``brand.js`` applied the
strings ONCE at boot from ``/api/config`` — so the open page kept the old
name until a manual reload.
Task 01 pinned that as the ABSENT behavior; **task 03 inverted it**, and
this module now pins the fix:
* ``APPLY_BRAND`` (``applyBrandFromConfig``) is ONE function in
``brand.js`` — the single owner of the brand DOM — exported on the
layer's existing ``window`` surface and called by the BOOT
(``applyBrand``) and by both admin surfaces. The boot is the
EXTRACTION, not a duplication: the four DOM writes exist once;
* ``settings.js`` re-applies after a save / per-row Reset / Clear all
that touched one of the three brand keys, and after nothing else;
* ``theme.js`` re-applies after Save and after Reset (a reset can make a
legacy baked-in string fall back, so the page must re-read it);
* a FAILED re-apply leaves the DOM at its last applied state — the
phase-168 "never a blanked panel" precedent applied to the brand.
House JS convention (``test_frontend_brand.py`` /
``test_settings_frontend_view.py`` / ``test_save_chat_ui.py``): the
browser behavior is pinned at source-marker level here, E2E in the
phase's ``tests/e2e/test_app_name_settings_fix.py`` (task 04).
``BRAND_DOM_TOKENS`` (the four writes that constitute "applying the
brand strings": the document title, the ``.brand-text`` header, the
``#message-input`` composer placeholder, the ``.footer-text`` line) stays
the ownership pin it was in task 01 — those belong to the ONE shared pass
in ``brand.js`` and must NEVER be duplicated inside ``settings.js`` / the
``theme.js`` save paths. The live-apply is asserted by presence of the
shared CALL, never by a second copy of the DOM path.
"""
from __future__ import annotations
from pathlib import Path
ASSETS = Path(__file__).resolve().parents[2] / "frontend" / "assets"
BRAND_JS = ASSETS / "brand.js"
SETTINGS_JS = ASSETS / "settings.js"
THEME_JS = ASSETS / "theme.js"
#: The ONE shared brand pass task 03 extracted out of ``brand.js`` (the
#: phase-190 contract name) — boot and live-apply run this function.
APPLY_BRAND = "applyBrandFromConfig"
#: The four DOM writes that ARE "re-applying the brand strings" — the
#: shared pass owns them; a save surface that writes one of these
#: directly has duplicated the brand path (the defect the phase fixes
#: must not be re-introduced as a second copy).
BRAND_DOM_TOKENS = ("document.title", "brand-text", "message-input", "footer-text")
#: The three keys the Settings panel owns and whose save/reset therefore
#: re-applies the brand (phase 190 A1). A save of any other row must not.
BRAND_KEYS = ("app_name", "input_placeholder", "footer_text")
#: The two statements every live re-apply is made of, pinned verbatim so
#: the counts below count CALL SITES and not prose (the house
#: ``= fetch("/api/config"`` idiom in ``test_frontend_brand.py``). Both
#: surfaces write them identically — the shared semantics, one shape.
CONFIG_FETCH = 'const r = await fetch("/api/config", { cache: "no-store" });'
SHARED_CALL = "window.applyBrandFromConfig(await r.json());"
# The three settings.js settle paths (save / per-row Reset / clear-all) —
# each ends its success arm with the catalogue re-GET (phase 168,
# untouched) followed by the brand re-apply (phase 190 task 03, additive).
SETTINGS_SETTLE_PATHS = (
"async function saveSettings()",
"async function resetRow(",
"async function confirmClearAll(",
)
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _body(js: str, header: str, closer: str = "\n }") -> str:
"""The source of the function whose declaration starts at ``header``
up to its closing brace — the house slice idiom
(``test_settings_frontend_view.py``): these view modules nest their
functions at two spaces inside ``mount``, so the first ``\\n }``
after the header closes it (nested blocks close at four).
``closer`` opens the same idiom to top-level functions (``brand.js``,
whose blocks close at column 0)."""
start = js.index(header)
end = js.find(closer, start)
assert end != -1, f"unterminated function body for {header!r}"
return js[start:end]
def _every_marker_preceded_by(body: str, marker: str, before: str) -> None:
"""Every ``marker`` occurrence sits after some ``before``
occurrence (the order pin for a body with more than one of each)."""
positions = []
start = 0
while (i := body.find(marker, start)) != -1:
positions.append(i)
start = i + 1
assert positions, f"marker {marker!r} missing from the body"
for pos in positions:
assert body.rfind(before, 0, pos) != -1, (
f"{marker!r} must never run before {before!r}"
)
# ---------------------------------------------------------------------------
# brand.js — the ONE shared pass, exported, and the boot runs IT
# ---------------------------------------------------------------------------
def test_brand_js_exports_the_shared_pass() -> None:
"""Task 03's extraction: the brand DOM lives in ONE named function
``applyBrandFromConfig(cfg)`` and reaches the page's MODULE scripts
through the layer's existing export surface — the ``window`` globals
it has always had (``window.BOR_BRAND``, ``window.BOR_CONFIG_PROMISE``).
``brand.js`` stays a classic script (no ``export`` statement —
``test_frontend_brand.py`` owns that pin), so the assignment IS the
export."""
js = _read(BRAND_JS)
assert "function applyBrandFromConfig(cfg) {" in js, (
"the shared pass is a named function taking the /api/config payload"
)
assert "window.applyBrandFromConfig = applyBrandFromConfig;" in js, (
"the shared pass is exported on the window surface settings.js / "
"theme.js already read (window.BOR_BRAND, window.BOR_CONFIG_PROMISE)"
)
def test_boot_call_site_is_the_extraction_not_a_second_copy() -> None:
"""The boot behavior is byte-identical (same request, same
assignments, same order) because the boot RUNS the shared pass:
``applyBrand`` awaits the parse-time promise and hands the settled
payload over — its own body contains NONE of the four DOM writes and
each of them occurs exactly ONCE in the file (the extraction, not a
duplication)."""
js = _read(BRAND_JS)
boot = _body(js, "function applyBrand() {", closer="\n}")
assert "applyBrandFromConfig(cfg)" in boot, "the boot calls the shared pass"
assert "BOR_CONFIG_PROMISE.then" in boot, "the boot still rides the parse-time fetch"
for token in BRAND_DOM_TOKENS:
assert token not in boot, (
f"the boot must not carry its own copy of the {token!r} write — "
"the shared pass is the ONE owner"
)
# Each of the four brand DOM writes exists exactly once in the file.
assert js.count('document.querySelectorAll(".brand-text")') == 1
assert js.count('document.querySelectorAll(".footer-text")') == 1
assert js.count('document.querySelector("#message-input")') == 1
# …and every `document.title` write sits inside the shared pass.
shared = _body(js, "function applyBrandFromConfig(cfg) {", closer="\n}")
assert js.count("document.title =") == shared.count("document.title =") >= 1
# The boot wiring is the phase-39 one (pinned in test_frontend_brand.py).
assert 'document.addEventListener("DOMContentLoaded", applyBrand)' in js
assert js.count('= fetch("/api/config"') == 1, (
"the boot fetch stays the file's only fetch — the SURFACES fetch, "
"brand.js never fetches twice"
)
def test_the_shared_pass_reapplies_over_the_name_already_shown() -> None:
"""A LIVE re-apply runs against a document whose literal passes
already rewrote the default literal into the previous name — so the
shared pass also replaces ``window.BOR_BRAND_APPLIED`` (the title,
the prose text nodes, the aria-label/placeholder/meta attributes:
the three places the literal passes touch). At boot the global is
``""`` so the clause is skipped and the boot path stays exactly the
phase-39 sequence (the byte-identical requirement)."""
js = _read(BRAND_JS)
shared = _body(js, "function applyBrandFromConfig(cfg) {", closer="\n}")
assert 'window.BOR_BRAND_APPLIED = "";' in js, (
"the previous-name global ships empty — at boot the clause never runs"
)
assert "window.BOR_BRAND_APPLIED" in shared, "the shared pass reads the last applied name"
assert "if (previous && previous !== name) {" in shared, (
"the re-apply clause is gated on a previous name that differs "
"(the boot no-op stays byte-identical)"
)
assert "document.title.replaceAll(previous, name)" in shared
assert "node.nodeValue.replaceAll(previous, name)" in shared
assert 'window.BOR_BRAND_APPLIED = name;' in shared, (
"the pass records what the document now shows, so the NEXT "
"re-apply replaces the right string"
)
# ---------------------------------------------------------------------------
# settings.js — the panel re-applies, gated on the three brand keys
# ---------------------------------------------------------------------------
def test_settings_js_settle_paths_reapply_the_brand() -> None:
"""The INVERTED task-01 pin: every settle path of the Settings panel
(Save, the per-row Reset, Clear all) re-applies the brand to the open
document through the shared pass — AFTER the phase-168 catalogue
re-GET, which stays the catalogue's own refresh (untouched). Clear
all passes all three keys (clearing hands every brand string back to
the environment); the Reset passes its own key; Save passes the keys
that actually landed — including the prefix of a batch that stopped
at a later failure (K4), because that value DID take effect."""
js = _read(SETTINGS_JS)
assert APPLY_BRAND in js, (
"task 03 inverted the task-01 absence pin: the panel now reaches "
"the shared brand pass"
)
for header in SETTINGS_SETTLE_PATHS:
body = _body(js, header)
assert "await reapplyBrand(" in body, (
f"{header} must re-apply the brand on settle"
)
assert "await loadSettings()" in body, (
f"the catalogue re-GET stays ({header}) — the phase-168 "
"refresh the re-apply EXTENDS, never replaces"
)
_every_marker_preceded_by(body, "await reapplyBrand(", "await loadSettings()")
save = _body(js, "async function saveSettings()")
assert "await reapplyBrand(saved)" in save, (
"Save re-applies the keys that landed (the K4 prefix included)"
)
assert "await reapplyBrand([key])" in _body(js, "async function resetRow("), (
"the per-row Reset re-applies the key it handed back to the env"
)
assert "await reapplyBrand(BRAND_KEYS)" in _body(
js, "async function confirmClearAll("
), "Clear all re-applies every brand key"
def _brand_keys_line(js: str) -> str:
"""The ``BRAND_KEYS`` declaration (the gate's data)."""
start = js.index("const BRAND_KEYS = [")
end = js.index("];", start)
return js[start:end]
def test_settings_js_gates_the_reapply_on_the_three_brand_keys() -> None:
"""The gate itself, in ONE helper: only ``app_name`` /
``input_placeholder`` / ``footer_text`` re-apply. A save of any of
the ~72 other rows costs no extra request and no DOM churn — and the
helper is the single place the panel reaches ``/api/config``, so the
surfaces cannot drift (the pin: exactly one shared-pass call and one
config fetch in the file)."""
js = _read(SETTINGS_JS)
helper = _body(js, "async function reapplyBrand(")
keys_line = _brand_keys_line(js)
assert tuple(BRAND_KEYS) == (
"app_name",
"input_placeholder",
"footer_text",
), "the phase-190 brand trio is the panel's own key set"
for key in BRAND_KEYS:
assert f'"{key}"' in keys_line, (
f"the gate must name {key!r} — otherwise its save never re-applies"
)
assert "if (!keys.some((key) => BRAND_KEYS.includes(key))) return;" in helper, (
"a settle that touched no brand key makes NO re-apply request"
)
assert js.count(SHARED_CALL) == 1, (
"ONE shared-pass call site in the panel — inside the helper"
)
assert js.count(CONFIG_FETCH) == 1, (
"ONE /api/config fetch in the panel module (the helper's)"
)
assert '{ cache: "no-store" }' in helper, (
"never a cached brand — the same no-store read the boot makes"
)
def test_settings_js_reapply_is_single_flight_under_the_existing_guard() -> None:
"""The §7.4 never-stale guard, EXTENDED rather than forked: the Save
button's own ``saveInFlight`` flag still owns the save (the re-apply
is awaited before it drops, so a second Save cannot start while the
config fetch is out), and the ONE flag inside the shared helper covers
the settle paths that reach it — a second Save / Reset / Clear all
never stacks a second ``/api/config`` fetch."""
js = _read(SETTINGS_JS)
helper = _body(js, "async function reapplyBrand(")
assert "let brandReapplyInFlight = false;" in js
assert "if (brandReapplyInFlight) return;" in helper, (
"one re-apply at a time — a second settle never stacks a fetch"
)
assert "brandReapplyInFlight = true;" in helper
finally_arm = helper[helper.index("} finally {") :]
assert "brandReapplyInFlight = false;" in finally_arm, (
"the flag clears in the finally — a failed re-apply can never "
"strand the guard on (the house stuck-button contract)"
)
save = _body(js, "async function saveSettings()")
guard_off = save.index("saveInFlight = false;")
assert save.index("await reapplyBrand(saved)") < guard_off, (
"the brand re-apply sits INSIDE the saveInFlight scope — the "
"existing guard's reach, not a parallel one"
)
# ---------------------------------------------------------------------------
# theme.js — the tab re-applies after Save and after Reset
# ---------------------------------------------------------------------------
def test_theme_js_save_and_reset_reapply_the_brand() -> None:
"""The Theme tab's two settle paths run the SAME shared pass after
the phase-92 palette reconcile: the tab no longer EDITS the strings
(A1) but a Reset nulls the stored row, and a legacy baked-in string
then FALLS BACK (panel row → env) — the open document must re-read
it without a reload. One code path for both surfaces' semantics
(``reapplyBrand``, called by Save and by Reset)."""
js = _read(THEME_JS)
save = _body(js, "async function saveTheme()")
reset = _body(js, "async function resetTheme()")
for name, body in (("saveTheme", save), ("resetTheme", reset)):
assert "await reapplyBrand()" in body, (
f"{name} re-applies the brand strings on success"
)
assert body.index("applyServedTheme(settings)") < body.index(
"await reapplyBrand()"
), "the palette reconcile keeps its place; the brand follows it"
assert js.count(SHARED_CALL) == 1, (
"ONE shared-pass call site in the tab module — inside its helper"
)
assert js.count(CONFIG_FETCH) == 1, (
"ONE /api/config fetch in the tab module, and the SAME statement "
"the panel uses (one shared semantics, one shape)"
)
helper = _body(js, "async function reapplyBrand()")
assert "if (brandReapplyInFlight) return;" in helper, (
"one re-apply at a time across Save and Reset (§7.4)"
)
# ---------------------------------------------------------------------------
# the failure arm — never a blanked brand (both surfaces)
# ---------------------------------------------------------------------------
def test_a_failed_reapply_leaves_the_dom_at_its_last_applied_state() -> None:
"""The phase-168 "never a blanked panel" precedent, applied to the
brand: in BOTH helpers a non-2xx returns BEFORE the shared pass runs,
and a network/JSON failure is swallowed with no DOM write at all —
the open page keeps the name, footer and placeholder it already
shows, and the save's own status / error line is the visibility."""
for path in (SETTINGS_JS, THEME_JS):
js = _read(path)
helper = _body(js, "async function reapplyBrand(")
assert "if (!r.ok) return;" in helper, (
f"{path.name}: a non-2xx /api/config must never reach the DOM"
)
assert helper.index("if (!r.ok) return;") < helper.index(
SHARED_CALL
), f"{path.name}: the shared pass runs only on a settled payload"
assert "} catch {" in helper, (
f"{path.name}: an unreachable app / non-JSON body is caught, "
"not thrown (the brand never blanks)"
)
catch_arm = helper[helper.index("} catch {") : helper.index("} finally {")]
for token in BRAND_DOM_TOKENS:
assert token not in catch_arm, (
f"{path.name}: the failure arm writes nothing ({token!r})"
)
# ---------------------------------------------------------------------------
# the ownership pins carried unchanged from task 01
# ---------------------------------------------------------------------------
def test_settings_js_never_writes_the_brand_dom_directly() -> None:
"""The shared-pass ownership pin — GREEN before, during AND after the
phase: the four brand DOM writes (title, ``.brand-text``, the composer
placeholder, ``.footer-text``) belong to the ONE brand path in
``brand.js``; the Settings panel must never duplicate one of them
inline. Task 03's live-apply goes through the shared
``applyBrandFromConfig`` call, so this file stays free of the tokens
even after the fix (no second copy of the DOM path)."""
js = _read(SETTINGS_JS)
for token in BRAND_DOM_TOKENS:
assert token not in js, (
f"settings.js must not own the brand DOM write {token!r} — "
"the shared brand.js pass is its ONE owner"
)
def test_theme_js_save_and_reset_reconcile_colors_only() -> None:
"""GREEN pre-phase AND through the phase: ``saveTheme``'s (and
``resetTheme``'s) success path calls ``applyServedTheme`` — the
phase-92 COLOR reconcile — and neither writes the brand DOM (no
title, no ``.brand-text``, no composer placeholder, no footer line).
Task 03's live brand reaches the document through the shared call,
which is exactly what this pin keeps true."""
js = _read(THEME_JS)
save = _body(js, "async function saveTheme()")
reset = _body(js, "async function resetTheme()")
assert "applyServedTheme(settings)" in save, (
"the success path keeps the phase-92 color reconcile"
)
assert "applyServedTheme(settings)" in reset, (
"the reset path keeps the phase-92 color reconcile"
)
for name, body in (("saveTheme", save), ("resetTheme", reset)):
for token in BRAND_DOM_TOKENS:
assert token not in body, (
f"{name} must stay colors-only at the DOM level — "
f"the brand write {token!r} belongs to the shared brand pass"
)
+17 -11
View File
@@ -292,7 +292,7 @@ def test_router_writes_active_state_title_and_meta() -> None:
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, "
"Set the palette — the theme is baked into every served page, "
"live on the first paint."
) in js
assert 'settings: "Settings · Brain of Reese"' in js
@@ -1444,20 +1444,26 @@ def test_theme_view_scaffold_in_the_shell() -> None:
"#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 17 labeled type=color palette inputs (the 9 identity
# variables, then the 8 semantic state variables — phase 93 —
# one per theming.COLOR_FIELDS field).
# convention): the 17 labeled type=color palette inputs (the 9
# identity variables, then the 8 semantic state variables — phase
# 93 — one per theming.COLOR_FIELDS field), and NOTHING else: the
# phase-190 (A1) strip removed the "Branding" fieldset and its
# three string inputs — the admin Settings panel owns the brand
# strings, so the tab neither renders nor sends them.
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"
assert f'id="{field_id}"' not in body, (
f"#{field_id} is gone from the Theme tab (phase 190 A1)"
)
assert f'for="{field_id}"' not in body, f"#{field_id}'s label is gone too"
assert "maxlength=\"300\"" not in body, (
"the 300-char string inputs were the branding fieldset's — gone"
)
assert not re.search(r"<legend[^>]*>\s*Branding\s*</legend>", body), (
"the Branding fieldset is gone (phase 190 A1)"
)
for field_id in (
"theme-bg",
"theme-surface",
+65
View File
@@ -221,6 +221,71 @@ def test_get_engine_creates_once_and_caches() -> None:
assert first is second
# ---------------------------------------------------------------------------
# applied_override_keys — "which keys does the panel own?" (phase 190)
# ---------------------------------------------------------------------------
class _KeyResult:
"""The ``session.execute(select(AppSetting.key))`` shape — the one
call :func:`applied_override_keys` makes on the caller's session."""
def __init__(self, keys: list[str]) -> None:
self._keys = keys
def scalars(self) -> _KeyResult:
return self
def all(self) -> list[str]:
return list(self._keys)
class _RecordingSession:
"""A stand-in session: it answers the key read and counts the calls
(the resolver's "one read for all three string fields" contract)."""
def __init__(self, keys: list[str]) -> None:
self.keys = keys
self.calls = 0
def execute(self, _stmt: object) -> _KeyResult:
self.calls += 1
return _KeyResult(self.keys)
def test_applied_override_keys_filters_orphans_and_locked_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from typing import Any, cast
"""Only a row the overlay would ACTUALLY apply counts: an orphan row
(a field a later release removed) and a LOCKED A3 row (a
hand-inserted ``database_url`` — read-only, never in effect) are
excluded, so a row that changes nothing can never win a brand
string from the Theme-tab fallback (phase 190 A1)."""
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
session = _RecordingSession(
["app_name", "database_url", "embedding_dim", "no_such_setting"]
)
assert settings_overlay.applied_override_keys(
cast(Any, session)
) == frozenset({"app_name"})
assert session.calls == 1
def test_applied_override_keys_kill_switch_reads_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from typing import Any, cast
"""``BOR_SETTINGS_DB=0`` means NO row is in effect (D6) — the helper
says so WITHOUT touching the database, so ``effective_settings``
falls through to the Theme-tab row instead of shadowing it with a
row the resolver ignores."""
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
session = _RecordingSession(["app_name"])
assert settings_overlay.applied_override_keys(cast(Any, session)) == frozenset()
assert session.calls == 0 # short-circuited before any statement
# ---------------------------------------------------------------------------
# merge — the defensive fallback (a validator change in a later release
# blames a field no override owns and no single drop heals the set: the
+77 -38
View File
@@ -3,7 +3,10 @@ pins).
The Theme tab edits ALL 17 palette variables (the 9 identity + the 8
semantic state colors — B3 revised, owner permission 2026-09-10,
TODO.md L3) plus the 3 branding strings: 20 form fields total. The
TODO.md L3) and NOTHING else since phase 190 (task 02, A1: the admin
Settings panel is the single writable owner of the 3 brand strings, so
the tab's "Branding" fieldset, its three string inputs and the string
half of its PUT body are gone). The
live behavior (live preview, the Save/Reset PUT body, the served-theme
sync, the contrast warnings) is E2E-gated (``tests/e2e/
test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04);
@@ -12,20 +15,25 @@ design depends on, so a silent regression is caught without a browser
(house pattern: ``tests/unit/test_big_read_progress.py`` reads
frontend sources and asserts on their mechanisms):
* ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order
(the 3 branding strings, then the 17 colors in the server's
``theming.COLOR_FIELDS`` order — identity, brand, then state):
everything that iterates FIELDS (live preview, ``collectBody``'s PUT
body, ``clearPreview``, ``applyServedTheme``'s tag content) covers
the 8 semantic pickers automatically only if this order holds;
* ``theme.js`` ``FIELDS`` — exactly 17 entries (the colors ONLY — no
``kind: "string"`` entry may come back) in the FORM's order, the
server's ``theming.COLOR_FIELDS`` order (identity, brand, then
state): everything that iterates FIELDS (live preview,
``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``'
tag content) covers the 8 semantic pickers automatically only if this
order holds — and the body ``collectBody`` builds carries no string
key either (the phase-190 pin: an old cached client's save can no
longer re-bake a brand string);
* ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA
(4.5:1) pairs: the five identity pairs + the three semantic
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
(decorative borders, no contrast duty — the same rule as
``--line`` / ``--grid-line``); the docstring mirror is pinned in
``tests/unit/test_theming.py``;
* ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids
+ visible labels; the 17 color inputs are ``type="color"`` and ship
* ``index.html`` ``#view-theme`` — the 17 color inputs carry E2E-stable
ids + visible labels, and the three removed string inputs stay absent
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
the phase-190 A1 strip); the color inputs are ``type="color"`` and ship
the BUILT-IN static values — asserted against
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
house drift pattern — the same guard ``test_theming.py`` runs on
@@ -88,16 +96,24 @@ def _root_declarations() -> dict[str, str]:
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
# ---------- theme.js: FIELDS — the 20 form fields, in order ----------
# ---------- theme.js: FIELDS — the 17 color form fields, in order ----------
def test_fields_lists_all_20_in_form_order() -> None:
"""``FIELDS`` has EXACTLY 20 entries — the 3 branding strings,
then the 17 color fields — in the form's own order (the palette
fieldset's 9 identity pickers, then the State colors fieldset's 8
semantic pickers), each with its E2E-stable ``theme-*`` id and
kind (the color entries are ``kind: "color"`` — the live preview,
``collectBody``, and the served-theme sync all key on it)."""
def test_fields_lists_the_17_colors_and_no_strings() -> None:
"""``FIELDS`` has EXACTLY 17 entries — the color fields ONLY — in the
form's own order (the palette fieldset's 9 identity pickers, then
the State colors fieldset's 8 semantic pickers), each with its
E2E-stable ``theme-*`` id and kind (the color entries are
``kind: "color"`` — the live preview, ``collectBody``, and the
served-theme sync all key on it).
Phase 190 (task 02, A1) is what makes this a CONTRACT and not a
coincidence: the admin Settings panel is the single writable owner
of ``app_name`` / ``input_placeholder`` / ``footer_text``, so no
entry may carry ``kind: "string"`` — an entry means a populated
input AND a PUT-body key, and a string key is exactly how the
Theme tab used to bake the then-effective name into its row and
shadow every later panel change (``TODO.md`` L3)."""
js = _js()
start = js.index("const FIELDS = [")
end = js.index("];", start)
@@ -105,11 +121,8 @@ def test_fields_lists_all_20_in_form_order() -> None:
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
js[start:end],
)
assert len(fields) == 20, f"FIELDS must list exactly 20 entries, got {len(fields)}"
assert len(fields) == 17, f"FIELDS must list exactly 17 entries, got {len(fields)}"
assert [(f, i) for f, i, _ in fields] == [
("app_name", "theme-app-name"),
("input_placeholder", "theme-placeholder"),
("footer_text", "theme-footer"),
("bg", "theme-bg"),
("surface", "theme-surface"),
("ink", "theme-ink"),
@@ -127,11 +140,14 @@ def test_fields_lists_all_20_in_form_order() -> None:
("accent_bg", "theme-accent-bg"),
("accent_ink", "theme-accent-ink"),
("accent_line", "theme-accent-line"),
], "FIELDS must list the 20 fields in the form's order"
assert all(kind == "color" for _, _, kind in fields[3:]), (
"the 17 palette entries are all kind color"
], "FIELDS must list the 17 color fields in the form's order"
assert all(kind == "color" for _, _, kind in fields), (
"every palette entry is kind color — no string field is back in the tab"
)
assert all(kind == "string" for _, _, kind in fields[:3])
for name in theming.STRING_FIELDS:
assert f'{{ field: "{name}"' not in js[start:end], (
f"{name} left the Theme tab in phase 190 — the panel owns it"
)
def test_fields_color_order_is_the_server_color_fields_order() -> None:
@@ -153,6 +169,28 @@ def test_fields_color_order_is_the_server_color_fields_order() -> None:
)
def test_collectbody_builds_the_17_colors_and_no_string_keys() -> None:
"""``collectBody`` — the PUT body the Save button sends — is
FIELDS-driven and therefore carries the 17 colors ONLY: no string
key appears anywhere in its source (phase 190, A1), and the old
trimmed-string branch (``value.trim() || null`` — the bake) is gone.
The server ignores body strings defensively too
(``app/api/ui_settings.py``), so this is the client half of "a
Theme-tab save can never shadow the Settings panel again"."""
js = _js()
start = js.index("function collectBody()")
end = js.index("\n }", start)
body_src = js[start:end]
assert body_src, "collectBody must exist"
for name in theming.STRING_FIELDS:
assert name not in body_src, f"{name} must never ride the PUT body"
assert "value.trim()" not in body_src, "the string-trim bake branch must be gone"
assert "for (const f of FIELDS)" in body_src, (
"the body stays FIELDS-driven — one ordering source for the PUT"
)
assert "isHex(value)" in body_src, "the colors keep their hex-or-null rule"
# ---------- theme.js: PAIRS — exactly the eight WCAG pairs ----------
@@ -197,23 +235,24 @@ def test_line_vars_are_excluded_from_pairs() -> None:
)
# ---------- index.html: the #view-theme form (20 inputs) ----------
# ---------- index.html: the #view-theme form (17 color inputs) ----------
def test_theme_view_carries_all_20_labeled_inputs() -> None:
"""All 20 inputs are STATIC markup in the shell (the E2E-stable
selectors convention): each with a visible ``<label for>``; the 17
palette inputs are ``type="color"`` (the 3 branding inputs
``type="text"``)."""
def test_theme_view_carries_the_17_labeled_color_inputs() -> None:
"""The 17 color inputs are STATIC markup in the shell (the
E2E-stable selectors convention), each with a visible
``<label for>`` and ``type="color"`` — and NO string input is in the
view (phase 190, task 02: the "Branding" fieldset and its
``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer``
inputs are gone, so the tab can neither show nor send a brand
string; the same absence is asserted in the browser by
``tests/e2e/test_admin_theme_tab.py``)."""
body = _theme_view(_html())
text_ids = ("theme-app-name", "theme-placeholder", "theme-footer")
for field_id in text_ids:
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
f"missing the visible label for #{field_id}"
)
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="text"[^>]*>', body), (
f"#{field_id} must be a text input"
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
assert f'id="{field_id}"' not in body, (
f"#{field_id} must be gone from the Theme tab (phase 190 A1)"
)
assert f'for="{field_id}"' not in body, f"#{field_id}'s label must be gone"
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
+219 -3
View File
@@ -29,6 +29,20 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
ellipsis. The :root 17-variable set itself stays pinned by
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
phase adds no palette variable, B3).
* Phase 190 (tasks 01 + 02): the brand-string OWNERSHIP pins (A1, owner
confirmed 2026-10-18) — the Settings-panel row (``app_settings``,
phase 168) is the single writable owner of the three strings, so the
resolution order is panel row → ``ui_settings`` (Theme-tab) row →
env. Task 01 pinned the order as a strict ``xfail`` (the baked-in tab
row SHADOWED the panel — the TODO.md L3 incident); task 02 flipped
``effective_settings`` and removed the marker, so the ownership pin
(``test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row``)
is GREEN here. Beside it: the no-panel-row fallback pin (the tab row
still applies — the A1 fallback a legacy baked-in row lands in, green
before AND after the flip), the PER-FIELD pin (a panel row for one
string never touches the other two), and the kill-switch pin
(``BOR_SETTINGS_DB=0`` → no row is in effect, so the tab row keeps
its phase-91 fallback).
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
renderer, byte-identical for the built-in palette (the golden
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
@@ -47,12 +61,13 @@ import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from sqlalchemy import select
import pytest
from sqlalchemy import select, text, update
from sqlalchemy.orm import Session
from app.config import Settings
from app.config import Settings, invalidate_settings
from app.core import theming
from app.models import UiSettings
from app.models import AppSetting, UiSettings
REPO_ROOT = Path(__file__).resolve().parents[2]
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
@@ -72,6 +87,16 @@ def _start_row_missing(db: Session) -> None:
db.commit()
def _clear_panel_rows(db: Session) -> None:
"""Phase 190: the ``app_settings`` rows (the Settings-panel surface,
phase 168) are global state exactly like the single ``ui_settings``
row — wipe them so the ownership pins start AND end with an empty
panel (a surviving row would shadow every later suite's strings
once the overlay is enabled)."""
db.execute(text("DELETE FROM app_settings"))
db.commit()
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped, in file order."""
@@ -133,6 +158,38 @@ def _env_settings() -> Settings:
)
def _panel_settings() -> Settings:
"""The OVERLAY-RESOLVED ``Settings`` (phase 190): the object
:func:`app.config.get_settings` hands every consumer when the
Settings panel holds ``app_name`` / ``input_placeholder`` /
``footer_text`` rows — the DB rows layered over the env base
(``_env_settings`` standing for that base). The ownership pin
pairs this explicit object with REAL committed ``app_settings``
rows so the contract holds whichever side of the overlay the
resolver consults — the panel row is the source, never the bare
env value.
"""
return Settings(
app_name="Panel Name",
input_placeholder="Panel placeholder…",
footer_text="Panel footer",
)
def _seed_panel_rows(db: Session) -> None:
"""The three Settings-panel rows, committed (production writes them
through ``PUT /api/admin/settings/{key}`` — the resolver only ever
sees the table, so seeding it directly IS the panel state)."""
db.add_all(
[
AppSetting(key="app_name", value="Panel Name"),
AppSetting(key="input_placeholder", value="Panel placeholder…"),
AppSetting(key="footer_text", value="Panel footer"),
]
)
db.commit()
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
@@ -193,6 +250,165 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
db.commit()
# ---------- phase 190 (task 01): the brand-string ownership (A1) ----------
def test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Settings panel is the SINGLE
writable owner of the three brand strings, so an ``app_settings``
row beats a conflicting ``ui_settings`` (Theme-tab) row for EVERY
one of them — the resolution order is panel row → tab row → env.
GREEN since task 02 flipped the order (phase 190 task 01 pinned it
as a strict ``xfail`` on the defect: ``effective_settings`` used to
return the ``ui_settings`` value whenever it was a non-empty string,
so a Theme-tab save BAKED the then-effective name into the row and
every later Settings-panel change was SHADOWED — the exact
``TODO.md`` L3 incident, "changing the App name in settings broke,
it no longer updates the name").
"""
_start_row_missing(db)
_clear_panel_rows(db)
_seed_panel_rows(db)
db.add(
UiSettings(
id=1,
app_name="Tab Name",
input_placeholder="Tab placeholder…",
footer_text="Tab footer",
)
)
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # the overlay is the panel's mechanism
try:
effective = theming.effective_settings(db, _panel_settings())
assert effective["app_name"] == "Panel Name" # pre-fix: "Tab Name" (the shadow)
assert effective["input_placeholder"] == "Panel placeholder…"
assert effective["footer_text"] == "Panel footer"
# The ownership flip is STRINGS-only — the tab row keeps the
# colors half untouched (the 17-color palette is unchanged).
assert effective["bg"] == theming.BUILTIN_COLORS["bg"]
assert effective["brand"] == theming.BUILTIN_COLORS["brand"]
finally:
_clear_panel_rows(db)
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_strings_fall_back_to_ui_settings_row_without_panel_row(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The A1 FALLBACK (no data migration): a ``ui_settings`` row set and
NO Settings-panel row means the tab value still applies (a legacy
baked-in row keeps rendering what it holds until the panel
overrides it) — the env value stays the LAST resort.
GREEN pre-phase AND post-phase: the pin that protects the fallback
branch of the new order. An implementation that lets the plain env
``Settings`` value beat the tab row (instead of consulting the
actual ``app_settings`` rows for a real override) fails here.
"""
_start_row_missing(db)
_clear_panel_rows(db)
db.add(
UiSettings(
id=1,
app_name="Tab Name",
input_placeholder="Tab placeholder…",
footer_text="Tab footer",
)
)
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # overlay live, panel table simply empty
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Tab Name"
assert effective["input_placeholder"] == "Tab placeholder…"
assert effective["footer_text"] == "Tab footer"
finally:
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_strings_resolve_per_field_panel_then_tab_then_env(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The order is PER FIELD, not per row (phase 190 task 02): one
Settings-panel row owns exactly its own key, so ``app_name`` reads
the panel while ``input_placeholder`` falls to the tab row and
``footer_text`` — absent from BOTH rows — falls to the env value.
The panel row that equals the env value still wins, too: presence,
never a value compare (a compare is how the shadow would sneak
back — an owner who sets the panel name to what the env already
says would see a legacy tab bake instead)."""
_start_row_missing(db)
_clear_panel_rows(db)
db.add(AppSetting(key="app_name", value="Panel Name"))
db.add(UiSettings(id=1, app_name="Tab Name", input_placeholder="Tab placeholder…"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
env = _env_settings()
try:
effective = theming.effective_settings(
db,
Settings(
app_name="Panel Name",
input_placeholder=env.input_placeholder,
footer_text=env.footer_text,
),
)
assert effective["app_name"] == "Panel Name" # the panel row (tab row ignored)
assert effective["input_placeholder"] == "Tab placeholder…" # no panel row → tab
assert effective["footer_text"] == env.footer_text # neither row → env
# A panel row that EQUALS the env value still owns the field —
# the tab row must not resurrect itself on a value tie.
db.execute(
update(AppSetting).where(AppSetting.key == "app_name").values(
value=env.app_name
)
)
db.commit()
tied = theming.effective_settings(db, Settings(app_name=env.app_name))
assert tied["app_name"] == env.app_name
assert tied["input_placeholder"] == "Tab placeholder…"
finally:
_clear_panel_rows(db)
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_strings_ignore_panel_rows_when_the_overlay_is_off(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``BOR_SETTINGS_DB`` kill switch (phase 168 D6) governs the
ownership too: with the overlay off NO row is in effect (so
``settings.app_name`` is the plain env value), and the Theme-tab row
keeps its phase-91 fallback rather than being shadowed by a row the
resolver never applies."""
_start_row_missing(db)
_clear_panel_rows(db)
_seed_panel_rows(db)
db.add(UiSettings(id=1, app_name="Tab Name"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Tab Name" # no override in effect → the tab row
assert effective["input_placeholder"] == _env_settings().input_placeholder
assert effective["footer_text"] == _env_settings().footer_text
finally:
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
_clear_panel_rows(db)
db.execute(_delete_row())
db.commit()
invalidate_settings()
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
+62 -40
View File
@@ -1,4 +1,5 @@
"""Unit: the admin UI-settings API (phase 91, task 01).
"""Unit: the admin UI-settings API (phase 91, task 01; colors-only since
phase 190, task 02).
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
contract and the GET/PUT persistence on the single ``ui_settings`` row
@@ -6,13 +7,18 @@ contract and the GET/PUT persistence on the single ``ui_settings`` row
the 8 semantic state):
* PUT validation — the 422s NAME the offending field (fixed details):
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
prefix, 3-digit shorthand, 8 hex chars, missing ``#``) — identity AND
semantic fields alike (the loops are ``COLOR_FIELDS``-driven);
a non-``#rrggbb`` color (wrong prefix, 3-digit shorthand, 8 hex
chars, missing ``#``) — identity AND semantic fields alike (the loops
are ``COLOR_FIELDS``-driven);
* the STRINGS are accepted and IGNORED (phase 190, A1 — the admin
Settings panel is the single writable owner of ``app_name`` /
``input_placeholder`` / ``footer_text``): no body string is written,
whatever its length or whitespace, and the stored columns are left
exactly as they were (never cleared) — so an old cached client's Save
cannot re-bake a name and shadow the panel;
* 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);
must leave the row empty — the no-op injection contract);
* 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
@@ -65,25 +71,33 @@ 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(
def test_put_string_fields_are_ignored_not_validated(
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))."""
"""Phase 190 (A1): the 3 string fields of the body are DROPPED before
the handler reads them — no trim, no length check, no 422. A
400-char name (the old 422 case) is a plain 200 that writes nothing,
and the response reports the effective (env) value; the palette in
the same body still saves. An old cached client can therefore never
re-bake a brand string from this surface — and can never be blocked
by a validation error on a field this API no longer owns."""
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
r = admin_client.put(
"/api/ui-settings", json={field: "x" * 301, "brand": "#818cf8"}
)
assert r.status_code == 200, (field, r.text)
assert r.json()[field] == getattr(get_settings(), field)
db.expire_all()
row = _row(db)
assert row is not None
assert getattr(row, field) is None, f"{field} must never be stored"
assert row.brand == "#818cf8" # the colors half still saves beside it
# And the row the PUT created holds no string at all.
db.expire_all()
row = _row(db)
assert row is not None
for field in theming.STRING_FIELDS:
assert getattr(row, field) is None
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
@@ -210,27 +224,35 @@ def test_put_semantic_built_in_is_stored_as_null(
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
def test_put_empty_string_is_the_clear_operation(
def test_put_cannot_clear_a_stored_string(
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
"""The A1 fallback survives the write strip: a legacy row that holds
a baked-in string keeps it through an empty / whitespace / explicit
``null`` PUT — the Theme tab neither writes nor CLEARS the strings
(clearing one is the panel's ``DELETE /api/admin/settings/{key}``).
The response keeps reporting the effective value, so the old client
still shows what the deployment shows."""
db.add(UiSettings(id=1, app_name="Legacy bake", footer_text="Legacy footer"))
db.commit()
for body in ({"app_name": " ", "footer_text": ""}, {"app_name": None}):
r = admin_client.put("/api/ui-settings", json=body)
assert r.status_code == 200, (body, r.text)
db.expire_all()
row = _row(db)
assert row is not None
assert row.app_name == "Legacy bake" # never written, never cleared
assert row.footer_text == "Legacy footer"
# The response reports the EFFECTIVE value (the stored fallback).
assert r.json()["app_name"] == "Legacy bake"
assert r.json()["footer_text"] == "Legacy footer"
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 12 keys, no nulls."""
built-ins and the env strings — all 20 keys, no nulls."""
db.add(UiSettings(id=1, bg="#123456"))
db.commit()
r = admin_client.get("/api/ui-settings")
@@ -258,19 +280,19 @@ def test_upsert_creates_then_updates_the_id_1_row(
row = _row(db)
assert row is not None and row.id == 1
assert row.brand == "#123abc"
assert row.app_name == "First"
assert row.app_name is None # phase 190 A1: the body's string is dropped
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"
assert r2.json()["input_placeholder"] == get_settings().input_placeholder
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
assert rows[0].input_placeholder is None # the strings are never written
assert rows[0].app_name is None