phase: 190_app_name_settings_fix
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:
@@ -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"
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user