phase: 190_app_name_settings_fix
All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user