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
+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"]