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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)…
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user