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
+152 -72
View File
@@ -17,16 +17,24 @@ Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
20 inputs show the effective defaults (the 3 template strings +
the 17 built-in hexes — the 9 identity + the 8 semantic state,
phase 93 — parsed out of ``styles.css``'s ``:root`` IN-TEST — the
suite can never drift from the stylesheet); Save
17 color inputs show the effective defaults (the built-in hexes —
the 9 identity + the 8 semantic state, phase 93 — parsed out of
``styles.css``'s ``:root`` IN-TEST — the suite can never drift from
the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
the inputs re-populate to the saved values; the persisted row is
the one saved; and the saved non-AA palette lists its failing
pair in ``#theme-contrast`` without blocking the save
(warning-only).
(warning-only), and the row lands with its 17 colors and NO brand
string (phase 190, A1).
1b. ``test_tab_renders_the_17_colors_and_no_string_inputs`` — the
phase-190 (A1) strip in the browser: the tab's DOM holds exactly the
17 ``type=color`` inputs and NONE of the old string inputs
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
count 0), the admin Settings panel being the single writable owner of
the brand strings; a Save posts a body with the 17 colors and no
string key.
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
@@ -34,8 +42,11 @@ Test → contract mapping (one story, one phase, one isolated file):
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
stylesheet application). The 3 strings stay on the brand.js boot
fetch (the B4 split: colors pre-paint, strings via the fetch).
stylesheet application). Phase 190 (A1) turned the other half of the
old B4 split into a contract: a palette save writes NO brand string,
so the anonymous page still renders the env/template name,
placeholder and footer line after the save (the boot fetch has
nothing new to apply — a theme save can no longer rename the app).
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
anonymous meets ``#theme-gate`` (sign-in link
``?next=/theme.html``) with ``#theme-content`` hidden and the
@@ -45,8 +56,8 @@ Test → contract mapping (one story, one phase, one isolated file):
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
20 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
17 built-in colors, serves NO theme tag, and the served bytes equal
a row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
@@ -89,7 +100,7 @@ from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.core.theming import COLOR_FIELDS, STRING_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
@@ -133,14 +144,10 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
# The three brand strings are GONE from this surface (phase 190, A1 —
# the admin Settings panel is their single writable owner), so this
# suite carries no saved-string fixture any more: a palette save writes
# colors only, and every string assertion reads the env/template default.
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
@@ -306,12 +313,21 @@ def _cookies(page: Page) -> dict[str, str]:
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save itself is test 1's job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings and this API ignores them. The seed asserts the
colors round-trip AND that the effective strings are still the
env/template defaults, i.e. a theme save cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -336,27 +352,22 @@ def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
#: The three string inputs the Theme tab used to render (phase 91) —
#: REMOVED in phase 190 (A1: the admin Settings panel is the single
#: writable owner of the brand strings). Every pin below asserts they
#: count 0 in the tab DOM.
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 20 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
def _expect_form_values(page: Page, colors: dict[str, str]) -> None:
"""Assert the 17 color inputs show the given effective values."""
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
@@ -401,7 +412,6 @@ def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_0
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
@@ -418,15 +428,13 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 20 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 17 built-in hexes (9 identity + 8 semantic)
# parsed straight out of styles.css's :root (the resolver's
# missing-row branch).
_expect_form_values(page, defaults, builtin)
# The 17 color inputs show the EFFECTIVE defaults: the built-in
# hexes (9 identity + 8 semantic) parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, builtin)
# Set a distinct palette + the 3 strings, then Save through the
# real form — the PUT held so the §7.4 in-flight state is
# observable deterministically.
# Set a distinct palette, then Save through the real form — the PUT
# held so the §7.4 in-flight state is observable deterministically.
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
@@ -448,19 +456,20 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) values (the
# The inputs re-populate to the SAVED (effective) colors (the
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
_expect_form_values(page, PALETTE)
# The row landed in Postgres (the id-1 single row, all 20 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
# The row landed in Postgres (the id-1 single row) with its 17
# colors — every palette color differs from its built-in, so nothing
# collapsed to NULL — and with NO brand string: the tab no longer
# writes them, which is the write half of the TODO.md L3 fix
# (phase 190, A1).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
assert row.app_name == APP_NAME
assert row.input_placeholder == PLACEHOLDER
assert row.footer_text == FOOTER
for field in STRING_FIELDS:
assert getattr(row, field) is None, f"{field} must never be stored"
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
@@ -476,10 +485,73 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 1b. The phase-190 (A1) strip in the browser: 17 color inputs, no
# string inputs, and a Save body that carries no string key
# ---------------------------------------------------------------------------
def test_tab_renders_the_17_colors_and_no_string_inputs(
page: Page, app_url: str, db_ready: None
) -> None:
"""The owner-visible shape of the strip (phase 190, A1): the Theme
tab is a COLORS surface — its DOM holds exactly the 17
``type=color`` pickers and NONE of the three string inputs it used
to render, and the body Save posts carries the 17 colors and no
string key. That is the read half of "changing the App name in
settings broke": the tab can neither show a stale name nor bake one
into its row (the write half is the ignored-string PUT, pinned by
``tests/integration/test_ui_settings_api.py``)."""
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The three string inputs are ABSENT — not hidden, not empty: gone
# from the DOM, so no selector can fill one and no populate() can
# write a stale effective value into it.
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
# The 17 color pickers are all there and still type=color.
color_inputs = page.locator('#view-theme input[type="color"]')
expect(color_inputs).to_have_count(len(COLOR_FIELDS))
_expect_form_values(page, builtin)
# Save posts the 17 colors only — the captured PUT body names no
# string field (the client half of the bake being dead).
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
_fill_theme_form(page, PALETTE)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert set(body) == set(COLOR_FIELDS), (
f"the PUT body must be the 17 colors only, got {sorted(body)}"
)
for field in STRING_FIELDS:
assert field not in body
assert body.get("bg") == PALETTE["bg"]
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), the B4
# strings via the boot fetch
# + the computed palette at load (the no-pop-in proof), and the
# brand strings UNTOUCHED by a palette save (phase 190 A1)
# ---------------------------------------------------------------------------
@@ -527,16 +599,25 @@ def test_saved_theme_is_pre_paint_for_everyone(
anon.goto(app_url + "/")
_assert_raw_tag(anon.content(), PALETTE)
_wait_theme_computed(anon, PALETTE)
# The B4 split: the 3 strings are NOT pre-paint — they apply
# post-fetch via the /api/config boot fetch (brand.js) on the
# anonymous page too: the name (header brand + window
# global), the placeholder, and the footer line.
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
# Phase 190 (A1): the palette save wrote NO brand string, so
# the anonymous page renders the env/template strings through
# the SAME brand.js boot fetch (name + window global, the
# composer placeholder, the footer line) — saving a theme can no
# longer rename the deployment, which is the owner-visible half
# of the TODO.md L3 fix. (The strings' own surface — the admin
# Settings panel — is pinned by test_admin_settings_panel.py and
# the phase-190 suite.)
defaults = _template_defaults()
expect(anon.locator(".brand-text")).to_have_text(
defaults["app_name"], timeout=15_000
)
assert anon.evaluate("() => window.BOR_BRAND") == defaults["app_name"]
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
"placeholder", defaults["input_placeholder"]
)
expect(anon.locator(".footer-text").first).to_have_text(
defaults["footer_text"]
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
finally:
if anon_ctx is not None:
anon_ctx.close()
@@ -625,7 +706,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 20 defaults, NO theme tag, and
# 4. Reset: the §7.4 lifecycle, the 17 built-in colors, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -633,14 +714,13 @@ def test_anonymous_and_token_user_are_walled(
def test_reset_restores_the_builtin_byte_identical(
page: Page, app_url: str, db_ready: None
) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the effective defaults (the row-less
# state — the autouse clean truncated the row).
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
@@ -672,9 +752,9 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
# The form re-populates to the 20 defaults (the env/built-in
# The form re-populates to the 17 built-in colors (the built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# eight pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()
+704
View File
@@ -0,0 +1,704 @@
"""Phase 190 E2E (Playwright): the Settings-panel app name IS the app name.
Source: ``TODO.md`` L3 — "changing the App name in settings broke, it no
longer updates the name." The incident, in two defects:
1. **the shadow bake** — the admin Theme tab (phase 91) carried the three
brand strings in its form pre-populated with the EFFECTIVE value, and
``PUT /api/ui-settings`` wrote every one of them. So the first palette
save BAKED the then-effective name into the single ``ui_settings`` row,
``effective_settings`` resolved the strings tab-row-first, and every
LATER Settings-panel rename was shadowed: the panel showed the new value
with its ``db`` badge while ``/api/config`` — and the page — kept the
baked one;
2. **the stale open document** — neither surface re-applied the brand
strings after a save: ``settings.js`` re-GET the catalogue and stopped,
``theme.js`` ran the phase-92 palette reconcile and stopped, ``brand.js``
ran its pass once at boot. Even a save the server agreed with kept
painting the old name until a manual reload.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_incident_the_panel_name_survives_a_theme_tab_color_save`` — the
owner's own sequence at the wire: set the name in the Settings panel
(``/api/config`` serves it, the catalogue badge says ``db``) → change ONE
color in the Theme tab and save → ``/api/config`` STILL reports the panel
value, the stored ``ui_settings`` string columns stay NULL (never baked),
and a SECOND panel rename reaches ``/api/config`` and a fresh page (the
shadow the incident is). An OLD client whose PUT still carries the string
keys is accepted and changes NOTHING (A1's defensive half).
2. ``test_panel_brand_save_repaints_the_open_document`` — the A2 contract:
save the panel's three brand rows and the document that is ALREADY open
carries them in ``document.title``, ``.brand-text``, the footer line and
the composer placeholder (the no-reload half pinned by a document-life
sentinel) — and a SECOND rename repaints again, because the pass replaces
the name the page SHOWS and not only the template literal.
3. ``test_panel_row_reset_hands_the_brand_back_to_the_env_live`` — the
per-row Reset (``DELETE /api/admin/settings/{key}``) IS the string
fallback path, and it is live: all four places fall back to the env
values without a reload.
4. ``test_legacy_baked_tab_row_paints_then_the_panel_row_beats_it_live`` —
the A1 fallback + the tab's self-heal: a pre-190 baked ``ui_settings``
row still paints at boot when no panel row owns the string; a panel row
written from ANOTHER browser wins; the Theme tab's own Save re-reads it
onto the open document without a reload; the Theme-tab Reset never blanks
the brand; and the stored row keeps its legacy string (the tab's PUT
neither rewrites nor clears those columns).
5. ``test_boot_paints_the_effective_name_with_one_config_request`` — the boot
path stays byte-identical (task 03's extraction, not a duplication): a
fresh load with no save paints the env name in all four places and issues
EXACTLY ONE ``/api/config`` request (no double-apply).
6. ``test_theme_tab_dom_is_colors_only`` — the task-02 strip in the browser:
17 ``type=color`` pickers and NONE of ``#theme-app-name`` /
``#theme-placeholder`` / ``#theme-footer``, and the Save body carries no
string key.
Deviation note on the phase file's (c) ("a Theme-tab Reset → the brand falls
back"): the Theme tab's Reset is the phase-91 all-null COLORS PUT, and A1
forbids it from touching the string columns, so a palette reset can never
change a brand string. The live fall-back the owner sees is therefore pinned
where it actually happens — the panel's per-row Reset (test 3) and a panel
row removed while the tab is open (test 4) — and the tab's Reset is pinned to
re-read the effective string and NEVER blank the brand (test 4).
Per-module app env (the phase-168 / phase-91 module-app pattern): ONE app
with ``BOR_SETTINGS_DB=1`` (the panel writes must actually take effect — the
conftest pins the overlay OFF for every other suite) and the three brand vars
pinned to DISTINCTIVE env values, so "the DB row now wins" and "the row is
gone again" are visible DOM changes rather than no-ops against the code
defaults.
DB isolation: the shared e2e Postgres keeps ``app_settings`` (the panel rows —
a leftover row retunes every later suite's app) and the single ``ui_settings``
row across suites. An autouse fixture truncates ``ui_settings`` AND clears the
overrides THROUGH THE API (``DELETE /api/admin/settings``, which deletes every
row and calls ``invalidate_settings()``) before and after every test — a bare
TRUNCATE would leave the app's cached ``Settings`` (the phase-168
``lru_cache``) serving a previous test's override, and every test here starts
from the real env base. The module teardown pins both tables empty.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, Request, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# ---------------------------------------------------------------------------
# The fixtures of this incident — every value distinct from every other AND
# from the code defaults, so "which name is on screen" is never ambiguous.
# ---------------------------------------------------------------------------
#: The env base — the module app's own environment, the fallback target of
#: every Reset here and the boot path's answer with no rows anywhere.
ENV_NAME = "Environment Brain E2E"
ENV_PLACEHOLDER = "Environment placeholder E2E"
ENV_FOOTER = "Environment footer E2E"
#: The panel's first rename (the incident's "set the name in Settings").
PANEL_ONE = "Panel Name One E2E"
PANEL_ONE_PLACEHOLDER = "Panel placeholder E2E"
PANEL_ONE_FOOTER = "Panel footer E2E"
#: The panel's SECOND rename — the one the baked Theme-tab row used to
#: swallow, and the one the live re-apply has to paint over the first.
PANEL_TWO = "Panel Name Two E2E"
#: What a pre-phase-190 Theme-tab save baked into ``ui_settings``.
LEGACY_BAKED_NAME = "Legacy Baked Tab Name"
#: One color pick for the Theme-tab save — distinct from the built-in
#: ``#f43f5e``, so the save is a real write and not the NULL collapse.
THEME_FIELD = "brand"
THEME_INPUT = "#theme-brand"
THEME_PICK = "#4f46e5"
THEME_PICK_2 = "#556677"
#: The three string inputs the Theme tab used to render (phase 91), REMOVED
#: in phase 190 (A1 — the panel is the strings' single writable owner).
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
# ---------------------------------------------------------------------------
# Per-module app env (the phase-168 panel / phase-91 theme pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the overlay ON (the whole
subject here is a SAVED brand string; the conftest pins it off for every
other suite — the ``test_pictorial_pdf.py`` opt-in pattern) and the three
brand vars pinned to DISTINCTIVE env values (the phase-61/62 leak guard:
an operator's local ``.env`` may carry the owner's name, and "the DB row
wins / the env wins again" has to be measured against values this suite
owns)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# The mock-calibrated gate pins (the conftest pattern) — no chat turn is
# sent here, but the app boots with the same shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
# Phase 168's kill switch, ON for this suite (the opt-in).
env["BOR_SETTINGS_DB"] = "1"
# The distinctive env base — what every Reset hands control back to.
env["BOR_APP_NAME"] = ENV_NAME
env["BOR_INPUT_PLACEHOLDER"] = ENV_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = ENV_FOOTER
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _admin_client() -> httpx.Client:
"""The API side of the form login — the panel in ANOTHER browser, i.e.
the out-of-band writer the self-heal test needs."""
client = httpx.Client(timeout=60.0)
r = client.post(f"{APP_URL}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login → {r.status_code}: {r.text[:120]}"
return client
def _clean_state() -> None:
"""Back to the env base: truncate the single ``ui_settings`` row (raw —
the theme row is read per request, no process cache) and clear the panel
overrides THROUGH THE API, which deletes every ``app_settings`` row AND
invalidates the app's cached ``Settings`` (a bare TRUNCATE would leave
the previous test's override in effect inside the app)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
client = _admin_client()
try:
r = client.delete(f"{APP_URL}/api/admin/settings", timeout=30)
assert r.status_code == 200, r.text[:200]
finally:
client.close()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_state()
yield
_clean_state()
with SessionLocal() as db:
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
assert db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0
def _save_panel_rows(page: Page, values: dict[str, str]) -> None:
"""Type the given catalogue rows and press Save (the phase-168 flow),
waiting for the save's own status line AND the settled dirty hint (the
transition, not just the text — a second save lands the same line)."""
page.fill("#settings-filter", "")
for key, value in values.items():
page.fill(f'tr[data-key="{key}"] .settings-control', value)
expect(page.locator("#settings-dirty")).to_have_text(
f"{len(values)} unsaved changes"
)
page.click("#settings-save")
expect(page.locator("#settings-status")).to_contain_text(
f"Saved {len(values)} settings.", timeout=30_000
)
expect(page.locator("#settings-dirty")).to_have_text("", timeout=30_000)
def _config(page: Page) -> dict[str, object]:
"""``GET /api/config`` from the browser context — the very payload
``brand.js`` applies at boot and every live re-apply reads."""
r = page.request.get(f"{APP_URL}/api/config")
assert r.status == 200, r.text()[:200]
body: dict[str, object] = r.json()
return body
def _panel_source(page: Page, key: str) -> str:
"""The catalogue's own source badge for one key (``db`` / ``env`` /
``default``) — the panel's statement of WHO owns the value."""
r = page.request.get(f"{APP_URL}/api/admin/settings")
assert r.status == 200, r.text()[:200]
for item in r.json()["items"]:
if item["key"] == key:
return str(item["source"])
raise AssertionError(f"{key} missing from the catalogue")
def _seed_legacy_tab_row(app_name: str) -> None:
"""Put a pre-phase-190 baked row in the table — the shape an old
Theme-tab save wrote (the then-effective name beside a color). The API
CANNOT write the string columns any more (that is what A1 changed), so
the legacy row is seeded directly."""
with SessionLocal() as db:
db.merge(
UiSettings(
id=1,
app_name=app_name,
input_placeholder=None,
footer_text=None,
brand=THEME_PICK,
)
)
db.commit()
def _tab_row_strings() -> dict[str, str | None]:
"""The STORED string columns of the single ``ui_settings`` row — the DB
truth, not the effective view the GET reports."""
with SessionLocal() as db:
row = db.get(UiSettings, 1)
if row is None:
return {"app_name": None, "input_placeholder": None, "footer_text": None}
return {
"app_name": row.app_name,
"input_placeholder": row.input_placeholder,
"footer_text": row.footer_text,
}
def _arm_no_reload(page: Page) -> None:
"""A document-life sentinel: any navigation or reload wipes it, so the
later equality check IS the "without a reload" proof."""
page.evaluate("() => { window.__bor190Doc = 1; }")
def _assert_same_document(page: Page) -> None:
assert page.evaluate("() => window.__bor190Doc") == 1, (
"the open document was replaced — the assertions around this one "
"would pass on a reload and prove nothing about the live apply"
)
def _expect_brand(page: Page, name: str, placeholder: str, footer: str) -> None:
"""The four DOM places the A2 contract covers, on the document that is
open RIGHT NOW (each ``expect`` retries on its own clock)."""
expect(page).to_have_title(re.compile(re.escape(name)), timeout=15_000)
expect(page.locator(".brand-text")).to_have_text(name)
expect(page.locator(".footer-text").first).to_have_text(footer)
expect(page.locator("#message-input")).to_have_attribute("placeholder", placeholder)
# ---------------------------------------------------------------------------
# 1. THE INCIDENT: a Theme-tab color save used to shadow every later
# Settings-panel rename
# ---------------------------------------------------------------------------
def test_incident_the_panel_name_survives_a_theme_tab_color_save(
page: Page, db_ready: None
) -> None:
"""The owner's sequence end to end: rename in the panel → save a color in
the Theme tab → the panel's name is STILL the effective one → rename
AGAIN and the new name is what the server serves and the next page paints
(pre-fix the FIRST rename was baked into ``ui_settings`` and shadowed the
second one forever)."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
# The starting state: no row anywhere, so the effective name is the env
# base this app booted with.
assert _config(page)["app_name"] == ENV_NAME
assert _panel_source(page, "app_name") == "env"
# 1. Set the app name in the Settings panel (the phase-168 flow).
_save_panel_rows(page, {"app_name": PANEL_ONE})
expect(page.locator('tr[data-key="app_name"] .settings-src')).to_have_text("DB")
assert _config(page)["app_name"] == PANEL_ONE, (
"the panel row must be what /api/config serves"
)
assert _panel_source(page, "app_name") == "db"
# 2. The admin opens the Theme tab, changes ONE color and saves. Pre-fix
# this PUT carried the pre-populated effective strings and baked
# PANEL_ONE into the row.
page.goto(APP_URL + "/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
page.fill(THEME_INPUT, THEME_PICK)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text("Theme saved.", timeout=30_000)
# …the panel value survives the palette save (the wire answer the brand
# layer boots from).
assert _config(page)["app_name"] == PANEL_ONE, (
"a Theme-tab color save must not take ownership of the app name"
)
# …the row it wrote carries the color and NO brand string: the string
# columns stay exactly as stored (NULL here — never baked).
assert _tab_row_strings() == {
"app_name": None,
"input_placeholder": None,
"footer_text": None,
}, f"the Theme-tab save baked the brand strings: {_tab_row_strings()}"
# …and the tab's own GET still REPORTS the effective strings, read-only
# (old-client compat): the panel's name, the env placeholder and footer.
r = page.request.get(f"{APP_URL}/api/ui-settings")
assert r.status == 200, r.text()[:200]
body = r.json()
assert body["app_name"] == PANEL_ONE
assert body["input_placeholder"] == ENV_PLACEHOLDER
assert body["footer_text"] == ENV_FOOTER
# 3. THE INCIDENT HALF: rename again in the panel. Pre-fix the baked row
# shadowed this save — the panel showed "Two", the app kept "One".
page.goto(APP_URL + "/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
_save_panel_rows(page, {"app_name": PANEL_TWO})
assert _config(page)["app_name"] == PANEL_TWO, (
"the second rename must reach /api/config — the shadow IS the incident"
)
page.goto(APP_URL + "/")
expect(page.locator(".brand-text")).to_have_text(PANEL_TWO, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(PANEL_TWO)))
# 4. The defensive half (A1): an OLD client that still PUTs the three
# string keys is accepted and changes NOTHING — the strings are
# dropped before validation and the columns are neither written nor
# cleared. Only the color moves.
stale = page.request.put(
f"{APP_URL}/api/ui-settings",
data=json.dumps(
{
THEME_FIELD: THEME_PICK_2,
"app_name": "Rebaked By An Old Client",
"input_placeholder": "Rebaked placeholder",
"footer_text": "Rebaked footer",
}
),
headers={"Content-Type": "application/json"},
)
assert stale.status == 200, stale.text()[:200]
assert _config(page)["app_name"] == PANEL_TWO, "an old client cannot re-bake the name"
assert _tab_row_strings() == {
"app_name": None,
"input_placeholder": None,
"footer_text": None,
}, "the ignored strings must not be written into the row either"
assert stale.json()["app_name"] == PANEL_TWO, (
"the response reports the EFFECTIVE strings, not the body it dropped"
)
# ---------------------------------------------------------------------------
# 2. The live apply (A2): the panel's save repaints the OPEN document
# ---------------------------------------------------------------------------
def test_panel_brand_save_repaints_the_open_document(page: Page, db_ready: None) -> None:
"""Save the brand rows in the panel and the document that is ALREADY open
repaints — the title, the header brand, the footer line and the composer
placeholder — with no reload (the phase-168 "takes effect on clicking
save" contract, extended from the catalogue to the display). A second
rename repaints again: the shared pass replaces the name the page SHOWS,
not only the template literal, or the second save would paint nothing."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
_arm_no_reload(page)
# The starting paint is the env base, on the surface that is open now.
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
# ONE batch over the panel's three brand rows.
_save_panel_rows(
page,
{
"app_name": PANEL_ONE,
"input_placeholder": PANEL_ONE_PLACEHOLDER,
"footer_text": PANEL_ONE_FOOTER,
},
)
# …the SAME document carries all of them — no navigation, no reload (the
# sentinel below is what turns "without a reload" into an assertion).
_expect_brand(page, PANEL_ONE, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == PANEL_ONE
_assert_same_document(page)
# The second rename: the page shows PANEL_ONE, so a pass that only ever
# replaced the template literal would silently paint nothing.
_save_panel_rows(page, {"app_name": PANEL_TWO})
_expect_brand(page, PANEL_TWO, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == PANEL_TWO
_assert_same_document(page)
# A FRESH document (the boot pass alone) shows the same name everywhere —
# the prose the boot pass rewrites included, with no stale env name left.
page.goto(APP_URL + "/")
expect(page.locator(".brand-text")).to_have_text(PANEL_TWO, timeout=15_000)
expect(page.locator("h1.empty-state-title")).to_have_text(f"Hey! I'm {PANEL_TWO}.")
assert ENV_NAME not in page.locator("#view-chat").inner_text(), (
"a stale env name survived the boot pass"
)
# ---------------------------------------------------------------------------
# 3. The panel's Reset IS the string fallback — and it is live too
# ---------------------------------------------------------------------------
def test_panel_row_reset_hands_the_brand_back_to_the_env_live(
page: Page, db_ready: None
) -> None:
"""``DELETE /api/admin/settings/{key}`` is how a brand string falls back
(the Theme tab's PUT never touches those columns), and the fall-back is
visible on the open document with no reload."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
# A saved override is live first (the baseline the Reset has to undo).
_save_panel_rows(
page,
{
"app_name": PANEL_ONE,
"input_placeholder": PANEL_ONE_PLACEHOLDER,
"footer_text": PANEL_ONE_FOOTER,
},
)
_arm_no_reload(page)
_expect_brand(page, PANEL_ONE, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
# Reset all three rows: each DELETE lands its own status line AND
# re-applies the brand to the open document.
for key, env_var in (
("app_name", "BOR_APP_NAME"),
("input_placeholder", "BOR_INPUT_PLACEHOLDER"),
("footer_text", "BOR_FOOTER_TEXT"),
):
page.click(f'tr[data-key="{key}"] .settings-reset')
expect(page.locator("#settings-status")).to_contain_text(
f"{env_var} is back to the environment value.", timeout=30_000
)
expect(page.locator(f'tr[data-key="{key}"] .settings-src')).to_have_text("env")
# …the open document is back on the env base, still the same document.
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == ENV_NAME
_assert_same_document(page)
assert _config(page)["app_name"] == ENV_NAME
# ---------------------------------------------------------------------------
# 4. A legacy baked row still applies (the A1 fallback), the panel row beats
# it, and the Theme tab's own settle re-reads the effective name
# ---------------------------------------------------------------------------
def test_legacy_baked_tab_row_paints_then_the_panel_row_beats_it_live(
page: Page, db_ready: None
) -> None:
"""No data migration happened (A1): a row an old Theme-tab save baked
still paints when no panel row owns the string, and stops shadowing the
moment the panel takes ownership — including onto the OPEN tab, whose
Save re-reads the effective brand while the stored row keeps its legacy
string (the tab's PUT neither rewrites nor clears those columns)."""
page.set_default_timeout(30_000)
_seed_legacy_tab_row(LEGACY_BAKED_NAME)
# The fallback branch: no panel row → the baked tab row IS the name
# (the public /api/config answer, and the catalogue's own badge: the
# panel owns nothing here).
assert _config(page)["app_name"] == LEGACY_BAKED_NAME
login(page, APP_URL, next="/theme.html")
assert _panel_source(page, "app_name") == "env"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_arm_no_reload(page)
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(LEGACY_BAKED_NAME)))
# The panel (ANOTHER browser) takes ownership of the string while this
# tab is open — the open tab cannot know until its next settle.
other = _admin_client()
try:
r = other.put(f"{APP_URL}/api/admin/settings/app_name", json={"value": PANEL_ONE})
assert r.status_code == 200, r.text
finally:
other.close()
assert _config(page)["app_name"] == PANEL_ONE, "the panel row beats the baked row"
page.wait_for_timeout(500) # nothing pulls the open tab — it stays put
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME)
# The Theme tab's Save (a color pick only — the strings are not its to
# write) re-reads the effective brand onto the OPEN document.
page.fill(THEME_INPUT, THEME_PICK_2)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text("Theme saved.", timeout=30_000)
expect(page.locator(".brand-text")).to_have_text(PANEL_ONE, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(PANEL_ONE)))
_assert_same_document(page)
# The stored row still carries the legacy string (never rewritten, never
# cleared), so the documented fallback keeps working.
assert _tab_row_strings()["app_name"] == LEGACY_BAKED_NAME
# Handing the string back to the environment (the panel's DELETE, the
# string fallback path) while the tab is open, then the tab's Reset: the
# open document re-reads the effective name — the legacy row again — and
# a palette reset NEVER blanks the brand.
other = _admin_client()
try:
r = other.delete(f"{APP_URL}/api/admin/settings/app_name")
assert r.status_code == 200, r.text
finally:
other.close()
assert _config(page)["app_name"] == LEGACY_BAKED_NAME
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(LEGACY_BAKED_NAME)))
_assert_same_document(page)
# ---------------------------------------------------------------------------
# 5. The boot path stays byte-identical: ONE /api/config request, no re-apply
# ---------------------------------------------------------------------------
def test_boot_paints_the_effective_name_with_one_config_request(
page: Page, db_ready: None
) -> None:
"""Task 03 extracted the boot DOM pass into the shared function; it must
not DOUBLE-apply. A fresh load with no save paints the effective (here:
env) name in all four places and issues EXACTLY ONE ``/api/config``
request — the boot fetch ``brand.js`` has always made, nothing more."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/")
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
config_requests: list[str] = []
def watch(request: Request) -> None:
if "/api/config" in request.url:
config_requests.append(request.url)
page.on("request", watch)
page.goto(APP_URL + "/")
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == ENV_NAME
assert page.evaluate("() => window.BOR_BRAND_APPLIED") == ENV_NAME, (
"the shared pass records what the document shows (the re-apply's "
"source string) — and the boot pass is the only one that ran"
)
page.wait_for_timeout(500) # any second, stray fetch would have landed
assert config_requests == [f"{APP_URL}/api/config"], (
f"the boot is ONE /api/config request, got {config_requests}"
)
# ---------------------------------------------------------------------------
# 6. The Theme tab is a COLORS surface (the task-02 strip)
# ---------------------------------------------------------------------------
def test_theme_tab_dom_is_colors_only(page: Page, db_ready: None) -> None:
"""The read half of the single-writer fix, in the isolated suite: the tab
renders the 17 color pickers and NONE of the three string inputs — so no
save can pre-populate an effective name and bake it back — and its Save
posts a body with no string key."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
expect(page.locator('#view-theme input[type="color"]')).to_have_count(17)
# The body the Save posts carries the colors only — no string key.
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
page.fill(THEME_INPUT, THEME_PICK)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert "app_name" not in body
assert "input_placeholder" not in body
assert "footer_text" not in body
assert body.get(THEME_FIELD) == THEME_PICK
+42 -44
View File
@@ -145,14 +145,9 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
# No saved-string fixture since phase 190 (A1): the admin Settings
# panel owns the brand strings, so this surface saves COLORS only and
# every string assertion reads the env/template default.
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
@@ -234,17 +229,23 @@ def _template_defaults() -> dict[str, str]:
}
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate is
the last visible step — the app-name input carries the effective
default (the static markup ships it empty, so only a settled
``loadSettings`` can have written it). From here on, no in-flight
mount load can race the test's own save/reset refetch (an
out-of-order settle would re-reconcile the open document onto a
stale read)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
Phase 190 (task 02) moved it: the settle used to be read off the
``#theme-app-name`` input (the only input the static markup ships
empty, so a value in it proved ``populate`` had run) — that input is
gone with the tab's string fieldset, so the network is the source of
truth now. From here on, no in-flight mount load can race the test's
own save/reset refetch (an out-of-order settle would re-reconcile the
open document onto a stale read)."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]:
@@ -476,12 +477,20 @@ def _cookies(page: Page) -> dict[str, str]:
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save path is this module's own tests' job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings — and the response keeps reporting the effective
(env/template) strings, so a palette seed cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -506,18 +515,9 @@ def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
@@ -545,16 +545,14 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
# The mount's initial load has SETTLED inside this helper (the
# row-less state — no tag, no overrides) before the test touches
# the form: no in-flight load can race the save's own refetch.
_login_to_theme(page, app_url)
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The mount's initial load has settled (the row-less state — no
# tag, no overrides) before the test touches the form: no
# in-flight load can race the save's own refetch.
_wait_mount_settled(page)
# Fill the 20 inputs (the 3 strings + the 17-color palette) and Save
# through the real form — the PUT held so the §7.4 in-flight state
# Fill the 17 color inputs and Save through the real form — the PUT
# held so the §7.4 in-flight state
# is observable deterministically (the same lifecycle assertions
# as the phase-91 suite — the save's contract is unchanged).
_fill_theme_form(page, PALETTE)
+10 -7
View File
@@ -467,11 +467,11 @@ def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) ->
# feed the PUT body, the live preview, and the refetch).
for field in COLOR_FIELDS:
page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
# The 3 strings stay default (untouched — they hold the effective
# template defaults the module server pins).
# The tab has no string inputs (phase 190, A1) — the 3 brand
# strings are the Settings panel's, so nothing here can touch them.
# Capture the PUT body the form builds (the wiring proof: all 17
# grays ride the wire together with the 3 default strings).
# Capture the PUT body the form builds (the wiring proof: the 17
# grays ride the wire and NOTHING else — no string key, phase 190).
captured: dict[str, Any] = {}
def handle(route: Route) -> None:
@@ -498,11 +498,14 @@ def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) ->
# hidden (it re-checks the saved state on the save's refetch).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The PUT carried every field: the 17 grays + the 3 default strings
# (the strings were never touched, so they ride at their defaults).
# The PUT carried the 17 grays and NOTHING else (phase 190, A1:
# the brand strings left the tab's body — an old client's save can
# no longer re-bake one and shadow the Settings panel).
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert body == {**GRAY, **defaults}, f"unexpected PUT body: {body}"
assert body == GRAY, f"unexpected PUT body: {body}"
for field in defaults:
assert field not in body, f"{field} must never ride the tab's PUT body"
# The inputs re-populate to the SAVED (effective) grays (the
# save's refetch is the canonical state)…
+21 -34
View File
@@ -176,26 +176,20 @@ def _parse_roles(svg: str) -> dict[str, str]:
}
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from
the class fields — never drifts from ``app/config.py``; the
module server pins the same values, so the effective strings
start exactly here)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate
is the last visible step — the app-name input carries the
effective default (the static markup ships it empty, so only a
settled ``loadSettings`` can have written it)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
Phase 190 (task 02) moved it with the tab's string fieldset: the
settle used to be read off the (now removed) ``#theme-app-name``
input, the only input the static markup shipped empty. The network
is the source of truth now."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
def _favicon_href(page: Page) -> str:
@@ -519,9 +513,7 @@ def test_live_preview_repaints_the_favicon(
a non-driving pick (``#theme-bg``) never touches the href."""
page.set_default_timeout(30_000)
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A clean start: the href is the route (no row → the golden
# art served by the route).
@@ -568,9 +560,7 @@ def test_save_restores_the_route_url(
re-reads the DB per request, no process cache)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A live pick first — so the save has a data: href to restore.
for field in ("surface", "brand", "brand_ink"):
@@ -588,9 +578,10 @@ def test_save_restores_the_route_url(
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: the route serves the saved
# palette's bytes (the PUT saved all 20 form values — the 3
# driving colors + the 14 built-ins (→ NULL) + the 3 template
# strings; the effective palette is the 3 + the built-ins).
# palette's bytes (the PUT saved the 17 form colors — the 3
# driving colors + the 14 built-ins (→ NULL); the brand strings are
# the Settings panel's since phase 190 (A1) and this body carried
# none, so the effective palette is the 3 + the built-ins).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
@@ -614,9 +605,7 @@ def test_reset_restores_the_route_url(
(the reset nulled the row — the built-ins govern)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# Save first — the route is themed (the reset then has a real
# state to settle back from).
@@ -672,9 +661,7 @@ def test_view_refresh_restores_the_route_url(
golden bytes)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A live pick — the href is the data: one the re-show restores.
for field in ("surface", "brand", "brand_ink"):
+136 -24
View File
@@ -44,6 +44,28 @@ database returned them, so the comparison measures the index. Setup stays inside
generate ≈ 0.5 s, seed ≈ 2 s, ``CREATE INDEX CONCURRENTLY`` ≈ 1.4 s, read-back +
exact top-100 ≈ 3.5 s — ≈ 8 s against the ~60 s target.
**The determinism premise (measured while fixing the 2026-10-18 flake).** A
plan is a cost decision, and two things outside this module's control move it
on a shared dev DB: the statistics of BOTH relations the tier's SELECT joins
(the fixture used to ``ANALYZE`` only ``chunks``, so the join side could carry
an estimate from whatever ran before it) and the ``embedding IS NULL`` fraction
of ``chunks`` — a NULL-embedding row is a candidate the index scan must skip
before the LIMIT fills, so ambient NULL rows inflate the estimated index cost.
Measured on this corpus with ambient NULL-embedding rows inserted alongside the
5,000 fixture rows: at 4,000 of them the UNFORCED planner leaves
``ix_chunks_embedding_hnsw`` for ``Sort → Hash Join → Seq Scan on chunks`` with
the index present and valid (at 2,000 and at 18,000 it uses it again — the band
moves with the statistics, which is exactly what makes it a flake rather than a
finding). Therefore: the fixture analyzes both joined relations, asserts the
index is present and VALID before any plan is read, and the index assertions run
with the alternatives that can disguise a scan refused
(``enable_seqscan`` AND ``enable_bitmapscan`` — verified across the pollution
levels above, and verified to still report ``False`` with the index dropped, so
the pin cannot pass on a missing index). The UNFORCED preference — the shape of
the 2026-10-15 incident — is asserted when the relation holds nothing but the
fixture corpus, which is the case a clean CI DB and this file's own teardown
leave.
**What 5,000 rows proves and what it does not.** It is enough for a PLAN pin
(the planner needs a populated relation to prefer an ordered index scan over a
seq scan + top-N sort) and enough for a deterministic index-vs-exact
@@ -326,6 +348,23 @@ def _plan(session: Session, sql: str) -> list[dict[str, Any]]:
return nodes
def _index_validity(session: Session) -> bool | None:
"""``pg_index.indisvalid`` for :data:`INDEX_NAME` (``None`` = absent) — the
catalog fact behind every plan assertion here, so a failure names the
missing/INVALID index instead of a plan shape."""
value = session.execute(
text(
"SELECT i.indisvalid"
" FROM pg_index i"
" JOIN pg_class c ON c.oid = i.indexrelid"
" WHERE c.relname = :name"
),
{"name": INDEX_NAME},
).scalar()
session.rollback()
return None if value is None else bool(value)
def _describe(nodes: list[dict[str, Any]]) -> str:
return ", ".join(
f"{node.get('Node Type')}[{node.get('Index Name') or node.get('Relation Name') or '?'}]"
@@ -369,7 +408,12 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
config = Config() # no alembic.ini — env.py reads the URL from app config
config.set_main_option("script_location", "alembic")
try:
preexisting = session.execute(text(f"SELECT count(*) FROM {RELATION}")).scalar() or 0
# `preexisting` is the relation's own state (the skip guard below);
# its EMBEDDED half is what the exact reference can score, and the
# pristine flag is the premise of the unforced-plan arm of pin 1.
preexisting, preexisting_embedded = session.execute(
text(f"SELECT count(*), count(embedding) FROM {RELATION}")
).one()
if preexisting > MAX_RELATION_ROWS:
pytest.skip(
f"{RELATION} holds {preexisting} rows: the exact reference is a Python "
@@ -384,15 +428,34 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
session.rollback()
command.upgrade(config, "0029") # the ONE definition of the index
session.rollback()
# Planner statistics for the plan pin: a freshly loaded relation has
# none, and a row estimate of 0 makes "seq scan + top-N sort" look free.
# The premise of every pin below, checked where it is established: an
# INVALID remnant (phase 188 D2) or a drop by whatever else owns the
# schema on this box must report ITSELF, not surface three tests later
# as "the plan did not name the index".
validity = _index_validity(session)
assert validity is True, (
f"{INDEX_NAME} is {validity!r} after migration 0029 "
"(None = absent, False = the INVALID remnant D2 describes) — every "
"plan assertion below would be measuring a scan"
)
# Planner statistics for the plan pins — BOTH relations the tier's
# SELECT joins, because the plan is a JOIN plan and a stale estimate on
# either side flips it (see the module docstring). A freshly loaded
# relation has none, and a row estimate of 0 makes
# "seq scan + top-N sort" look free.
session.execute(text(f"ANALYZE {RELATION}"))
session.execute(text("ANALYZE documents"))
session.commit()
rows = session.execute(select(Chunk.id, Chunk.embedding)).all()
# The SAME predicate the tier applies: a NULL-embedding row is in
# neither the index nor the exact reference (a zero vector would score
# as a real candidate and land in no result set).
rows = session.execute(
select(Chunk.id, Chunk.embedding).where(Chunk.embedding.is_not(None))
).all()
stored: dict[Any, list[float]] = {row[0]: row[1] or [] for row in rows}
assert len(stored) >= preexisting + CORPUS_CHUNKS, (
f"expected {CORPUS_CHUNKS} fixture rows on top of {preexisting}, "
f"the relation holds {len(stored)}"
assert len(stored) >= preexisting_embedded + CORPUS_CHUNKS, (
f"expected {CORPUS_CHUNKS} fixture rows on top of "
f"{preexisting_embedded} embedded rows, the relation holds {len(stored)}"
)
# The exact sets are the expensive half (30 × N dot products); the two
# recall pins differ only in the ef_search they ask the index for.
@@ -401,6 +464,9 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
"exact": [_exact_top(stored, query) for query in queries],
"chunk_ids": set(stored),
"plan_sql": _vector_statement_sql(),
# The unforced-planner assertion (pin 1) is meaningful only on a
# relation this module owns — see the determinism premise above.
"pristine": preexisting == 0,
}
finally:
session.rollback()
@@ -437,14 +503,27 @@ def _force_index_scan(session: Session) -> None:
caller's transaction instead of reaching the tier. Every caller resets it in
a ``finally`` — a leaked ``off`` would slow every later test on this pooled
connection — and the assertion beside it proves the lever took effect.
``enable_bitmapscan`` goes off with it because refusing sequential scans
alone does not refuse a scan: measured on the 2026-10-18 flake, the planner
answered ``enable_seqscan = off`` with
``Sort → Nested Loop → Index Scan on documents → Bitmap Heap Scan on chunks
(ix_chunks_document_id)`` — every chunk row read and sorted through the
btree on ``document_id``, the very full read these pins exist to rule out,
wearing an index's name. With both levers off the only access path left for
``chunks`` that can serve the ORDER BY is the HNSW graph; with the index
dropped the same arm reports ``False`` (verified), so the guard cannot pass
on a missing index.
"""
session.rollback()
session.execute(text("SET enable_seqscan = off"))
session.execute(text("SET enable_bitmapscan = off"))
def _release_index_scan(session: Session) -> None:
session.rollback()
session.execute(text("RESET enable_seqscan"))
session.execute(text("RESET enable_bitmapscan"))
session.rollback()
@@ -467,34 +546,66 @@ def test_plan_uses_the_hnsw_index(db: Session, hnsw_corpus: dict[str, Any]) -> N
Explaining the captured statement is also what proves D1's "no query
rewrite": the ``<=>`` the retriever already emits is exactly the operator
``vector_cosine_ops`` serves.
Two arms, because the unforced one is a cost decision this file does not
own alone (module docstring, "The determinism premise"): the DEFAULT plan is
asserted while the relation holds nothing but the fixture corpus (a clean CI
DB, and this module's own teardown), and the FORCED plan — sequential and
bitmap scans refused, so nothing that reads every row can be chosen — is
asserted always.
"""
nodes = _plan(db, hnsw_corpus["plan_sql"])
assert _uses_index(nodes, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan — the incident is back: [{_describe(nodes)}]"
)
assert not _seq_scans(nodes, RELATION), (
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
)
db.rollback()
sql = hnsw_corpus["plan_sql"]
if hnsw_corpus["pristine"]:
nodes = _plan(db, sql)
assert _uses_index(nodes, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan — the incident is back "
f"(index validity: {_index_validity(db)}): [{_describe(nodes)}]"
)
assert not _seq_scans(nodes, RELATION), (
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
)
_force_index_scan(db)
try:
forced = _plan(db, sql)
assert _uses_index(forced, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan even with sequential and bitmap "
f"scans refused (index validity: {_index_validity(db)}): "
f"[{_describe(forced)}]"
)
assert not _seq_scans(forced, RELATION), (
f"a sequential scan of {RELATION} survived the index: "
f"[{_describe(forced)}]"
)
finally:
_release_index_scan(db)
db.rollback()
def test_plan_without_the_index_seq_scans_the_relation(
db: Session, hnsw_corpus: dict[str, Any]
) -> None:
"""Vacuity guard: the same statement, same session, same statistics, with
index scans refused, MUST fall back to ``Seq Scan on chunks``.
the index access paths refused, MUST fall back to ``Seq Scan on chunks``.
``SET LOCAL enable_indexscan = off`` rather than dropping the index: a
``DROP INDEX CONCURRENTLY`` + rebuild would cost a second graph build and
leave the corpus unindexed if the rebuild failed, while this GUC is
transaction-scoped (it dies with the rollback) and removes exactly the
access path under test. Without this test the plan pin would pass vacuously
on any plan that merely mentions some index.
``SET LOCAL enable_indexscan/indexonlyscan/bitmapscan = off`` rather than
dropping the index: a ``DROP INDEX CONCURRENTLY`` + rebuild would cost a
second graph build and leave the corpus unindexed if the rebuild failed,
while these GUCs are transaction-scoped (they die with the rollback) and
together remove every access path that reaches an index — including the
bitmap path into ``ix_chunks_document_id``, which alone would let this guard
answer with a disguised full read instead of the seq scan it asserts.
Without this test the plan pin would pass vacuously on any plan that merely
mentions some index.
"""
sql = hnsw_corpus["plan_sql"]
db.rollback()
db.execute(text("SET LOCAL enable_indexscan = off"))
db.execute(text("SET LOCAL enable_indexonlyscan = off"))
# Bitmap scans go too: they are the other way into ``ix_chunks_document_id``,
# so leaving them on let this guard depend on the planner's mood instead of
# on the access path it removes (the 2026-10-18 flake answer was a Bitmap
# Heap Scan of chunks and no Seq Scan at all).
db.execute(text("SET LOCAL enable_bitmapscan = off"))
nodes = _plan(db, sql)
assert _seq_scans(nodes, RELATION), (
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
@@ -534,8 +645,9 @@ def test_top100_recall_at_the_shipped_ef_search(
depth_nodes = _plan(db, hnsw_corpus["plan_sql"])
assert _uses_index(depth_nodes, INDEX_NAME), (
f"this recall run at ef_search={EF_PIN} did not use {INDEX_NAME} even "
"with sequential scans refused — the pin would be measuring a scan "
f"and calling it parity: [{_describe(depth_nodes)}]"
"with sequential AND bitmap scans refused — the pin would be "
"measuring a scan and calling it parity (index validity: "
f"{_index_validity(db)}): [{_describe(depth_nodes)}]"
)
overlaps: list[float] = []
+214 -23
View File
@@ -17,6 +17,21 @@ The auth + public-contract half of task 01, driven through the real app
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
and the two effective strings are the entire response.
Phase 190 (tasks 01 + 02) adds the brand-string OWNERSHIP pins (A1): the
Settings-panel row (``app_settings``, phase 168) beats a conflicting
``ui_settings`` (Theme-tab) row — the ``PUT /api/ui-settings`` string
bake is dead (the PUT ignores string fields in the body and never
writes the string columns), the GET reports the EFFECTIVE (panel)
strings read-only, and the anonymous ``/api/config`` boot fetch serves
the panel's name over a legacy baked-in tab row. Task 01 pinned those
three as documented-RED ``xfail(strict=True)`` cases; task 02 flipped
the resolution order and stopped the bake, so the markers are gone and
all three are GREEN. The colors half of the PUT contract stayed GREEN
throughout (the 17 colors, their normalization and the upsert behave
exactly as phases 91/92/93 left them); the STRINGS half of the older
phase-91 pins moved with the write path — a body string no longer
round-trips, it is ignored.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
@@ -30,12 +45,30 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.config import get_settings, invalidate_settings
from app.core import theming
from app.main import app as fastapi_app
from app.models import UiSettings
from app.models import AppSetting, UiSettings
from tests.conftest import ADMIN_PASSWORD
#: The three brand strings and their distinct panel/tab/body values
#: (phase 190) — one triple per field so every pin reads as one story.
_PANEL_STRINGS = {
"app_name": "Panel Name",
"input_placeholder": "Panel placeholder…",
"footer_text": "Panel footer",
}
_TAB_STRINGS = {
"app_name": "Tab Name",
"input_placeholder": "Tab placeholder…",
"footer_text": "Tab footer",
}
_BODY_STRINGS = {
"app_name": "Baked By PUT",
"input_placeholder": "Baked placeholder…",
"footer_text": "Baked footer",
}
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
@@ -60,6 +93,44 @@ def _admin_client() -> TestClient:
return c
@pytest.fixture()
def overlay_panel(db: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Phase 190: the settings overlay ON with an empty ``app_settings``
table (the ``test_admin_settings_api`` idiom) — the Settings panel's
write path is only IN EFFECT through the overlay, so the pins that
plant panel rows need the switch on. Teardown deletes the rows and
re-resolves the cache — NO row and no stale resolution may survive
this module (conftest pins the overlay OFF for every other suite)."""
db.execute(text("DELETE FROM app_settings"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
invalidate_settings()
yield
db.execute(text("DELETE FROM app_settings"))
db.commit()
invalidate_settings()
get_settings.cache_clear()
def _seed_tab_row(db: Session, **colors: str) -> None:
"""The single ``ui_settings`` row with the three TAB strings, plus
any colors the pin needs — committed directly (after task 02 the
API no longer writes strings, so the seed must not depend on the
behavior under test)."""
db.add(UiSettings(id=1, **_TAB_STRINGS, **colors))
db.commit()
def _seed_panel_rows(db: Session) -> None:
"""The three Settings-panel rows, committed (the panel writes them
through ``PUT /api/admin/settings/{key}`` — the resolver only ever
sees the table), then the settings cache is cleared so the next
resolution layers them over the env base."""
db.add_all(AppSetting(key=key, value=value) for key, value in _PANEL_STRINGS.items())
db.commit()
invalidate_settings()
def test_anonymous_get_and_put_403(client: TestClient) -> None:
"""Router-level ``require_admin``: both routes are 403 ``admin only``
for the unsigned-in caller (one fixed detail — no enumeration)."""
@@ -94,7 +165,10 @@ def test_token_user_get_and_put_403(client: TestClient) -> None:
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
"""The admin passes the gate on both methods: GET reports the
effective defaults (row missing), PUT persists + reports the new
effective values, and a follow-up GET reads them back."""
effective COLORS, and a follow-up GET reads them back. Phase 190
(A1): the PUT body carries a brand string and the response reports
the ENV one instead — the string is ignored, the palette half of the
phase-91 round trip is unchanged."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = client.get("/api/ui-settings")
@@ -109,12 +183,12 @@ def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
json={"app_name": "Reese Brain", "brand": "#818cf8"},
)
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "Reese Brain"
assert r.json()["app_name"] == get_settings().app_name # the string is ignored
assert r.json()["brand"] == "#818cf8"
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == "Reese Brain"
assert r.json()["app_name"] == get_settings().app_name # never stored (A1)
assert r.json()["brand"] == "#818cf8"
# Untouched fields stay at their defaults (DB-over-env / -built-in).
assert r.json()["footer_text"] == get_settings().footer_text
@@ -248,31 +322,148 @@ def test_api_config_carries_no_theme_key(client: TestClient) -> None:
def test_api_config_returns_db_strings_after_admin_put(
client: TestClient, db: Session
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
(the frontend's boot fetch — no admin needed) serves the DB strings
over the env values; the untouched fields keep the env values; the
five-key set is unchanged (the retired ``theme`` key is absent)."""
admin = _admin_client()
r = admin.put(
"/api/ui-settings",
json={
"app_name": "Brain of Testy",
"input_placeholder": "Ask the vault…",
"footer_text": "Powered by my own models",
},
)
assert r.status_code == 200, r.text
"""B1 with a set row: once the admin has saved the three brand
strings, the ANONYMOUS /api/config (the frontend's boot fetch — no
admin needed) serves them over the env values; the untouched fields
keep the env values; the six-key set is unchanged (the retired
``theme`` key is absent).
Phase 190 (A1) moved the writer: the strings are seeded through the
Settings panel's surface (``app_settings`` + the overlay, the phase
168 ``PUT /api/admin/settings/{key}`` rows), because the Theme-tab
PUT no longer writes them — the DB-over-env contract this pin owns
is the same one, now owned by the panel."""
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == "Brain of Testy"
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
# The colors never ride /api/config (the pre-paint injection is
# task 02; brand.js's surface is the five keys).
assert "theme" not in body
assert body["version"] == get_settings().app_version
# ---------- phase 190 (task 01): the brand-string ownership (A1) ----------
def test_put_ignores_string_fields_in_body(
client: TestClient, db: Session
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Theme-tab PUT no longer
writes the three brand strings — a body carrying ``app_name`` /
``input_placeholder`` / ``footer_text`` DIFFERENT from the stored
row's values (and from the effective value) is still 200, but the
stored row's string columns stay EXACTLY as they were (re-GET shows
the old values, a direct row read shows no write). An old client's
save can therefore never re-bake a string and shadow the Settings
panel again.
GREEN since task 02 (phase 190 task 01 pinned it as a strict
``xfail`` on the defect: ``_validate_strings`` used to trim and
store every string in the body — that bake is the mechanism of the
``TODO.md`` L3 incident).
"""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
# The strings half — pre-fix the response round-trips the baked
# body value ("Baked By PUT"); post-fix the stored value stands.
assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
assert r.json()["input_placeholder"] == _TAB_STRINGS["input_placeholder"]
assert r.json()["footer_text"] == _TAB_STRINGS["footer_text"]
# The stored row itself was never touched (not just the response).
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.app_name == _TAB_STRINGS["app_name"]
assert row.input_placeholder == _TAB_STRINGS["input_placeholder"]
assert row.footer_text == _TAB_STRINGS["footer_text"]
# A follow-up GET shows the OLD values, not the body's.
r = admin.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
def test_put_still_stores_the_colors_beside_the_ignored_strings(
client: TestClient, db: Session
) -> None:
"""The colors half of the same contract — GREEN pre-phase AND
post-phase: the 17 colors in the same body as the ignored strings
ARE stored and reported (the Theme tab stays a colors surface; the
string drop does not narrow the palette half by one color)."""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
assert r.json()["bg"] == "#001122" # GREEN pre-fix and post-fix
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.bg == "#001122" # GREEN pre-fix and post-fix
assert row.brand is None # an unsent color stays NULL (the built-in reports)
r = admin.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["bg"] == "#001122"
def test_get_reports_effective_strings_read_only(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""A1 reaching the API: with a conflicting Settings-panel row,
``GET /api/ui-settings`` reports the EFFECTIVE (panel) value for
each string — the tab's read side stays the live view (old-client
compat), and it stays READ-ONLY (the GET never writes the row).
GREEN since task 02 flipped the order (phase 190 task 01 pinned it
as a strict ``xfail``: the endpoint used to report the baked
``ui_settings`` value).
"""
_seed_tab_row(db)
_seed_panel_rows(db)
admin = _admin_client()
r = admin.get("/api/ui-settings")
assert r.status_code == 200, r.text
body = r.json()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
# The 20-value shape is unchanged (17 colors + 3 strings).
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
# Read-only: the GET never wrote the effective values into the row.
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.app_name == _TAB_STRINGS["app_name"]
def test_api_config_reports_panel_strings_over_the_baked_tab_row(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""The owner-visible incident through the frontend's boot fetch
(phase 190, ``TODO.md`` L3): with a panel row AND a legacy baked-in
``ui_settings`` row, the ANONYMOUS ``GET /api/config`` — the very
call ``brand.js`` drives the title / brand / footer / placeholder
from — serves the Settings-panel strings, not the baked tab values.
GREEN since task 02 (phase 190 task 01 pinned it as a strict
``xfail``: pre-fix ``effective_settings`` resolved the tab row
first, so the open page kept the OLD name after every later panel
change — the incident as the browser sees it).
"""
_seed_tab_row(db)
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # public, unchanged key set
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
+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