phase: 190_app_name_settings_fix
All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 39 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 117 0 100%
|
||||
app/core/theming.py 45 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6941 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
**Task 01 complete — both defects pinned with failing tests.**
|
||||
|
||||
**Implemented**
|
||||
- `tests/unit/test_theming.py`: new A1 ownership pins — `test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row` (**RED**, seeds real `app_settings` panel rows + conflicting `ui_settings` tab row, all 3 strings; fails `'Tab Name' == 'Panel Name'` — the exact shadow) and `test_effective_strings_fall_back_to_ui_settings_row_without_panel_row` (**GREEN now & post-fix** — protects the A1 fallback, rejects a naive "env beats tab" fix); helpers `_panel_settings`/`_seed_panel_rows`/`_clear_panel_rows`, docstring updated.
|
||||
- `tests/integration/test_ui_settings_api.py`: `overlay_panel` fixture (overlay ON, `app_settings` wiped before/after, cache invalidated — the phase-168 idiom) + `test_put_ignores_string_fields_in_body` (**RED** — the string bake), `test_put_still_stores_the_colors_beside_the_ignored_strings` (**GREEN pre & post** — colors half), `test_get_reports_effective_strings_read_only` (**RED**), `test_api_config_reports_panel_strings_over_the_baked_tab_row` (**RED** — the owner-visible incident through the boot fetch).
|
||||
- `tests/unit/test_brand_live.py` (NEW): marker pins on the ABSENT live-apply — `applyBrandFromConfig` absent from settings.js settle paths (documented **inverted in task 03**), brand DOM tokens never owned by settings.js/theme.js save paths (stay green through the phase); theme.js save/reset pinned colors-only (`applyServedTheme`).
|
||||
|
||||
**Results (exact)**
|
||||
- `uv run pytest tests/unit/test_theming.py tests/integration/test_ui_settings_api.py tests/unit/test_brand_live.py -v` → exactly the 4 documented RED pins fail with the shadow/bake assertions; everything else green (fallback + colors-half + all 3 brand-live marker pins pass).
|
||||
- `uv run pytest` (full suite) → **4 failed, 4614 passed** — the 4 failures are solely the documented RED pins (the task's artifact; task 02 turns them green).
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → `TOTAL 6946 16 99%` (>90%).
|
||||
- `uv run ruff check .` → clean; `uv run pyright` (changed files) → 0 errors.
|
||||
|
||||
**Docs**: no doc impact — tests only; `app/` and `frontend/` untouched (app code is task 02's scope).
|
||||
|
||||
**Notable**: unit red pin seeds BOTH representations (real `app_settings` rows + overlay-resolved `Settings`) so any correct task-02 design (session read or overlay read) satisfies it, while the fallback pin forbids the incorrect "settings-param beats tab row" shortcut. No live-infra touched.
|
||||
|
||||
**Next pending task**: `190_app_name_settings_fix/02_precedence_and_strip_theme_tab.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
.........F.............................................................. [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
........................................................................ [ 99%]
|
||||
......F.FF [100%]
|
||||
=================================== FAILURES ===================================
|
||||
___ test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row ____
|
||||
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7f2c5415d040>
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f2c70c40130>
|
||||
|
||||
def test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A1 (owner-confirmed 2026-10-18): the Settings panel is the SINGLE
|
||||
writable owner of the three brand strings, so an ``app_settings``
|
||||
row beats a conflicting ``ui_settings`` (Theme-tab) row for EVERY
|
||||
one of them — the resolution order is panel row → tab row → env.
|
||||
|
||||
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
|
||||
today ``effective_settings`` returns the ``ui_settings`` value
|
||||
whenever it is a non-empty string, so a Theme-tab save BAKES the
|
||||
then-effective name into the row and every later Settings-panel
|
||||
change is SHADOWED — the exact ``TODO.md`` L3 incident ("changing
|
||||
the App name in settings broke, it no longer updates the name").
|
||||
"""
|
||||
_start_row_missing(db)
|
||||
_clear_panel_rows(db)
|
||||
_seed_panel_rows(db)
|
||||
db.add(
|
||||
UiSettings(
|
||||
id=1,
|
||||
app_name="Tab Name",
|
||||
input_placeholder="Tab placeholder…",
|
||||
footer_text="Tab footer",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # the overlay is the panel's mechanism
|
||||
try:
|
||||
effective = theming.effective_settings(db, _panel_settings())
|
||||
> assert effective["app_name"] == "Panel Name" # pre-fix: "Tab Name" (the shadow)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
E AssertionError: assert 'Tab Name' == 'Panel Name'
|
||||
E
|
||||
E - Panel Name
|
||||
E + Tab Name
|
||||
|
||||
tests/unit/test_theming.py:282: AssertionError
|
||||
____________________ test_put_ignores_string_fields_in_body ____________________
|
||||
|
||||
client = <starlette.testclient.TestClient object at 0x7f2a2ba9c210>
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7f2a29ee63f0>
|
||||
|
||||
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.
|
||||
|
||||
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
|
||||
today ``_validate_strings`` trims and stores every string in the
|
||||
body — this 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"]
|
||||
E AssertionError: assert 'Baked By PUT' == 'Tab Name'
|
||||
E
|
||||
E - Tab Name
|
||||
E + Baked By PUT
|
||||
|
||||
tests/integration/test_ui_settings_api.py:372: AssertionError
|
||||
_________________ test_get_reports_effective_strings_read_only _________________
|
||||
|
||||
client = <starlette.testclient.TestClient object at 0x7f2a2bf2ba10>
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7f2a29ed05f0>
|
||||
overlay_panel = None
|
||||
|
||||
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).
|
||||
|
||||
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
|
||||
today the endpoint reports 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"]
|
||||
E AssertionError: assert 'Tab Name' == 'Panel Name'
|
||||
E
|
||||
E - Panel Name
|
||||
E + Tab Name
|
||||
|
||||
tests/integration/test_ui_settings_api.py:427: AssertionError
|
||||
_________ test_api_config_reports_panel_strings_over_the_baked_tab_row _________
|
||||
|
||||
client = <starlette.testclient.TestClient object at 0x7f2a387682f0>
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7f2a29ed3e30>
|
||||
overlay_panel = None
|
||||
|
||||
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.
|
||||
|
||||
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
|
||||
pre-fix ``effective_settings`` resolves the tab row first, so the
|
||||
open page keeps the OLD name after every later panel change.
|
||||
"""
|
||||
_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"]
|
||||
E AssertionError: assert 'Tab Name' == 'Panel Name'
|
||||
E
|
||||
E - Panel Name
|
||||
E + Tab Name
|
||||
|
||||
tests/integration/test_ui_settings_api.py:458: AssertionError
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
=========================== short test summary info ============================
|
||||
FAILED tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row
|
||||
FAILED tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body
|
||||
FAILED tests/integration/test_ui_settings_api.py::test_get_reports_effective_strings_read_only
|
||||
FAILED tests/integration/test_ui_settings_api.py::test_api_config_reports_panel_strings_over_the_baked_tab_row
|
||||
tests FAILED: unit + integration suite did not pass
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation FAILED (see output above)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Retry complete — the 4 defect pins are now `xfail(strict=True)`, so the full gate is green while both defects stay pinned.**
|
||||
|
||||
**Fixed since rejection**
|
||||
- Kept all task-01 pins intact, but carried the 4 RED pins (`tests/unit/test_theming.py` ownership pin; `tests/integration/test_ui_settings_api.py` string-bake, effective-GET, /api-config incident pins) under `@pytest.mark.xfail(strict=True, reason="phase 190 task 01 defect pin (A1)…")` — the harness gate requires an exit-0 suite; strict xfail keeps the defect proven failing AND turns XPASS into a hard failure the moment task 02 fixes `effective_settings`/the PUT, forcing marker deletion in the fix commit. No `app/` or frontend code touched (task 02's scope).
|
||||
- Docstrings/module docs updated to the xfail convention; fixed one E501 introduced while re-wrapping.
|
||||
|
||||
**Results (exact)**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **exit 0 — 4614 passed, 4 xfailed**, `TOTAL 6946 16 99%` (>90%).
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- Targeted: `pytest tests/unit/test_theming.py tests/integration/test_ui_settings_api.py tests/unit/test_brand_live.py` → exit 0, the 4 pins report `x` (expected-fail with the exact `'Tab Name' == 'Panel Name'` / bake assertions), fallback + colors-half + brand-live marker pins pass.
|
||||
|
||||
**Docs**: no doc impact (tests-only change; no behavior/setup change).
|
||||
|
||||
**Notable**: the phase's "failing pins are the artifact" convention conflicts with this harness's 100%-green gate; strict-xfail satisfies both — pinned defect, green gate, mechanical inversion in task 02. No live-infra touched.
|
||||
|
||||
**Next pending task**: `190_app_name_settings_fix/02_precedence_and_strip_theme_tab.md` (flip the resolution order + strip the Theme-tab strings, then delete the four xfail markers).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
.........x.............................................................. [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
........................................................................ [ 99%]
|
||||
......x.xx [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
**Task 02 complete — panel wins, Theme tab is colors-only.**
|
||||
|
||||
- `app/core/settings_overlay.py`: new `applied_override_keys(session)` — one `SELECT key FROM app_settings` on the caller's session (kill-switch short-circuit, orphan/LOCKED-A3 rows excluded); **deviation** from the task's "cached `has_override(field)`" wording: a cross-request memo goes stale on a direct row write and can re-shadow a just-saved value, and the overlay's `NullPool` would cost a fresh connection per page serve (the resolver reads the map ONCE, so still no query per field).
|
||||
- `app/core/theming.py`: `effective_settings` strings now resolve **panel row → tab row → env** (presence, never a value compare); the 17-color half untouched; docstrings rewritten.
|
||||
- `app/api/ui_settings.py`: PUT persists the 17 colors only — `_validate_strings`/`_MAX_STRING_LEN` deleted, body strings accepted-and-dropped (old-client compat), stored string columns never written or cleared; GET/DELETE unchanged; `models.py` / `schemas.py` / `config.py` docs updated (verified: `/api/config` has no `source` vocabulary → nothing to grow).
|
||||
- `frontend`: `theme.js` `FIELDS` = 17 colors (no `kind:"string"`), `collectBody` posts no string key, docstrings now "17 colors"; `index.html` Branding fieldset (`#theme-app-name`/`#theme-placeholder`/`#theme-footer`) removed + copy points at Settings; `router.js` theme description.
|
||||
- Tests: removed the 4 task-01 `xfail(strict=True)` markers (now green); new unit pins — per-field panel/tab/env + env-tie, kill-switch-off, `applied_override_keys` filter/no-read, `collectBody` no-string marker; new E2E `test_tab_renders_the_17_colors_and_no_string_inputs` (string inputs count 0, PUT body == the 17 colors); retargeted the phase-91 string pins (`test_ui_settings.py`, `test_ui_settings_api.py`, `test_frontend_router.py`, `test_theme_frontend.py`) and the 4 theme E2E suites (settle signal moved from `#theme-app-name` to the mount `GET /api/ui-settings` response).
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4623 passed**, TOTAL 99%; touched files 100% (`api/config.py`, `api/ui_settings.py`, `core/settings_overlay.py`, `core/theming.py`).
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- E2E in isolation, all green: `test_admin_theme_tab.py` (6), `test_theme_save_and_coverage.py` (3), `test_themed_favicon.py` (6), `test_theme_semantic_completion.py` (8), `test_admin_settings_panel.py`+`test_configurable_brand.py`+`test_smoke.py` (20), `test_admin_settings_layout.py`+`test_settings_mobile_value_width.py` (9).
|
||||
- Docs: `README.md` *Customizing the look* now states the single-writer ownership (panel → tab row → env), the ignored-string PUT, the panel-DELETE reset and the `BOR_SETTINGS_DB=0` behavior, plus a cross-reference in the phase-168 precedence paragraph. `AGENTS.md`: no doc impact — it names no settings-overlay/resolution-order description (verified), so per the phase's doc mandate nothing was added.
|
||||
- Notable: the Theme-tab "Reset to defaults" now resets the palette only (a string reset is `DELETE /api/admin/settings/{key}`) — the A1 consequence of a write path that never touches the string columns; live brand-on-save is task 03, deliberately not implemented here.
|
||||
- Next pending task: `.agents/phases/todo/190_app_name_settings_fix/03_live_brand_on_save.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
........................................................................ [ 99%]
|
||||
............... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 39 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 117 0 100%
|
||||
app/core/theming.py 45 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6941 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
**Task 03 complete — brand strings now apply live on save/reset (A2).**
|
||||
|
||||
- `frontend/assets/brand.js`: DOM path extracted into ONE `applyBrandFromConfig(cfg)` exported on the layer's existing window surface (`window.applyBrandFromConfig`); boot `applyBrand()` awaits the same parse-time fetch and calls it (boot byte-identical: one fetch, one copy of each of the four writes). Added the re-apply clause (`window.BOR_BRAND_APPLIED`): a second rename replaces the name the page *shows*, not just the default literal — skipped at boot (previous `""`).
|
||||
- `settings.js`: one `reapplyBrand(keys)` helper (key-gated on `app_name`/`input_placeholder`/`footer_text`, single-flight flag cleared in `finally`, `no-store` fetch) awaited by Save success **and** the K4 prefix of a failed batch, the per-row Reset, and Clear all (all three keys); the phase-168 catalogue re-GET untouched, still inside `saveInFlight`.
|
||||
- `theme.js`: same-shaped `reapplyBrand()` awaited after `applyServedTheme` in `saveTheme` and after `resetTheme`; non-2xx/网络 failure returns before the shared call → never a blanked brand.
|
||||
- `tests/unit/test_brand_live.py` rewritten: task-01's absence pins inverted + new pins (export, boot-is-the-extraction with exactly one copy of each DOM write, previous-name clause + boot no-op, per-settle-path gating, one config fetch/call site per module, `finally` guard reset, failure-arm writes nothing, both task-01 ownership pins kept green).
|
||||
- Live browser verification (throwaway script, deleted): panel save renamed title+brand, footer and composer updated, second rename worked, panel Reset fell back to env live, a non-brand save made **0** `/api/config` requests, theme save kept the legacy baked name (no re-bake), theme reset painted a panel row created meanwhile, and a blocked re-apply left the DOM untouched.
|
||||
|
||||
Gates: `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed**, TOTAL 99% (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. E2E in isolation (`--no-cov`): `test_admin_settings_panel.py` 12 passed **unedited**, `test_admin_theme_tab.py` 6, `test_theme_save_and_coverage.py` 3, `test_theme_semantic_completion.py` 8, `test_themed_favicon.py` 6, `test_configurable_brand.py` 5, `test_smoke.py` 3, `test_admin_settings_layout.py` 5, `test_settings_mobile_value_width.py` 4, `test_document_viewer.py` 8 (restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that that run rewrites).
|
||||
|
||||
Docs: `README.md` *Customizing the look* gained "The save is visible without a reload" (shared pass, key gating, single-flight, never blanked). `AGENTS.md`: no doc impact — it names no settings-overlay/resolution-order or brand-layer description (verified).
|
||||
|
||||
Deviations: (1) the re-apply needed the previous-name substitution to work at all on an already-branded document — boot stays byte-identical; (2) task 02's PUT never nulls the legacy string columns, so the Theme-tab reset's re-apply is a **self-heal of the effective string**, not a fallback (theme.js docstrings state this; the string fallback path is the panel's `DELETE /api/admin/settings/{key}`, verified live).
|
||||
|
||||
Next pending task: `.agents/phases/todo/190_app_name_settings_fix/04_e2e.md` (dedicated Playwright suite, gates, docs consistency pass, atomic commit).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
..............................................FFF....................... [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................... [100%]
|
||||
=================================== FAILURES ===================================
|
||||
________________________ test_plan_uses_the_hnsw_index _________________________
|
||||
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7fda70190140>
|
||||
hnsw_corpus = {'queries': [[-0.007988976012119104, 0.0047693130549608205, 0.025719801734912076, -0.0030883843456668432, -0.018585783....0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]' \n LIMIT 100"}
|
||||
|
||||
def test_plan_uses_the_hnsw_index(db: Session, hnsw_corpus: dict[str, Any]) -> None:
|
||||
"""The 2026-10-15 incident WAS a plan, so the pin is a plan: the tier's own
|
||||
``ORDER BY embedding <=> <vector> LIMIT 100`` runs through
|
||||
``ix_chunks_embedding_hnsw`` and never reads ``chunks`` sequentially.
|
||||
|
||||
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.
|
||||
"""
|
||||
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)}]"
|
||||
)
|
||||
E AssertionError: ix_chunks_embedding_hnsw is not in the plan — the incident is back: [Limit[?], Sort[?], Nested Loop[?], Index Scan[documents_pkey], Bitmap Heap Scan[chunks], Bitmap Index Scan[ix_chunks_document_id]]
|
||||
E assert False
|
||||
E + where False = _uses_index([{'Node Type': 'Limit', 'Parallel Aware': False, 'Async Capable': False, 'Startup Cost': 247.28, ...}, {'Node Type': '...Node Type': 'Bitmap Index Scan', 'Parent Relationship': 'Outer', 'Parallel Aware': False, 'Async Capable': False, ...}], 'ix_chunks_embedding_hnsw')
|
||||
|
||||
tests/integration/test_chunks_hnsw_recall.py:472: AssertionError
|
||||
---------------------------- Captured stdout setup -----------------------------
|
||||
2026-10-18 00:22:11 INFO alembic.runtime.migration :: Context impl PostgresqlImpl.
|
||||
2026-10-18 00:22:11 INFO alembic.runtime.migration :: Will assume transactional DDL.
|
||||
2026-10-18 00:22:11 INFO alembic.runtime.migration :: Running downgrade 0029 -> 0028, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
|
||||
2026-10-18 00:22:13 INFO alembic.runtime.migration :: Context impl PostgresqlImpl.
|
||||
2026-10-18 00:22:13 INFO alembic.runtime.migration :: Will assume transactional DDL.
|
||||
2026-10-18 00:22:13 INFO alembic.runtime.migration :: Running upgrade 0028 -> 0029, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
|
||||
------------------------------ Captured log setup ------------------------------
|
||||
INFO alembic.runtime.migration:migration.py:205 Context impl PostgresqlImpl.
|
||||
INFO alembic.runtime.migration:migration.py:208 Will assume transactional DDL.
|
||||
INFO alembic.runtime.migration:migration.py:616 Running downgrade 0029 -> 0028, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
|
||||
INFO alembic.runtime.migration:migration.py:205 Context impl PostgresqlImpl.
|
||||
INFO alembic.runtime.migration:migration.py:208 Will assume transactional DDL.
|
||||
INFO alembic.runtime.migration:migration.py:616 Running upgrade 0028 -> 0029, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
|
||||
______________ test_plan_without_the_index_seq_scans_the_relation ______________
|
||||
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7fda48dc9130>
|
||||
hnsw_corpus = {'queries': [[-0.007988976012119104, 0.0047693130549608205, 0.025719801734912076, -0.0030883843456668432, -0.018585783....0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]' \n LIMIT 100"}
|
||||
|
||||
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``.
|
||||
|
||||
``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.
|
||||
"""
|
||||
sql = hnsw_corpus["plan_sql"]
|
||||
db.rollback()
|
||||
db.execute(text("SET LOCAL enable_indexscan = off"))
|
||||
db.execute(text("SET LOCAL enable_indexonlyscan = off"))
|
||||
nodes = _plan(db, sql)
|
||||
> assert _seq_scans(nodes, RELATION), (
|
||||
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
|
||||
)
|
||||
E AssertionError: the vacuity guard itself did not seq-scan chunks: [Limit[?], Sort[?], Nested Loop[?], Seq Scan[documents], Bitmap Heap Scan[chunks], Bitmap Index Scan[ix_chunks_document_id]]
|
||||
E assert False
|
||||
E + where False = _seq_scans([{'Node Type': 'Limit', 'Parallel Aware': False, 'Async Capable': False, 'Startup Cost': 256.01, ...}, {'Node Type': '...Node Type': 'Bitmap Index Scan', 'Parent Relationship': 'Outer', 'Parallel Aware': False, 'Async Capable': False, ...}], 'chunks')
|
||||
|
||||
tests/integration/test_chunks_hnsw_recall.py:499: AssertionError
|
||||
_________________ test_top100_recall_at_the_shipped_ef_search __________________
|
||||
|
||||
db = <sqlalchemy.orm.session.Session object at 0x7fda48eb46e0>
|
||||
hnsw_corpus = {'queries': [[-0.007988976012119104, 0.0047693130549608205, 0.025719801734912076, -0.0030883843456668432, -0.018585783....0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]' \n LIMIT 100"}
|
||||
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fda48ade120>
|
||||
|
||||
def test_top100_recall_at_the_shipped_ef_search(
|
||||
db: Session,
|
||||
hnsw_corpus: dict[str, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""At the shipped ``ef_search`` the index returns the exact top-100 for
|
||||
every query, exact to 1e-6 on the score the honesty gate reads.
|
||||
|
||||
Two different failures surface here: a ``top_score`` that moved — A8's
|
||||
deflect/answer verdict flips on that one number, so it is pinned per row,
|
||||
not per query — and an overlap below the floor, i.e. a candidate that never
|
||||
reaches RRF. The failure message carries every query's overlap so a future
|
||||
``ef_search`` raise has its evidence in the report instead of in a re-run.
|
||||
"""
|
||||
queries: list[list[float]] = hnsw_corpus["queries"]
|
||||
exact: list[list[tuple[float, Any]]] = hnsw_corpus["exact"]
|
||||
chunk_ids: set[Any] = hnsw_corpus["chunk_ids"]
|
||||
_set_ef_search(monkeypatch, EF_PIN)
|
||||
|
||||
_force_index_scan(db)
|
||||
try:
|
||||
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)}]"
|
||||
)
|
||||
E AssertionError: this recall run at ef_search=400 did not use ix_chunks_embedding_hnsw even with sequential scans refused — the pin would be measuring a scan and calling it parity: [Limit[?], Sort[?], Nested Loop[?], Index Scan[documents_pkey], Bitmap Heap Scan[chunks], Bitmap Index Scan[ix_chunks_document_id]]
|
||||
E assert False
|
||||
E + where False = _uses_index([{'Node Type': 'Limit', 'Parallel Aware': False, 'Async Capable': False, 'Startup Cost': 247.28, ...}, {'Node Type': '...Node Type': 'Bitmap Index Scan', 'Parent Relationship': 'Outer', 'Parallel Aware': False, 'Async Capable': False, ...}], 'ix_chunks_embedding_hnsw')
|
||||
|
||||
tests/integration/test_chunks_hnsw_recall.py:535: AssertionError
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 39 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 117 0 100%
|
||||
app/core/theming.py 45 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6941 16 99%
|
||||
=========================== short test summary info ============================
|
||||
FAILED tests/integration/test_chunks_hnsw_recall.py::test_plan_uses_the_hnsw_index
|
||||
FAILED tests/integration/test_chunks_hnsw_recall.py::test_plan_without_the_index_seq_scans_the_relation
|
||||
FAILED tests/integration/test_chunks_hnsw_recall.py::test_top100_recall_at_the_shipped_ef_search
|
||||
tests FAILED: unit + integration suite did not pass
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation FAILED (see output above)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
**Task 03 complete + the rejected gate fixed (the phase-188 HNSW plan pins were flaky).**
|
||||
|
||||
Fixed since rejection (`tests/integration/test_chunks_hnsw_recall.py`, tests only):
|
||||
- **Diagnosed the real cause** (reproduced with scratch probes): the pins measured a relation they don't own. Ambient rows distort the planner's `WHERE embedding IS NOT NULL` cost — with 4,000 NULL-embedding chunks alongside the 5,000-row fixture the planner leaves `ix_chunks_embedding_hnsw` for `Sort → Hash Join → Seq Scan` *while the index is present and valid*; and the fixture `ANALYZE`d only `chunks`, never `documents`, though the tier's statement is a JOIN plan. That is the "did not use ix_chunks_embedding_hnsw" failure; `enable_seqscan=off` alone answered with `Bitmap Heap Scan … ix_chunks_document_id` — a full read wearing an index's name, which also broke the vacuity guard.
|
||||
- Fixture now asserts the index exists **and `indisvalid IS TRUE`** after migration 0029, `ANALYZE`s **both** joined relations, reads back only embedded rows (a NULL row is in neither the index nor the exact reference), and exposes `pristine`.
|
||||
- `_force_index_scan`/`_release_index_scan` refuse `enable_seqscan` **and** `enable_bitmapscan`; the vacuity guard also refuses bitmap; pin 1 asserts the unforced plan only on a pristine relation and the forced plan always; failure messages now print index validity. Verified the anti-false-pass: with the index dropped both arms report `idx=False`.
|
||||
- Phase-190 task 03 code unchanged from before (all green): `brand.js` `applyBrandFromConfig` (+ the previous-name re-apply clause), `settings.js` key-gated re-apply on Save/Reset/Clear-all, `theme.js` re-apply on Save/Reset, `tests/unit/test_brand_live.py` (10 pins).
|
||||
|
||||
Gates: `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**. E2E in isolation: `test_admin_settings_panel.py` 12 (unedited), `test_admin_theme_tab.py` 6, `test_configurable_brand.py` 5 (earlier this attempt also green: `test_theme_save_and_coverage` 3, `test_theme_semantic_completion` 8, `test_themed_favicon` 6, `test_smoke` 3, `test_admin_settings_layout` 5, `test_settings_mobile_value_width` 4, `test_document_viewer` 8). Also green with 4,000 injected ambient NULL chunks and with the 13-doc ambient import now in the dev DB.
|
||||
|
||||
Docs: `README.md` — corrected the live-apply paragraph (the Theme tab's PUT never writes the string columns, so its re-read is the self-heal of the *effective* string; a string reset is the panel's `DELETE /api/admin/settings/{key}`). `AGENTS.md`: no change — it names no settings-overlay/brand-layer description (verified); phase-188's "seq scans refused" wording stays true.
|
||||
|
||||
Notable: (1) the brand re-apply needed previous-name substitution or a second rename silently did nothing; (2) I hardened phase-188's pins rather than skipping them, so they still fail loudly on a missing/INVALID index; (3) observation, not fixed here — `test_admin_settings_panel.py` leaves a 13-document `docs` import in the shared dev DB (harmless now that the HNSW pins tolerate ambient rows).
|
||||
|
||||
Next pending task: `.agents/phases/todo/190_app_name_settings_fix/04_e2e.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 39 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 117 0 100%
|
||||
app/core/theming.py 45 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6941 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
Task 04 complete — the phase-190 E2E suite exists, falsification-verified, and every gate in the project is green.
|
||||
|
||||
**Implemented**
|
||||
- `tests/e2e/test_app_name_settings_fix.py` (NEW, 704 lines, module app with `BOR_SETTINGS_DB=1` + distinctive `BOR_APP_NAME`/`BOR_INPUT_PLACEHOLDER`/`BOR_FOOTER_TEXT`): (1) the incident at the wire — panel rename → Theme-tab color save → `/api/config` still serves the panel value, the stored `ui_settings` string columns stay NULL, the second rename lands, and an old client's string-carrying PUT changes nothing; (2) A2 live repaint — the four DOM places (title / `.brand-text` / footer / composer placeholder) update on the same document (no-reload sentinel) incl. the second rename; (3) the per-row Reset's live fallback to env; (4) a legacy baked tab row paints → an out-of-band panel row wins → the tab's Save/Reset re-read it live and never blank the brand; (5) byte-identical boot with **exactly one** `/api/config` request; (6) colors-only tab DOM (17 pickers, no `#theme-app-name`).
|
||||
- DB isolation clears the overrides **through the API** (`DELETE /api/admin/settings`) so the app's cached `Settings` invalidates — a bare TRUNCATE leaks the previous test's override.
|
||||
- **Anti-falsification checked**: reverting the resolver order + the PUT bake fails tests 1 and 4; deleting the `reapplyBrand` calls fails test 2. All files restored byte-exact (`diff` verified), shared DB left clean (0 `app_settings`, 0 `ui_settings`).
|
||||
- `README.md`: brand-strings single-writer pointer in the Settings page section + the E2E pin sentence in *Customizing the look*.
|
||||
|
||||
**Gates (exact)**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed**, `TOTAL 6941 16 99%` (>90%), exit 0
|
||||
- `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation
|
||||
- Regression, isolated: `test_admin_theme_tab` 6 · `test_theme_save_and_coverage` 3 · `test_admin_settings_panel` 12 (named `test_settings_panel.py` in the task file — that path does not exist) · `test_configurable_brand` 5 · `test_smoke` 3 · `test_admin_settings_layout` 5 · `test_settings_mobile_value_width` 4 · `test_theme_semantic_completion` 8 · `test_themed_favicon` 6 — all exit 0
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
|
||||
|
||||
**Docs**: README changed as above; **no AGENTS.md change** — verified it names no settings-overlay / resolution-order / brand-layer description (`grep -i "settings|theme|brand|168" AGENTS.md` → 0 hits), so the phase's "correct only where it names the old order" clause is satisfied.
|
||||
|
||||
**Deviations**: (1) the phase file's (c) "Theme-tab Reset → the brand falls back" cannot happen under A1 (that reset is the all-null COLORS PUT and must not touch the string columns), so the live fallback is pinned where it actually occurs (panel per-row Reset, and an out-of-band panel-row DELETE with the tab open) and the tab's Reset is pinned to re-read and never blank the brand — documented in the module docstring. (2) No commit and no `todo/` → `complete/` move: the pipeline rules reserve staging, committing and phase-file moves to the harness.
|
||||
|
||||
**Next pending task**: `190_app_name_settings_fix/04_e2e.md` was the phase's last task — after the harness commits it, the next is `191_source_name_labels_tags`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
...................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 39 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 117 0 100%
|
||||
app/core/theming.py 45 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6941 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -298,7 +298,11 @@
|
||||
round trip + INVALID self-heal + drift guard),
|
||||
`tests/integration/test_chunks_hnsw_recall.py` (the tier's captured
|
||||
statement names the index, `SET LOCAL` reaches the connection, recall at
|
||||
the shipped depth with seq scans refused), and
|
||||
the shipped depth with the index paths forced — `enable_seqscan`,
|
||||
`enable_indexonlyscan` AND `enable_bitmapscan` off, because a bitmap scan
|
||||
through `ix_chunks_document_id` otherwise disguises a full read and flips
|
||||
the plan on a shared dev DB — and the unforced plan asserted only when the
|
||||
relation holds nothing but the module's own corpus), and
|
||||
`tests/e2e/test_vector_index_recall.py` (the owner-visible arm: the SAME
|
||||
cited document with the index dropped and restored, the index VALID after a
|
||||
real importer run, the near-miss still deflecting in both arms, and the
|
||||
|
||||
@@ -324,6 +324,11 @@ Admin-only — anonymous visitors see a sign-in gate.
|
||||
The overlay can be switched off for the whole process with
|
||||
`BOR_SETTINGS_DB=0` (env-only; the panel stays visible and says so).
|
||||
|
||||
This panel is also the **single writable owner of the three brand strings**
|
||||
(`app_name`, `input_placeholder`, `footer_text`): saving one repaints the page
|
||||
you are on the moment you click, and the Theme tab can no longer write them —
|
||||
see *Customizing the look*.
|
||||
|
||||
### Shared chat (`/shared/<token>`)
|
||||
|
||||
A read-only snapshot of any conversation, shareable via a link. Open to
|
||||
@@ -1692,7 +1697,9 @@ Everything is an environment variable (the `BOR_` prefix, plus
|
||||
to `.env` and adjust.
|
||||
|
||||
**DB-over-env overrides (phase 168):** the precedence for every setting
|
||||
is **`app_settings` row → `BOR_*` environment → code default** — an
|
||||
is **`app_settings` row → `BOR_*` environment → code default** (the
|
||||
three brand strings have one extra tier between the row and the env —
|
||||
the legacy `ui_settings` Theme-tab row, see *Customizing the look*) — an
|
||||
override the admin saves in the Settings panel is stored in Postgres
|
||||
(`app_settings`, one row per key) and **wins over the env var for
|
||||
that key — with no restart**. The panel never writes `.env` (the env
|
||||
@@ -1862,18 +1869,53 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
|
||||
|
||||
### Customizing the look
|
||||
|
||||
Every identity string is an env var: `BOR_APP_NAME` (display name),
|
||||
`BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and
|
||||
Every identity string defaults from an env var: `BOR_APP_NAME` (display
|
||||
name), `BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and
|
||||
`BOR_FOOTER_TEXT` (footer line) — all served by `GET /api/config` and
|
||||
applied by `assets/brand.js` at boot.
|
||||
|
||||
The colors are set from the admin **Theme tab** (`/theme.html`,
|
||||
admin-only — phase 91): the 17 palette colors (the 9 identity colors plus
|
||||
the 8 semantic state colors — success/error/accent families) and the
|
||||
three strings above are edited with text fields and color pickers,
|
||||
persisted in the `ui_settings` table, and injected into every served page
|
||||
as an inline `<style>` BEFORE first paint (no red flash, no pop-in). The
|
||||
browser-tab **favicon** follows the same effective palette: `GET
|
||||
**The admin Settings panel (`/settings.html`) is the single writable
|
||||
owner of those three brand strings** (phase 190, A1). Their resolution
|
||||
order is **panel row → Theme-tab row → env**: saving `app_name` there
|
||||
wins over everything, and it beats a value an old Theme-tab save baked
|
||||
into `ui_settings` (the fix for "changing the App name in settings
|
||||
broke, it no longer updates the name"). `PUT /api/ui-settings` still
|
||||
accepts the three string fields so an old cached client keeps working,
|
||||
but it **ignores** them and never writes (or clears) those columns — a
|
||||
Theme-tab save can never re-bake a name. A string is reset with the
|
||||
panel's `DELETE /api/admin/settings/{key}` (back to the Theme-tab row if
|
||||
one still holds a value, else back to the env var), and with
|
||||
`BOR_SETTINGS_DB=0` no panel row is in effect, so the old order —
|
||||
Theme-tab row, then env — stands.
|
||||
|
||||
**The save is visible without a reload** (phase 190, task 03). Saving one of
|
||||
the three brand strings in the panel — or resetting it with
|
||||
`DELETE /api/admin/settings/{key}` — re-reads `GET /api/config` and applies it
|
||||
to the OPEN document through the ONE shared brand pass (`assets/brand.js`'s
|
||||
`applyBrandFromConfig`, the very function the boot runs): the page title, the
|
||||
header brand, the footer line and the chat composer placeholder update on the
|
||||
click, no reload — including a rename of an app whose old name is already on
|
||||
the page. The re-apply runs only when one of those three keys was involved (the
|
||||
other ~72 rows cost no extra request), never stacks a second fetch while one is
|
||||
in flight, and a failed one leaves the page exactly as it showed — the save's
|
||||
own status or error line stays the feedback. The Theme tab runs the same pass
|
||||
after its Save and its Reset; it no longer edits the strings, so that read is
|
||||
what keeps an open tab honest about a name the panel changed meanwhile.
|
||||
The whole contract — the incident regression (a color save no longer shadows a
|
||||
later rename) and the live repaint of title / brand / footer / placeholder — is
|
||||
pinned in a real browser by `tests/e2e/test_app_name_settings_fix.py` (run in
|
||||
isolation).
|
||||
|
||||
The colors stay the **Theme tab's** (`/theme.html`, admin-only — phase
|
||||
91): the 17 palette colors (the 9 identity colors plus the 8 semantic
|
||||
state colors — success/error/accent families) are edited with color
|
||||
pickers and nothing else, persisted in the `ui_settings` table, and
|
||||
injected into every served page as an inline `<style>` BEFORE first
|
||||
paint (no red flash, no pop-in). The tab's form renders those 17 pickers
|
||||
only — its old "Branding" fieldset (`#theme-app-name`,
|
||||
`#theme-placeholder`, `#theme-footer`) is gone, and its PUT body carries no
|
||||
string key. The browser-tab **favicon** follows the same effective palette:
|
||||
`GET
|
||||
/favicon.svg` renders the hexagon art from the saved colors on every
|
||||
request (no process cache — a theme change is visible on the next
|
||||
request, no restart) and degrades to the built-in art with 200 on a
|
||||
|
||||
+8
-6
@@ -3,12 +3,14 @@ layer, the phase-59 docs-push flag (the "Save as doc" gating), the
|
||||
phase-122 image flag (UI affordance gating), and the phase-62 UI
|
||||
customization strings (composer placeholder, footer line).
|
||||
|
||||
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
|
||||
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
|
||||
wins when set, env is the fallback), resolved by the SAME
|
||||
:func:`app.core.theming.effective_settings` resolver the
|
||||
``/api/ui-settings`` API uses, so the brand layer and the tab can never
|
||||
disagree. The route opens a short-lived session (the sync-endpoint
|
||||
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values,
|
||||
resolved by the SAME :func:`app.core.theming.effective_settings` resolver
|
||||
the ``/api/ui-settings`` API uses, so the brand layer and the tab can
|
||||
never disagree. Phase 190 (task 02, A1) fixed WHO owns them: the admin
|
||||
Settings panel's ``app_settings`` row (phase 168) first, then the
|
||||
legacy ``ui_settings`` (Theme-tab) row, then the env value — so a name
|
||||
set in the panel is what this endpoint serves, whatever an old Theme-tab
|
||||
save baked into the other row. The route opens a short-lived session (the sync-endpoint
|
||||
house pattern — the route is sync, matching the middleware world).
|
||||
|
||||
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
|
||||
|
||||
+39
-38
@@ -1,10 +1,14 @@
|
||||
"""UI settings admin API (phase 91, task 01).
|
||||
"""UI settings admin API (phase 91, task 01; colors-only since phase 190).
|
||||
|
||||
The persistence surface of the admin Theme tab (the tab itself lands in
|
||||
tasks 04/05): the single ``ui_settings`` row (id 1) that stores what the
|
||||
admin sets — the app name, input placeholder, footer text, and the 17
|
||||
admin sets — since phase 190 (A1, owner-confirmed 2026-10-18) the 17
|
||||
palette colors (the 9 identity colors + the 8 semantic state colors,
|
||||
phase 93 — B3 revised). The whole router sits behind
|
||||
phase 93 — B3 revised) and NOTHING else. The three brand strings (app
|
||||
name, input placeholder, footer text) belong to the admin Settings
|
||||
panel (``app_settings``, phase 168): this row keeps its string columns
|
||||
as the documented FALLBACK for values it baked in before that phase, but
|
||||
the API never writes them (see the PUT bullet). The whole router sits behind
|
||||
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
|
||||
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
|
||||
403 on every route (only the admin themes the deployment).
|
||||
@@ -14,16 +18,25 @@ Routes (under ``/api`` via the ``main`` registration):
|
||||
* ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's
|
||||
DB-over-env / DB-over-built-in merge, B1): a missing row reports the
|
||||
env strings + the built-in palette, so a fresh tab shows the live
|
||||
theme. Creates nothing.
|
||||
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
|
||||
string is trimmed (empty → NULL, >300 → 422 naming the field), each
|
||||
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
|
||||
naming the field), and — the owner-locked normalization — a color
|
||||
theme. Creates nothing. The three strings stay in the response
|
||||
READ-ONLY (old-client compat, and the resolver's order makes them the
|
||||
EFFECTIVE ones — the panel's value when the panel owns the field).
|
||||
* ``PUT /api/ui-settings`` — a FULL replacement of the row's 17 COLORS:
|
||||
each color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else
|
||||
422 naming the field), and — the owner-locked normalization — a color
|
||||
equal to its built-in is stored as NULL, so "save the defaults"
|
||||
leaves the row empty and the served HTML stays byte-identical (the
|
||||
no-op injection contract, task 02). Upserts the id-1 row (SELECT →
|
||||
update-or-insert); a concurrent PUT is single-admin — last writer
|
||||
wins. Returns the new effective values.
|
||||
no-op injection contract, task 02). The 3 string fields of the body
|
||||
are ACCEPTED AND IGNORED (phase 190 A1 — the request model keeps them
|
||||
so an old cached client still saves its palette; the handler drops
|
||||
them BEFORE validating or persisting, so a Theme-tab save can never
|
||||
re-bake a brand string and shadow the Settings panel again — the
|
||||
``TODO.md`` L3 incident). The stored string columns are left exactly
|
||||
as they were, never cleared: with no panel row a legacy baked value
|
||||
still applies (the A1 fallback), and "reset" for a string is the
|
||||
panel's ``DELETE /api/admin/settings/{key}``. Upserts the id-1 row
|
||||
(SELECT → update-or-insert); a concurrent PUT is single-admin — last
|
||||
writer wins. Returns the new effective values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -49,28 +62,6 @@ router = APIRouter(
|
||||
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
|
||||
#: lowercased on store so the stored/tagged hex is canonical).
|
||||
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
#: The strings' column length (mirrors ``ui_settings`` VARCHAR(300)).
|
||||
_MAX_STRING_LEN = 300
|
||||
|
||||
|
||||
def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
|
||||
"""Trim the 3 display strings: empty after the trim → ``None``
|
||||
(the clear operation), >300 chars after the trim → 422 naming the
|
||||
field (the house fixed-detail style — the detail never varies by
|
||||
value beyond naming the field)."""
|
||||
values: dict[str, str | None] = {}
|
||||
for field in theming.STRING_FIELDS:
|
||||
raw = getattr(payload, field)
|
||||
if raw is None:
|
||||
values[field] = None
|
||||
continue
|
||||
value = raw.strip()
|
||||
if len(value) > _MAX_STRING_LEN:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"{field} is too long (max 300)"
|
||||
)
|
||||
values[field] = value or None
|
||||
return values
|
||||
|
||||
|
||||
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
|
||||
@@ -105,7 +96,11 @@ def get_ui_settings(
|
||||
|
||||
Reads only: a missing row means "defaults" (the env strings + the
|
||||
built-in palette), so a fresh deployment's tab shows the live theme
|
||||
with an empty row, and nothing is ever upserted by a read.
|
||||
with an empty row, and nothing is ever upserted by a read. The 3
|
||||
strings report the EFFECTIVE values through the phase-190 order
|
||||
(panel row → this row → env), so an old client's form pre-populates
|
||||
with what the deployment actually shows, never with this row's stale
|
||||
bake.
|
||||
"""
|
||||
return UiSettingsOut(**theming.effective_settings(db, settings))
|
||||
|
||||
@@ -116,24 +111,30 @@ def update_ui_settings(
|
||||
settings: Settings = Depends(get_settings), # noqa: B008
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> UiSettingsOut:
|
||||
"""Replace the single row with the body's 20 values (validated and
|
||||
"""Replace the row's 17 COLORS with the body's (validated and
|
||||
normalized — see the module docstring), then report the new
|
||||
effective values.
|
||||
|
||||
The body's 3 brand strings are NEVER read (phase 190, A1: the admin
|
||||
Settings panel is their single writable owner). Dropping them here
|
||||
rather than rejecting them keeps an old cached client's Save working
|
||||
— and takes away the mechanism of the ``TODO.md`` L3 incident: the
|
||||
row's string columns keep exactly what they hold (never written, so
|
||||
a legacy baked value survives as the documented fallback, and no
|
||||
save can re-bake the then-effective name and shadow every later
|
||||
panel change).
|
||||
|
||||
Upsert on the id-1 row (SELECT → update-or-insert; the Python-side
|
||||
``default=1`` supplies the PK on the insert). Concurrency is
|
||||
single-admin (one owner, one tab) — the last writer wins and no
|
||||
lock is taken: a lost race just means the other admin's PUT is the
|
||||
effective one.
|
||||
"""
|
||||
strings = _validate_strings(payload)
|
||||
colors = _validate_colors(payload)
|
||||
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
if row is None:
|
||||
row = UiSettings(id=1)
|
||||
db.add(row)
|
||||
for field in theming.STRING_FIELDS:
|
||||
setattr(row, field, strings[field])
|
||||
for field in theming.COLOR_FIELDS:
|
||||
setattr(row, field, colors[field])
|
||||
db.commit()
|
||||
|
||||
@@ -32,6 +32,12 @@ pre-migration deployment is a normal state); any other DB failure is
|
||||
one WARNING per process naming the exception class ("settings
|
||||
overrides unavailable — env values in effect"). Both "once per process"
|
||||
guards are module-level flags, reset by :func:`reset_state_for_tests`.
|
||||
|
||||
Phase 190 (task 02) adds the one question another resolver asks this
|
||||
table: :func:`applied_override_keys` — "which keys does the Settings
|
||||
panel own right now?" (the brand-string ownership order in
|
||||
:func:`app.core.theming.effective_settings`). It reads on the CALLER's
|
||||
session, so answering it costs no second engine and never a stale view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -45,6 +51,7 @@ from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import Settings
|
||||
@@ -125,6 +132,38 @@ def load_overrides() -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def applied_override_keys(session: Session) -> frozenset[str]:
|
||||
"""The ``app_settings`` keys IN EFFECT right now — phase 190 task 02.
|
||||
|
||||
:func:`app.core.theming.effective_settings` needs exactly this to
|
||||
answer "does the admin's Settings panel own this brand string?" (A1:
|
||||
the panel row beats the Theme-tab row, and only a REAL row counts —
|
||||
comparing values would mis-read a panel row that happens to equal
|
||||
the env value, the exact shadow the phase-190 incident is).
|
||||
|
||||
Read on the CALLER's session — one ``SELECT`` on a connection the
|
||||
caller already holds, transactionally consistent with the
|
||||
``ui_settings`` read beside it (a cross-request memo of the key set
|
||||
would go stale the moment a row is written outside the panel and
|
||||
could re-shadow a just-saved value). The resolver calls this ONCE
|
||||
per resolution and tests the three string fields for membership, so
|
||||
a resolution costs no second query per field.
|
||||
|
||||
The kill switch (``BOR_SETTINGS_DB``, D6) short-circuits before any
|
||||
read — with the overlay off no row is in effect, and the Theme-tab
|
||||
row keeps its phase-91 fallback. A non-overridable key (LOCKED A3)
|
||||
and an orphan row (a field a later release removed) are never
|
||||
"in effect": :func:`merge` drops them, so they must not win here
|
||||
either.
|
||||
"""
|
||||
if not enabled():
|
||||
return frozenset()
|
||||
keys = session.execute(text("SELECT key FROM app_settings")).scalars().all()
|
||||
return frozenset(
|
||||
key for key in keys if key in Settings.model_fields and CATALOG[key].overridable
|
||||
)
|
||||
|
||||
|
||||
def _coerce(field_name: str, raw: str) -> Any:
|
||||
"""Coerce one env-native string row value for the candidate set.
|
||||
|
||||
|
||||
+49
-11
@@ -83,12 +83,28 @@ diverge (``tests/unit/test_theming.py`` pins the mirror: every
|
||||
pairs).
|
||||
|
||||
Effective-value resolution (:func:`effective_settings`) — the DB-over-
|
||||
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
|
||||
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
|
||||
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
|
||||
color column to the built-in. ONE resolver is used by BOTH
|
||||
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
|
||||
layer), so the tab and the running UI can never disagree.
|
||||
env / DB-over-built-in merge (B1, owner-locked 2026-09-09) resolved
|
||||
column-by-column:
|
||||
|
||||
* **the three brand strings** (``app_name`` / ``input_placeholder`` /
|
||||
``footer_text``) — **Settings-panel row → Theme-tab row → env**
|
||||
(phase 190, A1 owner-confirmed 2026-10-18). The panel (the
|
||||
``app_settings`` rows behind ``/api/admin/settings``, phase 168) is
|
||||
the SINGLE writable owner of the strings, so its row wins whenever it
|
||||
exists; the Theme-tab row (this module's ``ui_settings``) is the
|
||||
documented fallback for the rows it baked before this phase (no data
|
||||
migration — a legacy row keeps rendering what it holds until the
|
||||
panel overrides it); the ``BOR_`` env value is the last resort.
|
||||
Presence, never value comparison: an ``app_settings`` row that
|
||||
happens to equal the env value still owns the field (that equality is
|
||||
exactly how the phase-190 shadow looked from the owner's side).
|
||||
* **the 17 colors** — unchanged (B1): the ``ui_settings`` row when not
|
||||
``NULL``, else the built-in palette. The ownership flip is
|
||||
strings-only.
|
||||
|
||||
ONE resolver is used by BOTH ``GET /api/ui-settings`` (the tab) and
|
||||
``GET /api/config`` (the brand layer), so the tab and the running UI
|
||||
can never disagree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -99,6 +115,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.core import settings_overlay
|
||||
from app.models import UiSettings
|
||||
|
||||
#: The 17 built-in palette colors, keyed by BARE variable name (no
|
||||
@@ -136,8 +153,12 @@ BUILTIN_COLORS: dict[str, str] = {
|
||||
#: API, and the ``theme_style_tag`` renderer (task 02).
|
||||
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
|
||||
|
||||
#: The 3 display strings the ``ui_settings`` row carries — env fallback
|
||||
#: (B1: unlike the colors, the env vars stay the strings' default).
|
||||
#: The 3 brand strings the ``ui_settings`` row carries. Since phase 190
|
||||
#: (A1) the Settings panel (``app_settings``, phase 168) is their single
|
||||
#: writable OWNER and wins over this row; the row is the documented
|
||||
#: fallback for the values it baked in before that phase, with the env
|
||||
#: value last (B1: unlike the colors, the env vars stay the default).
|
||||
#: ``PUT /api/ui-settings`` no longer writes them.
|
||||
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
|
||||
@@ -150,12 +171,22 @@ def effective_settings(
|
||||
defaults, column by column:
|
||||
|
||||
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
|
||||
— the DB value when it is a non-empty string, else the env value
|
||||
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
|
||||
— the three-way order of phase 190 (A1), per field:
|
||||
**Settings-panel row** → **Theme-tab row** → **env**. The panel
|
||||
row is ``getattr(settings, field)`` (the ``Settings`` the route
|
||||
passes already resolves through the phase-168 overlay, so a panel
|
||||
row IS its value) and it wins only when the overlay reports the
|
||||
key as an actual override
|
||||
(:func:`app.core.settings_overlay.applied_override_keys`, read
|
||||
ONCE here — presence, never a value compare). Otherwise the
|
||||
``ui_settings`` value when it is a non-empty string (the phase-91
|
||||
fallback a legacy baked-in row still lands in), else the env
|
||||
value (B1: the env vars stay the last resort).
|
||||
* **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8
|
||||
semantic state) — the DB value when not ``None``, else
|
||||
:data:`BUILTIN_COLORS` (B1: no env fallback for colors — the
|
||||
built-in palette IS the default).
|
||||
built-in palette IS the default; the ownership flip is
|
||||
strings-only).
|
||||
|
||||
A missing row (``GET`` creates nothing) means "defaults" — the env
|
||||
strings + the built-in palette. The ``settings`` parameter names the
|
||||
@@ -167,8 +198,15 @@ def effective_settings(
|
||||
if settings is None:
|
||||
settings = get_settings()
|
||||
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
#: The keys the admin's Settings panel owns right now (phase 190
|
||||
#: A1) — one SELECT on this same session, consulted per string
|
||||
#: field below with a membership test (no query per field).
|
||||
panel_keys = settings_overlay.applied_override_keys(session)
|
||||
effective: dict[str, str] = {}
|
||||
for field in STRING_FIELDS:
|
||||
if field in panel_keys:
|
||||
effective[field] = getattr(settings, field)
|
||||
continue
|
||||
value = getattr(row, field, None) if row is not None else None
|
||||
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
|
||||
for key in COLOR_FIELDS:
|
||||
|
||||
+11
-1
@@ -563,6 +563,14 @@ class UiSettings(Base):
|
||||
overview). :func:`app.core.theming.effective_settings` resolves the
|
||||
effective 20 values both the ``GET /api/ui-settings`` and
|
||||
``GET /api/config`` endpoints serve.
|
||||
|
||||
Phase 190 (task 02, A1 — owner-confirmed 2026-10-18) narrowed the
|
||||
WRITE half of this row to the 17 colors: the three string columns
|
||||
are now the admin Settings panel's (``app_settings``, phase 168) and
|
||||
``PUT /api/ui-settings`` never touches them. The columns stay — a
|
||||
row this table baked before that phase is the documented FALLBACK
|
||||
(panel row → this row → env) and still renders until the panel
|
||||
overrides it, so the fix needed no data migration.
|
||||
"""
|
||||
|
||||
__tablename__ = "ui_settings"
|
||||
@@ -571,7 +579,9 @@ class UiSettings(Base):
|
||||
#: id=1 precedent — Python-side default; the migration carries no
|
||||
#: server default because the row is created only by the PUT upsert).
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
# --- Strings (NULL/empty = "use the env default" — B1) ---
|
||||
# --- Strings — READ-ONLY since phase 190 (A1): the Settings panel's
|
||||
# row wins over these, the Theme-tab PUT never writes them, and
|
||||
# NULL/empty means "use the env default" (B1) ---
|
||||
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
|
||||
+20
-14
@@ -2077,20 +2077,24 @@ class TokenAuthRequest(BaseModel):
|
||||
|
||||
|
||||
class UiSettingsIn(BaseModel):
|
||||
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
|
||||
replacement of the single ``ui_settings`` row.
|
||||
"""``PUT /api/ui-settings`` body (phase 91, task 01; colors-only since
|
||||
phase 190, task 02): a FULL replacement of the single ``ui_settings``
|
||||
row's 17 COLORS.
|
||||
|
||||
Every field is ``str | None`` — present = a new value (strings are
|
||||
trimmed; empty after the trim is the CLEAR operation, stored as
|
||||
NULL; colors must be ``#rrggbb`` and are lowercased on store),
|
||||
``null``/absent = "back to the default" (stored as NULL — the Reset
|
||||
button's all-null PUT is exactly the "defaults" operation). The
|
||||
API layer runs the trim/length/hex validation so the 422 details
|
||||
name the offending field (the house fixed-detail style); the
|
||||
built-in→NULL normalization (a color equal to its built-in is
|
||||
stored as NULL — "save the defaults" must leave the row empty, the
|
||||
no-op injection contract) happens there too, next to the palette
|
||||
it normalizes against.
|
||||
Every field is ``str | None`` — present = a new value, ``null``/
|
||||
absent = "back to the default" (stored as NULL — the Reset button's
|
||||
all-null PUT is exactly the "defaults" operation). The API layer
|
||||
runs the hex validation so the 422 details name the offending field
|
||||
(the house fixed-detail style); the built-in→NULL normalization (a
|
||||
color equal to its built-in is stored as NULL — "save the defaults"
|
||||
must leave the row empty, the no-op injection contract) happens
|
||||
there too, next to the palette it normalizes against.
|
||||
|
||||
The three string fields stay on the model for OLD-CLIENT COMPAT and
|
||||
are ACCEPTED AND IGNORED (phase 190 A1): the Settings panel owns the
|
||||
brand strings, so the handler drops them before validating or
|
||||
persisting and the stored columns keep what they hold. Resetting a
|
||||
string is ``DELETE /api/admin/settings/{key}``.
|
||||
"""
|
||||
|
||||
app_name: str | None = None
|
||||
@@ -2127,7 +2131,9 @@ class UiSettingsOut(BaseModel):
|
||||
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
|
||||
the LIVE theme — a fresh (row-missing) deployment reports the env
|
||||
strings and the built-in palette (the semantic colors report their
|
||||
built-ins — the effective values, the NULL = built-in rule).
|
||||
built-ins — the effective values, the NULL = built-in rule). Since
|
||||
phase 190 (A1) the 3 strings are READ-ONLY here and report the
|
||||
EFFECTIVE value through the panel-row → tab-row → env order.
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
|
||||
+169
-79
@@ -34,7 +34,7 @@
|
||||
* aria-label, the input label, the meta descriptions);
|
||||
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
||||
* settled config also carries the two UI-customization
|
||||
* string keys, applied in this same .then, AFTER the app_name
|
||||
* string keys, applied in this same pass, AFTER the app_name
|
||||
* passes and INDEPENDENT of them (they apply even when the
|
||||
* name is the default/empty). Each empty value is a no-op —
|
||||
* an unset deployment stays byte-identical:
|
||||
@@ -54,11 +54,37 @@
|
||||
* (the loadHealth house style: progressive enhancement, the page
|
||||
* never breaks).
|
||||
*
|
||||
* ONE pass, TWO callers (phase 190, task 03 — A2). Steps 1–6 live in
|
||||
* the named function applyBrandFromConfig(cfg), the single owner of the
|
||||
* brand DOM. It runs (a) at BOOT, through applyBrand() awaiting the
|
||||
* parse-time fetch — byte-identical to the pre-phase single pass — and
|
||||
* (b) on a LIVE re-apply, when the admin saves or resets a brand string
|
||||
* in the Settings panel (assets/settings.js) or saves/resets the Theme
|
||||
* tab (assets/theme.js): those surfaces fetch a fresh /api/config and
|
||||
* hand it to the SAME function, so "saved" is visible without a reload
|
||||
* (the PLAN §7.4 never-stale contract, extended from the phase-92
|
||||
* palette to the strings). Because a live re-apply runs against a
|
||||
* document whose previous pass already replaced the default literal,
|
||||
* step 7 re-runs the three literal passes over the name the page
|
||||
* CURRENTLY shows (window.BOR_BRAND_APPLIED) — at boot that is "" so
|
||||
* step 7 never runs and the boot path stays exactly the phase-39
|
||||
* sequence. The function is on this layer's existing export surface —
|
||||
* the window globals (window.BOR_BRAND, window.BOR_CONFIG_PROMISE) —
|
||||
* as window.applyBrandFromConfig; brand.js stays the ONE owner of the
|
||||
* brand DOM and no surface copies a step.
|
||||
*
|
||||
* No-op property: with the customization env vars (BOR_APP_NAME,
|
||||
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers
|
||||
* with the template defaults themselves — the name IS the literal, the
|
||||
* placeholder and footer are the phase-61 copy (re-setting them is
|
||||
* invisible) — so an unset deployment renders byte-identical.
|
||||
* invisible) — so an unset deployment renders byte-identical. The
|
||||
* "empty writes nothing" rule for the two phase-62 keys holds on a live
|
||||
* re-apply too (the page keeps what it shows): /api/config cannot
|
||||
* distinguish "cleared" from "unset", and both env defaults are
|
||||
* non-empty, so an empty value only ever comes from an operator saving
|
||||
* an empty string (and then the page keeps what it shows). A panel
|
||||
* RESET deletes the row, so the resolver hands back the legacy tab row
|
||||
* or the env default (both non-empty) and that name re-applies live.
|
||||
*/
|
||||
|
||||
/* The synchronous default — set BEFORE any fetch, so module scripts
|
||||
@@ -76,6 +102,12 @@ window.BOR_DOCS_REPO_CONFIGURED = false;
|
||||
sync with it. */
|
||||
const BRAND_LITERAL = "Brain of Reese";
|
||||
|
||||
/* Phase 190 (task 03): the name the DOM passes last applied — "" until
|
||||
the first apply (the boot pass). A live re-apply needs it: the page
|
||||
no longer carries the default literal where the brand shows, so the
|
||||
re-apply replaces THIS name too (step 7 below). */
|
||||
window.BOR_BRAND_APPLIED = "";
|
||||
|
||||
/* The name is operator-controlled: HTML-escape it before it touches
|
||||
innerHTML (the markdown.js escape pattern — local on purpose, no
|
||||
cross-module import for a 5-line helper). */
|
||||
@@ -108,106 +140,164 @@ const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
|
||||
);
|
||||
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
|
||||
|
||||
function applyBrand() {
|
||||
BOR_CONFIG_PROMISE.then((cfg) => {
|
||||
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
|
||||
if (name) {
|
||||
// A configured name: apply it (empty / missing: the default
|
||||
// stands — and the phase-62 keys below are INDEPENDENT of the
|
||||
// name, so they still apply).
|
||||
window.BOR_BRAND = name;
|
||||
/* THE shared brand pass (phase 190, task 03) — the ONE owner of the
|
||||
brand DOM. Synchronous, idempotent, and safe to run against an
|
||||
already-branded document: boot (applyBrand below, via the settled
|
||||
fetch) and the live re-apply (settings.js after a brand-string
|
||||
save/reset, theme.js after a save/reset) run THIS function and
|
||||
nothing else. `cfg` is a /api/config payload; a null/missing payload
|
||||
touches nothing (the loadHealth house style — never a blanked brand). */
|
||||
function applyBrandFromConfig(cfg) {
|
||||
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
|
||||
const previous =
|
||||
typeof window.BOR_BRAND_APPLIED === "string" ? window.BOR_BRAND_APPLIED : "";
|
||||
if (name) {
|
||||
// A configured name: apply it (empty / missing: the default
|
||||
// stands — and the phase-62 keys below are INDEPENDENT of the
|
||||
// name, so they still apply).
|
||||
window.BOR_BRAND = name;
|
||||
|
||||
// 1. The document title (global replace of the literal — covers
|
||||
// every page's static "<…> · Brain of Reese" titles).
|
||||
document.title = document.title.replaceAll(BRAND_LITERAL, name);
|
||||
// 1. The document title (global replace of the literal — covers
|
||||
// every page's static "<…> · Brain of Reese" titles).
|
||||
document.title = document.title.replaceAll(BRAND_LITERAL, name);
|
||||
|
||||
// 2. The header brand on every page: a name starting "Brain of "
|
||||
// keeps the bold split (the current look), anything else
|
||||
// renders plain — the name is always escaped.
|
||||
for (const el of document.querySelectorAll(".brand-text")) {
|
||||
if (name.startsWith("Brain of ")) {
|
||||
const rest = name.slice("Brain of ".length);
|
||||
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
|
||||
} else {
|
||||
el.textContent = name;
|
||||
}
|
||||
// 2. The header brand on every page: a name starting "Brain of "
|
||||
// keeps the bold split (the current look), anything else
|
||||
// renders plain — the name is always escaped.
|
||||
for (const el of document.querySelectorAll(".brand-text")) {
|
||||
if (name.startsWith("Brain of ")) {
|
||||
const rest = name.slice("Brain of ".length);
|
||||
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
|
||||
} else {
|
||||
el.textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Prose: a TreeWalker over the body's text nodes replaces the
|
||||
// literal (the empty-state h1, any other copy). Text nodes
|
||||
// inside <script>/<style> are rejected — the page source must
|
||||
// never be rewritten.
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
const tag = node.parentElement ? node.parentElement.tagName : "";
|
||||
return tag === "SCRIPT" || tag === "STYLE"
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
// 3. Prose: a TreeWalker over the body's text nodes replaces the
|
||||
// literal (the empty-state h1, any other copy). Text nodes
|
||||
// inside <script>/<style> are rejected — the page source must
|
||||
// never be rewritten.
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
const tag = node.parentElement ? node.parentElement.tagName : "";
|
||||
return tag === "SCRIPT" || tag === "STYLE"
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
);
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
for (const node of nodes) {
|
||||
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
|
||||
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
|
||||
},
|
||||
);
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
for (const node of nodes) {
|
||||
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
|
||||
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Attributes: the #messages aria-label, the composer input
|
||||
// label, the meta descriptions — aria-label / placeholder /
|
||||
// meta content only, each replaced in place.
|
||||
for (const el of document.querySelectorAll(
|
||||
"[aria-label], [placeholder], meta[content]",
|
||||
)) {
|
||||
for (const attr of ["aria-label", "placeholder"]) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (v && v.includes(BRAND_LITERAL)) {
|
||||
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
|
||||
}
|
||||
}
|
||||
if (el.tagName === "META") {
|
||||
const v = el.getAttribute("content");
|
||||
if (v && v.includes(BRAND_LITERAL)) {
|
||||
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Attributes: the #messages aria-label, the composer input
|
||||
// label, the meta descriptions — aria-label / placeholder /
|
||||
// meta content only, each replaced in place.
|
||||
// 7. Phase 190 (task 03, A2) — the RE-APPLY clause. A live re-apply
|
||||
// runs on a document whose steps 1 / 3 / 4 above already rewrote
|
||||
// the default literal into the PREVIOUS name, so a second rename
|
||||
// has to replace what the page SHOWS, not (only) the literal:
|
||||
// the same three places, one more source string. At boot
|
||||
// `previous` is "" — this block never runs and the boot pass is
|
||||
// byte-identical to the pre-phase sequence. (.brand-text, the
|
||||
// composer placeholder and the footer line need nothing here:
|
||||
// steps 2 / 5 / 6 assign their value outright.)
|
||||
if (previous && previous !== name) {
|
||||
document.title = document.title.replaceAll(previous, name);
|
||||
for (const node of nodes) {
|
||||
if (node.nodeValue && node.nodeValue.includes(previous)) {
|
||||
node.nodeValue = node.nodeValue.replaceAll(previous, name);
|
||||
}
|
||||
}
|
||||
for (const el of document.querySelectorAll(
|
||||
"[aria-label], [placeholder], meta[content]",
|
||||
)) {
|
||||
for (const attr of ["aria-label", "placeholder"]) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (v && v.includes(BRAND_LITERAL)) {
|
||||
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
|
||||
if (v && v.includes(previous)) {
|
||||
el.setAttribute(attr, v.replaceAll(previous, name));
|
||||
}
|
||||
}
|
||||
if (el.tagName === "META") {
|
||||
const v = el.getAttribute("content");
|
||||
if (v && v.includes(BRAND_LITERAL)) {
|
||||
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
|
||||
if (v && v.includes(previous)) {
|
||||
el.setAttribute("content", v.replaceAll(previous, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||
// config also carries the two customization STRING keys — applied
|
||||
// here, INDEPENDENT of the app_name block above (they apply even
|
||||
// when the name is the default/empty). Each empty value is a
|
||||
// no-op, so an unset deployment stays byte-identical (no attribute
|
||||
// or element touched, no second network call). Color theming is
|
||||
// server-side since phase 91 (task 03) — not this layer's job.
|
||||
const placeholder =
|
||||
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
|
||||
if (placeholder) {
|
||||
// 5. The composer placeholder — the chat page only:
|
||||
// #message-input exists only on index.html, so every other
|
||||
// page no-ops via the null guard.
|
||||
document.querySelector("#message-input")?.setAttribute(
|
||||
"placeholder",
|
||||
placeholder,
|
||||
);
|
||||
}
|
||||
// The name this document now shows — the source string the NEXT
|
||||
// re-apply replaces (phase 190, task 03).
|
||||
window.BOR_BRAND_APPLIED = name;
|
||||
}
|
||||
|
||||
const footerText =
|
||||
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
|
||||
if (footerText) {
|
||||
// 6. The footer line on every page (all 9 carry the phase-61
|
||||
// .footer-text hook). textContent on purpose: an operator
|
||||
// string can't inject markup.
|
||||
document.querySelectorAll(".footer-text").forEach((el) => {
|
||||
el.textContent = footerText;
|
||||
});
|
||||
}
|
||||
});
|
||||
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||
// config also carries the two customization STRING keys — applied
|
||||
// here, INDEPENDENT of the app_name block above (they apply even
|
||||
// when the name is the default/empty). Each empty value is a
|
||||
// no-op, so an unset deployment stays byte-identical (no attribute
|
||||
// or element touched, no second network call). Color theming is
|
||||
// server-side since phase 91 (task 03) — not this layer's job.
|
||||
const placeholder =
|
||||
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
|
||||
if (placeholder) {
|
||||
// 5. The composer placeholder — the chat page only:
|
||||
// #message-input exists only on index.html, so every other
|
||||
// page no-ops via the null guard.
|
||||
document.querySelector("#message-input")?.setAttribute(
|
||||
"placeholder",
|
||||
placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
const footerText =
|
||||
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
|
||||
if (footerText) {
|
||||
// 6. The footer line on every page (all 9 carry the phase-61
|
||||
// .footer-text hook). textContent on purpose: an operator
|
||||
// string can't inject markup.
|
||||
document.querySelectorAll(".footer-text").forEach((el) => {
|
||||
el.textContent = footerText;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* This layer's export surface is the window globals it has always had
|
||||
(window.BOR_BRAND, window.BOR_CONFIG_PROMISE); the shared pass joins
|
||||
them so the page's MODULE scripts — assets/settings.js and
|
||||
assets/theme.js, which cannot import from a classic script — call the
|
||||
ONE function instead of copying a step (phase 190, task 03). */
|
||||
window.applyBrandFromConfig = applyBrandFromConfig;
|
||||
|
||||
/* The boot call site — unchanged in behavior (phase 39): it awaits the
|
||||
parse-time fetch and hands the settled payload to the shared pass. */
|
||||
function applyBrand() {
|
||||
BOR_CONFIG_PROMISE.then((cfg) => applyBrandFromConfig(cfg));
|
||||
}
|
||||
|
||||
/* The DOM passes run once the document is ready AND the config is
|
||||
|
||||
@@ -81,7 +81,7 @@ const VIEW = {
|
||||
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
|
||||
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
||||
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
|
||||
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
|
||||
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette — colors only since phase 190)
|
||||
"/settings.html": "settings", // phase 168 task 05: the Settings view (admin settings panel)
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@ const DESCRIPTIONS = {
|
||||
history:
|
||||
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
|
||||
tokens: "Generate and revoke the API tokens that let people use the app.",
|
||||
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
|
||||
theme: "Set the palette — the theme is baked into every served page, live on the first paint.",
|
||||
settings: "Edit any application setting — a saved value wins over the environment, live on save; reset hands control back.",
|
||||
};
|
||||
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
* button, Escape / No cancels) → DELETE /api/admin/settings →
|
||||
* the status line `Cleared N overrides.` (N from the
|
||||
* response) + a re-GET.
|
||||
* • the live brand re-apply (phase 190, task 03 — A2, the second half
|
||||
* of "takes effect on clicking save") — when a save / Reset / Clear
|
||||
* all touched one of the three brand strings (`app_name`,
|
||||
* `input_placeholder`, `footer_text` — the panel's own keys), the
|
||||
* OPEN document is re-branded from a fresh `GET /api/config`
|
||||
* through `window.applyBrandFromConfig` (the ONE pass brand.js owns
|
||||
* and the boot runs — this file never writes the title / brand /
|
||||
* footer / placeholder itself). No brand key in the batch → no
|
||||
* extra request; a failed re-apply leaves the document exactly as
|
||||
* it was (never a blanked brand); the catalogue re-GET stays the
|
||||
* catalogue's own refresh. The Theme tab (theme.js) runs the SAME
|
||||
* shared pass after its own save / reset.
|
||||
* • search — #settings-filter: a case-insensitive substring over
|
||||
* the label + BOR_* name cells, applied on input with NO fetch
|
||||
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
|
||||
@@ -447,6 +459,48 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
|
||||
* The phase-168 header contract is "the change should take effect on
|
||||
* clicking 'save'" — for the three BRAND strings that means the OPEN
|
||||
* document too, not just the catalogue. One pass, ONE owner:
|
||||
* brand.js exports the boot's own function as
|
||||
* window.applyBrandFromConfig, and this helper feeds it a fresh
|
||||
* GET /api/config (the same no-store request the boot makes).
|
||||
*
|
||||
* • gated on the key — a save / Reset of any of the ~72 other rows
|
||||
* costs NO extra request and no DOM churn; Clear all passes all
|
||||
* three because clearing hands every one of them back to the
|
||||
* Theme-tab row or the environment;
|
||||
* • the §7.4 guard, EXTENDED not forked — the save itself is already
|
||||
* inside `saveInFlight` (this helper is awaited before that flag
|
||||
* drops, so a second Save can never start mid-re-apply) and the
|
||||
* one flag below covers the settle paths that share the pass, so a
|
||||
* second save / Reset / Clear all never stacks a second fetch;
|
||||
* • never a blanked brand — a non-2xx or an unreachable app returns
|
||||
* WITHOUT touching the DOM (the phase-168 "never a blanked panel"
|
||||
* precedent): the open page keeps its last applied name, footer
|
||||
* and placeholder, and the save's own status / error line stays
|
||||
* the visibility. */
|
||||
const BRAND_KEYS = ["app_name", "input_placeholder", "footer_text"];
|
||||
let brandReapplyInFlight = false;
|
||||
|
||||
async function reapplyBrand(keys) {
|
||||
if (!keys.some((key) => BRAND_KEYS.includes(key))) return; // not a brand row
|
||||
if (brandReapplyInFlight) return; // one re-apply at a time (§7.4)
|
||||
if (typeof window.applyBrandFromConfig !== "function") return; // brand.js is the owner
|
||||
brandReapplyInFlight = true;
|
||||
try {
|
||||
const r = await fetch("/api/config", { cache: "no-store" });
|
||||
if (!r.ok) return; // the brand keeps its last applied state
|
||||
window.applyBrandFromConfig(await r.json());
|
||||
} catch {
|
||||
/* unreachable app or a non-JSON body — nothing is written (the
|
||||
brand never blanks; the settle path's own line says it) */
|
||||
} finally {
|
||||
brandReapplyInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Save (dirty rows only — stop at the first failure,
|
||||
K4; the §7.4 never-stale lifecycle) ---------- */
|
||||
async function saveSettings() {
|
||||
@@ -499,6 +553,9 @@ export async function mount(root) {
|
||||
);
|
||||
const input = root.querySelector("#set-" + failure.key);
|
||||
if (input) input.focus();
|
||||
/* A brand row that DID land before the failure changed the app
|
||||
(K4 saves the prefix) — the open document re-reads it too. */
|
||||
await reapplyBrand(saved);
|
||||
} else {
|
||||
/* One status line (role=status) — a saved restart-required
|
||||
row says so (the owner is never told "saved" and then sees
|
||||
@@ -511,6 +568,10 @@ export async function mount(root) {
|
||||
(anyRestart ? " — applies on the next restart" : ""),
|
||||
);
|
||||
await loadSettings(); // every badge/value is the settled state
|
||||
/* …and the brand strings apply to the OPEN document (no reload) —
|
||||
additive to the catalogue re-GET above, still inside the
|
||||
saveInFlight guard (phase 190, task 03). */
|
||||
await reapplyBrand(saved);
|
||||
}
|
||||
saveInFlight = false;
|
||||
if (saveBtn) {
|
||||
@@ -535,6 +596,7 @@ export async function mount(root) {
|
||||
}
|
||||
announce(`${envVar} is back to the environment value.`);
|
||||
await loadSettings(); // the badge falls back to env/default
|
||||
await reapplyBrand([key]); // …and the open document falls back too
|
||||
}
|
||||
|
||||
/* ---------- Clear all overrides (the house two-step inline
|
||||
@@ -570,6 +632,7 @@ export async function mount(root) {
|
||||
restore();
|
||||
announce(`Cleared ${cleared} overrides.`);
|
||||
await loadSettings(); // every badge falls back to env/default
|
||||
await reapplyBrand(BRAND_KEYS); // every brand string may have moved
|
||||
}
|
||||
|
||||
function showClearAllConfirm() {
|
||||
|
||||
+107
-50
@@ -1,5 +1,7 @@
|
||||
/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
|
||||
* task 04): the admin palette + branding editor.
|
||||
* task 04; COLORS ONLY since phase 190, task 02): the admin palette
|
||||
* editor — the three brand strings moved to the admin Settings panel
|
||||
* (A1: that panel is their single writable owner).
|
||||
*
|
||||
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
|
||||
* shape): the router (assets/router.js) lazy-imports this module on
|
||||
@@ -17,19 +19,22 @@
|
||||
* gate (the #nav-theme link is already hidden by header.js — the
|
||||
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
|
||||
* /api/ui-settings request is ever made outside the admin branch.
|
||||
* • load — GET /api/ui-settings → populate the 20 inputs with the
|
||||
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
|
||||
* merge): the tab always shows the live theme — env defaults when
|
||||
* the row is empty. A failed fetch keeps the static form (the
|
||||
* built-in values ship in the inputs) and shows #theme-error with
|
||||
* a retry (the loadHealth house style — never a blanked panel).
|
||||
* • live preview (colors only, B4) — on `input` of any of the 17
|
||||
* color pickers the value is written straight onto <html> as an
|
||||
* inline custom property, so the WHOLE page repaints (every view,
|
||||
* the header) while the owner is picking. Text fields have NO page
|
||||
* effect: the 3 strings keep the brand.js runtime application
|
||||
* (owner-locked B4) — they apply via the /api/config boot fetch on
|
||||
* the NEXT page load, and the sub-copy says so.
|
||||
* • load — GET /api/ui-settings → populate the 17 color inputs with
|
||||
* the EFFECTIVE values (the resolver's DB-over-env /
|
||||
* DB-over-built-in merge): the tab always shows the live theme —
|
||||
* the built-in palette when the row is empty. A failed fetch keeps
|
||||
* the static form (the built-in values ship in the inputs) and
|
||||
* shows #theme-error with a retry (the loadHealth house style —
|
||||
* never a blanked panel). The response still carries the 3 brand
|
||||
* strings (read-only, old-client compat) — populate() simply has no
|
||||
* field to write them into.
|
||||
* • live preview (colors only) — on `input` of any of the 17 color
|
||||
* pickers the value is written straight onto <html> as an inline
|
||||
* custom property, so the WHOLE page repaints (every view, the
|
||||
* header) while the owner is picking. The brand strings are not
|
||||
* this tab's surface at all since phase 190 (A1) — the Settings
|
||||
* panel edits them (and, from phase 190 task 03, applies them to
|
||||
* the open document on save).
|
||||
* • favicon live preview (phase 177, A4 — owner-confirmed
|
||||
* 2026-10-13) — a pick of one of the THREE driving fields
|
||||
* (``surface`` / ``brand`` / ``brand_ink`` — the artwork's
|
||||
@@ -95,10 +100,10 @@
|
||||
* the admin opens the tab (a normal load is a no-op — the served
|
||||
* tag and the overrides agree).
|
||||
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
|
||||
* PUT /api/ui-settings with the 20 form values (a cleared/empty
|
||||
* text field → null; colors always their current hex — the
|
||||
* server's built-in→NULL normalization keeps the row empty when
|
||||
* the owner saves the defaults) → 200: #theme-result "Theme
|
||||
* PUT /api/ui-settings with the 17 form colors (always their
|
||||
* current hex — the server's built-in→NULL normalization keeps the
|
||||
* row empty when the owner saves the defaults; NO string key in
|
||||
* the body, phase 190 A1) → 200: #theme-result "Theme
|
||||
* saved." (role=status), refetch + re-populate (canonical state),
|
||||
* reconcile the open document to the settled values (#bor-theme
|
||||
* text + the <html> overrides — the OPEN page paints the saved
|
||||
@@ -111,13 +116,26 @@
|
||||
* field), the form is KEPT (the owner fixes + retries); any other
|
||||
* non-2xx: the fixed error line; a network error: the "is the app
|
||||
* reachable?" line.
|
||||
* • Reset — the same lifecycle ("Resetting…") with all 20 values
|
||||
* null (the API's documented "defaults" operation) → #theme-result
|
||||
* • Reset — the same lifecycle ("Resetting…") with all 17 colors
|
||||
* null (the API's documented "defaults" operation for the palette;
|
||||
* a brand string is reset from the Settings panel, phase 190 A1) →
|
||||
* #theme-result
|
||||
* "Reset to the built-in theme." → refetch + re-populate (the
|
||||
* env/built-in defaults) → reconcile the open document: the tag
|
||||
* is REMOVED (effective = the built-ins → content null) and the
|
||||
* <html> overrides are dropped (a failed refetch still drops
|
||||
* them — the picks are stale once the reset landed).
|
||||
* • the live brand re-apply (phase 190, task 03 — A2) — after BOTH
|
||||
* settle paths (Save success and Reset success) the open document
|
||||
* re-reads `GET /api/config` through `window.applyBrandFromConfig`,
|
||||
* the ONE brand pass brand.js owns and the boot runs (this file
|
||||
* never writes the title / brand / footer / composer itself). The tab
|
||||
* no longer EDITS the strings (A1) and its PUT never writes those
|
||||
* columns — so the re-read is the SELF-HEAL half: whatever the server
|
||||
* resolves (panel row → the legacy tab row → env) is what the open
|
||||
* page shows the moment the settle lands, including a name the panel
|
||||
* changed while this tab was open. One request at a time; a failed
|
||||
* re-apply leaves the document as it was (never a blanked brand).
|
||||
* • WCAG contrast (the phase-93 design's EIGHT pairs — the pairs
|
||||
* the layout actually pairs, see app/core/theming.py's docstring —
|
||||
* this file's PAIRS is its client-side mirror, and the two must
|
||||
@@ -168,21 +186,22 @@ export async function mount(root) {
|
||||
const SAVE_LABEL = "Save theme";
|
||||
const RESET_LABEL = "Reset to defaults";
|
||||
|
||||
/* The 20 form fields, in the form's order: `field` is the API key
|
||||
(the input's name attribute), `id` the E2E-stable element id,
|
||||
`kind` how the value is read for a PUT — a string field that is
|
||||
empty after the trim sends null (the server stores NULL = "use
|
||||
the default"); a color field always sends its current #rrggbb
|
||||
(the server's built-in→NULL normalization keeps the row empty
|
||||
when the owner saves the defaults). The 17 color fields follow
|
||||
the server's theming.COLOR_FIELDS order (the 9 identity
|
||||
variables, then the 8 semantic state variables — phase 93), so
|
||||
everything FIELDS-driven (live preview, collectBody,
|
||||
/* The 17 color form fields, in the form's order: `field` is the API
|
||||
key (the input's name attribute), `id` the E2E-stable element id,
|
||||
`kind` how the value is read for a PUT — a color field always
|
||||
sends its current #rrggbb (the server's built-in→NULL
|
||||
normalization keeps the row empty when the owner saves the
|
||||
defaults). THE 3 BRAND STRINGS ARE NOT HERE (phase 190, A1 —
|
||||
owner-confirmed 2026-10-18): the admin Settings panel is the
|
||||
single writable owner of app_name / input_placeholder /
|
||||
footer_text, so the tab renders no string input and its PUT body
|
||||
carries no string key — a Theme-tab save can never re-bake a name
|
||||
and shadow the panel (the TODO.md L3 incident). The 17 color
|
||||
fields follow the server's theming.COLOR_FIELDS order (the 9
|
||||
identity variables, then the 8 semantic state variables — phase
|
||||
93), so everything FIELDS-driven (live preview, collectBody,
|
||||
applyServedTheme's tag content) stays in COLOR_FIELDS order. */
|
||||
const FIELDS = [
|
||||
{ field: "app_name", id: "theme-app-name", kind: "string" },
|
||||
{ field: "input_placeholder", id: "theme-placeholder", kind: "string" },
|
||||
{ field: "footer_text", id: "theme-footer", kind: "string" },
|
||||
{ field: "bg", id: "theme-bg", kind: "color" },
|
||||
{ field: "surface", id: "theme-surface", kind: "color" },
|
||||
{ field: "ink", id: "theme-ink", kind: "color" },
|
||||
@@ -324,8 +343,8 @@ export async function mount(root) {
|
||||
* inline style beats the served <style id="bor-theme"> :root, so
|
||||
* the whole page repaints live while the owner is picking. An empty
|
||||
* value removes the override (the page falls back to the served
|
||||
* theme). Text fields write NOTHING here — the strings apply via
|
||||
* brand.js on the next page load (the sub-copy says so). */
|
||||
* theme). Only the 17 color pickers bind here — the tab has no
|
||||
* string input at all since phase 190 (A1). */
|
||||
function previewColor(field, value) {
|
||||
if (value) {
|
||||
document.documentElement.style.setProperty(cssVar(field), value);
|
||||
@@ -488,9 +507,10 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* GET /api/ui-settings → populate the 20 inputs with the EFFECTIVE
|
||||
values (the tab always shows the live theme — env defaults when
|
||||
the row is empty) and re-check the eight pairs (a SAVED palette
|
||||
/* GET /api/ui-settings → populate the 17 color inputs with the
|
||||
EFFECTIVE values (the tab always shows the live theme — the
|
||||
built-in palette when the row is empty) and re-check the eight
|
||||
pairs (a SAVED palette
|
||||
can itself fail AA — the warning then tracks it). A failed fetch
|
||||
keeps the static form + shows #theme-error with a retry (the
|
||||
loadHealth house style — never a blanked panel). Returns the
|
||||
@@ -524,17 +544,16 @@ export async function mount(root) {
|
||||
/* ---------- the PUT (Save + Reset share it) ---------- */
|
||||
|
||||
function collectBody() {
|
||||
/* All 20 fields — the 3 strings + the 17 colors (the 9 identity
|
||||
+ the 8 semantic state, phase 93). */
|
||||
/* The 17 colors ONLY (the 9 identity + the 8 semantic state) — no
|
||||
string key ever rides the wire (phase 190, A1: the Settings
|
||||
panel owns the brand strings, and the server ignores them in
|
||||
this body anyway — an old cached client can no longer re-bake
|
||||
one). */
|
||||
const body = {};
|
||||
for (const f of FIELDS) {
|
||||
const input = inputs[f.field];
|
||||
const value = input ? input.value : "";
|
||||
if (f.kind === "string") {
|
||||
body[f.field] = value.trim() || null; // cleared field → null
|
||||
} else {
|
||||
body[f.field] = isHex(value) ? value : null; // colors: their hex
|
||||
}
|
||||
body[f.field] = isHex(value) ? value : null; // colors: their hex
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -577,6 +596,39 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
|
||||
* The reconcile above owns the COLORS; the three brand STRINGS are the
|
||||
* Settings panel's (A1), and this tab's PUT never writes those columns.
|
||||
* The re-read is still owed: the effective string can move under an
|
||||
* open tab (the panel saved or reset one, another browser changed the
|
||||
* row), and every settle is the moment the document is re-branded to
|
||||
* what the server actually resolves. Both settle paths therefore run the
|
||||
* SAME shared
|
||||
* pass brand.js owns and the boot runs:
|
||||
* fetch("/api/config") → window.applyBrandFromConfig(cfg). One code
|
||||
* path for Save and Reset (the PUT body difference is the colors), one
|
||||
* request at a time (§7.4 — a second settle never stacks a second
|
||||
* fetch), and NEVER a blanked brand: a non-2xx or an unreachable app
|
||||
* returns without touching the DOM, leaving the last applied name,
|
||||
* footer and placeholder (the save's own status / error line is the
|
||||
* visibility). */
|
||||
let brandReapplyInFlight = false;
|
||||
|
||||
async function reapplyBrand() {
|
||||
if (brandReapplyInFlight) return;
|
||||
if (typeof window.applyBrandFromConfig !== "function") return; // brand.js is the owner
|
||||
brandReapplyInFlight = true;
|
||||
try {
|
||||
const r = await fetch("/api/config", { cache: "no-store" });
|
||||
if (!r.ok) return; // the brand keeps its last applied state
|
||||
window.applyBrandFromConfig(await r.json());
|
||||
} catch {
|
||||
/* unreachable app or a non-JSON body — nothing is written */
|
||||
} finally {
|
||||
brandReapplyInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
|
||||
if (!outcome.ok) {
|
||||
@@ -592,11 +644,12 @@ export async function mount(root) {
|
||||
/* A failed refetch keeps the current <html> overrides on purpose:
|
||||
the PUT body came from these very inputs, so they ARE the saved
|
||||
palette — never revert onto the stale tag (the phase-91 defect). */
|
||||
await reapplyBrand(); // the strings ride the same settle (task 03)
|
||||
}
|
||||
|
||||
async function resetTheme() {
|
||||
const body = {};
|
||||
for (const f of FIELDS) body[f.field] = null; // all null = the defaults
|
||||
for (const f of FIELDS) body[f.field] = null; // 17 nulls = the defaults
|
||||
const outcome = await putSettings(body, "Resetting…", resetBtn);
|
||||
if (!outcome.ok) {
|
||||
showError(outcome.detail);
|
||||
@@ -611,6 +664,11 @@ export async function mount(root) {
|
||||
clearPreview(); // the reset landed — the picks are stale
|
||||
restoreFavicon(); // stale picks — back to the route URL
|
||||
}
|
||||
/* The palette row is back to the defaults — and the open document
|
||||
re-reads the effective brand too, so a string the panel moved
|
||||
while this tab was open paints now, not on the next load
|
||||
(phase 190, task 03). */
|
||||
await reapplyBrand();
|
||||
}
|
||||
|
||||
/* ---------- view boot (phase 91 task 05) ----------
|
||||
@@ -642,10 +700,9 @@ export async function mount(root) {
|
||||
|
||||
/* Bindings — armed BEFORE the first load: a fast owner can start
|
||||
picking while the GET is still out; the preview writes are
|
||||
idempotent and the settled load re-populates afterwards. Color
|
||||
inputs drive the live preview + the contrast re-check; text
|
||||
inputs drive neither (B4 — the strings apply on the next page
|
||||
load, the sub-copy says so). */
|
||||
idempotent and the settled load re-populates afterwards. The 17
|
||||
color inputs drive the live preview + the contrast re-check (the
|
||||
brand strings are the Settings panel's surface, phase 190 A1). */
|
||||
for (const f of FIELDS) {
|
||||
const input = inputs[f.field];
|
||||
if (!input || f.kind !== "color") continue;
|
||||
|
||||
+17
-48
@@ -1275,8 +1275,9 @@
|
||||
|
||||
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
|
||||
folded view (the phase-76 fold pattern, the phase-79 Tokens
|
||||
view as the most recent precedent): the admin-only palette +
|
||||
branding editor. /theme.html serves THIS document (the shell
|
||||
view as the most recent precedent): the admin-only palette
|
||||
editor (the brand strings are the Settings panel's since phase
|
||||
190). /theme.html serves THIS document (the shell
|
||||
route in app/main.py); the router shows this section for that
|
||||
pathname. The CSS-file theming (BOR_THEME, phase 62) is
|
||||
retired (task 03): the effective theme is injected into every
|
||||
@@ -1297,7 +1298,7 @@
|
||||
<div class="container theme-shell">
|
||||
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
|
||||
#sources-gate pattern (phase 16) and the same .sources-gate
|
||||
visual language: the palette + branding is what the login
|
||||
visual language: the palette is what the login
|
||||
locks (B5, owner-locked 2026-09-09). Visible for
|
||||
anonymous, hidden for the admin (theme.js). The gate's
|
||||
Sign in returns to the Theme view (the header's ?next=
|
||||
@@ -1308,7 +1309,7 @@
|
||||
</div>
|
||||
<h2 id="theme-gate-title">Sign in to change the theme</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The palette and branding are admin-only. Chat — and any
|
||||
The palette is admin-only. Chat — and any
|
||||
document an answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
|
||||
@@ -1325,7 +1326,9 @@
|
||||
Changes preview live as you pick;
|
||||
<strong>Save theme</strong> bakes the palette into every
|
||||
page it is served on — it applies on the first paint, no
|
||||
pop-in.
|
||||
pop-in. The app name, the chat placeholder and the footer
|
||||
line are the Settings panel's: it owns the brand strings
|
||||
and applies them the moment they are saved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1333,50 +1336,16 @@
|
||||
the bindings — effective-value populate, the live preview,
|
||||
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
|
||||
in #theme-contrast). No real submit: both buttons are
|
||||
type="button"; maxlength=300 mirrors the server's 300-char
|
||||
limit (the server re-validates — 422 naming the field).
|
||||
The color inputs ship the BUILT-IN values (app/core/
|
||||
theming.py BUILTIN_COLORS) — task 05 re-populates them
|
||||
with the EFFECTIVE values on mount. -->
|
||||
type="button". The 17 color inputs ship the BUILT-IN
|
||||
values (app/core/theming.py BUILTIN_COLORS) — task 05
|
||||
re-populates them with the EFFECTIVE values on mount.
|
||||
Phase 190 (task 02, A1): the old "Branding" fieldset and
|
||||
its three string inputs (#theme-app-name /
|
||||
#theme-placeholder / #theme-footer) are GONE — the admin
|
||||
Settings panel is the single writable owner of the brand
|
||||
strings, so the tab neither shows nor sends them (a save
|
||||
here can no longer shadow a panel value). -->
|
||||
<form id="theme-form" novalidate>
|
||||
<fieldset class="theme-group">
|
||||
<legend class="theme-group-title">Branding</legend>
|
||||
<!-- B4 (owner-locked): the strings keep the brand.js
|
||||
runtime application — they apply via the /api/config
|
||||
boot fetch on the NEXT page load; the live preview
|
||||
covers the palette only. An empty field restores the
|
||||
default (the env value). -->
|
||||
<p class="theme-note">
|
||||
These strings apply on the next page load — the live
|
||||
preview covers the palette only. Leaving a field empty
|
||||
restores its default.
|
||||
</p>
|
||||
<label for="theme-app-name">App name</label>
|
||||
<input
|
||||
id="theme-app-name"
|
||||
name="app_name"
|
||||
type="text"
|
||||
maxlength="300"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label for="theme-placeholder">Chat input placeholder</label>
|
||||
<input
|
||||
id="theme-placeholder"
|
||||
name="input_placeholder"
|
||||
type="text"
|
||||
maxlength="300"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label for="theme-footer">Footer line</label>
|
||||
<input
|
||||
id="theme-footer"
|
||||
name="footer_text"
|
||||
type="text"
|
||||
maxlength="300"
|
||||
autocomplete="off"
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="theme-group">
|
||||
<!-- The eight text/background pairs (the five identity
|
||||
pairs + the three semantic ink-on-bg pairs from the
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Unit: the live-brand-on-save marker pins (phase 190, tasks 01 + 03).
|
||||
|
||||
Defect (b) of the phase (``TODO.md`` L3, the stale half): a save in
|
||||
either admin surface never re-applied the brand strings to the OPEN
|
||||
document. ``settings.js`` re-GETed the catalogue after a save but never
|
||||
touched the brand; ``theme.js``'s save ran ``applyServedTheme`` (the
|
||||
phase-92 COLOR reconcile) and stopped there; ``brand.js`` applied the
|
||||
strings ONCE at boot from ``/api/config`` — so the open page kept the old
|
||||
name until a manual reload.
|
||||
|
||||
Task 01 pinned that as the ABSENT behavior; **task 03 inverted it**, and
|
||||
this module now pins the fix:
|
||||
|
||||
* ``APPLY_BRAND`` (``applyBrandFromConfig``) is ONE function in
|
||||
``brand.js`` — the single owner of the brand DOM — exported on the
|
||||
layer's existing ``window`` surface and called by the BOOT
|
||||
(``applyBrand``) and by both admin surfaces. The boot is the
|
||||
EXTRACTION, not a duplication: the four DOM writes exist once;
|
||||
* ``settings.js`` re-applies after a save / per-row Reset / Clear all
|
||||
that touched one of the three brand keys, and after nothing else;
|
||||
* ``theme.js`` re-applies after Save and after Reset (a reset can make a
|
||||
legacy baked-in string fall back, so the page must re-read it);
|
||||
* a FAILED re-apply leaves the DOM at its last applied state — the
|
||||
phase-168 "never a blanked panel" precedent applied to the brand.
|
||||
|
||||
House JS convention (``test_frontend_brand.py`` /
|
||||
``test_settings_frontend_view.py`` / ``test_save_chat_ui.py``): the
|
||||
browser behavior is pinned at source-marker level here, E2E in the
|
||||
phase's ``tests/e2e/test_app_name_settings_fix.py`` (task 04).
|
||||
|
||||
``BRAND_DOM_TOKENS`` (the four writes that constitute "applying the
|
||||
brand strings": the document title, the ``.brand-text`` header, the
|
||||
``#message-input`` composer placeholder, the ``.footer-text`` line) stays
|
||||
the ownership pin it was in task 01 — those belong to the ONE shared pass
|
||||
in ``brand.js`` and must NEVER be duplicated inside ``settings.js`` / the
|
||||
``theme.js`` save paths. The live-apply is asserted by presence of the
|
||||
shared CALL, never by a second copy of the DOM path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS = Path(__file__).resolve().parents[2] / "frontend" / "assets"
|
||||
BRAND_JS = ASSETS / "brand.js"
|
||||
SETTINGS_JS = ASSETS / "settings.js"
|
||||
THEME_JS = ASSETS / "theme.js"
|
||||
|
||||
#: The ONE shared brand pass task 03 extracted out of ``brand.js`` (the
|
||||
#: phase-190 contract name) — boot and live-apply run this function.
|
||||
APPLY_BRAND = "applyBrandFromConfig"
|
||||
|
||||
#: The four DOM writes that ARE "re-applying the brand strings" — the
|
||||
#: shared pass owns them; a save surface that writes one of these
|
||||
#: directly has duplicated the brand path (the defect the phase fixes
|
||||
#: must not be re-introduced as a second copy).
|
||||
BRAND_DOM_TOKENS = ("document.title", "brand-text", "message-input", "footer-text")
|
||||
|
||||
#: The three keys the Settings panel owns and whose save/reset therefore
|
||||
#: re-applies the brand (phase 190 A1). A save of any other row must not.
|
||||
BRAND_KEYS = ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
#: The two statements every live re-apply is made of, pinned verbatim so
|
||||
#: the counts below count CALL SITES and not prose (the house
|
||||
#: ``= fetch("/api/config"`` idiom in ``test_frontend_brand.py``). Both
|
||||
#: surfaces write them identically — the shared semantics, one shape.
|
||||
CONFIG_FETCH = 'const r = await fetch("/api/config", { cache: "no-store" });'
|
||||
SHARED_CALL = "window.applyBrandFromConfig(await r.json());"
|
||||
|
||||
# The three settings.js settle paths (save / per-row Reset / clear-all) —
|
||||
# each ends its success arm with the catalogue re-GET (phase 168,
|
||||
# untouched) followed by the brand re-apply (phase 190 task 03, additive).
|
||||
SETTINGS_SETTLE_PATHS = (
|
||||
"async function saveSettings()",
|
||||
"async function resetRow(",
|
||||
"async function confirmClearAll(",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _body(js: str, header: str, closer: str = "\n }") -> str:
|
||||
"""The source of the function whose declaration starts at ``header``
|
||||
up to its closing brace — the house slice idiom
|
||||
(``test_settings_frontend_view.py``): these view modules nest their
|
||||
functions at two spaces inside ``mount``, so the first ``\\n }``
|
||||
after the header closes it (nested blocks close at four).
|
||||
``closer`` opens the same idiom to top-level functions (``brand.js``,
|
||||
whose blocks close at column 0)."""
|
||||
start = js.index(header)
|
||||
end = js.find(closer, start)
|
||||
assert end != -1, f"unterminated function body for {header!r}"
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def _every_marker_preceded_by(body: str, marker: str, before: str) -> None:
|
||||
"""Every ``marker`` occurrence sits after some ``before``
|
||||
occurrence (the order pin for a body with more than one of each)."""
|
||||
positions = []
|
||||
start = 0
|
||||
while (i := body.find(marker, start)) != -1:
|
||||
positions.append(i)
|
||||
start = i + 1
|
||||
assert positions, f"marker {marker!r} missing from the body"
|
||||
for pos in positions:
|
||||
assert body.rfind(before, 0, pos) != -1, (
|
||||
f"{marker!r} must never run before {before!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# brand.js — the ONE shared pass, exported, and the boot runs IT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_brand_js_exports_the_shared_pass() -> None:
|
||||
"""Task 03's extraction: the brand DOM lives in ONE named function
|
||||
``applyBrandFromConfig(cfg)`` and reaches the page's MODULE scripts
|
||||
through the layer's existing export surface — the ``window`` globals
|
||||
it has always had (``window.BOR_BRAND``, ``window.BOR_CONFIG_PROMISE``).
|
||||
``brand.js`` stays a classic script (no ``export`` statement —
|
||||
``test_frontend_brand.py`` owns that pin), so the assignment IS the
|
||||
export."""
|
||||
js = _read(BRAND_JS)
|
||||
assert "function applyBrandFromConfig(cfg) {" in js, (
|
||||
"the shared pass is a named function taking the /api/config payload"
|
||||
)
|
||||
assert "window.applyBrandFromConfig = applyBrandFromConfig;" in js, (
|
||||
"the shared pass is exported on the window surface settings.js / "
|
||||
"theme.js already read (window.BOR_BRAND, window.BOR_CONFIG_PROMISE)"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_call_site_is_the_extraction_not_a_second_copy() -> None:
|
||||
"""The boot behavior is byte-identical (same request, same
|
||||
assignments, same order) because the boot RUNS the shared pass:
|
||||
``applyBrand`` awaits the parse-time promise and hands the settled
|
||||
payload over — its own body contains NONE of the four DOM writes and
|
||||
each of them occurs exactly ONCE in the file (the extraction, not a
|
||||
duplication)."""
|
||||
js = _read(BRAND_JS)
|
||||
boot = _body(js, "function applyBrand() {", closer="\n}")
|
||||
assert "applyBrandFromConfig(cfg)" in boot, "the boot calls the shared pass"
|
||||
assert "BOR_CONFIG_PROMISE.then" in boot, "the boot still rides the parse-time fetch"
|
||||
for token in BRAND_DOM_TOKENS:
|
||||
assert token not in boot, (
|
||||
f"the boot must not carry its own copy of the {token!r} write — "
|
||||
"the shared pass is the ONE owner"
|
||||
)
|
||||
# Each of the four brand DOM writes exists exactly once in the file.
|
||||
assert js.count('document.querySelectorAll(".brand-text")') == 1
|
||||
assert js.count('document.querySelectorAll(".footer-text")') == 1
|
||||
assert js.count('document.querySelector("#message-input")') == 1
|
||||
# …and every `document.title` write sits inside the shared pass.
|
||||
shared = _body(js, "function applyBrandFromConfig(cfg) {", closer="\n}")
|
||||
assert js.count("document.title =") == shared.count("document.title =") >= 1
|
||||
# The boot wiring is the phase-39 one (pinned in test_frontend_brand.py).
|
||||
assert 'document.addEventListener("DOMContentLoaded", applyBrand)' in js
|
||||
assert js.count('= fetch("/api/config"') == 1, (
|
||||
"the boot fetch stays the file's only fetch — the SURFACES fetch, "
|
||||
"brand.js never fetches twice"
|
||||
)
|
||||
|
||||
|
||||
def test_the_shared_pass_reapplies_over_the_name_already_shown() -> None:
|
||||
"""A LIVE re-apply runs against a document whose literal passes
|
||||
already rewrote the default literal into the previous name — so the
|
||||
shared pass also replaces ``window.BOR_BRAND_APPLIED`` (the title,
|
||||
the prose text nodes, the aria-label/placeholder/meta attributes:
|
||||
the three places the literal passes touch). At boot the global is
|
||||
``""`` so the clause is skipped and the boot path stays exactly the
|
||||
phase-39 sequence (the byte-identical requirement)."""
|
||||
js = _read(BRAND_JS)
|
||||
shared = _body(js, "function applyBrandFromConfig(cfg) {", closer="\n}")
|
||||
assert 'window.BOR_BRAND_APPLIED = "";' in js, (
|
||||
"the previous-name global ships empty — at boot the clause never runs"
|
||||
)
|
||||
assert "window.BOR_BRAND_APPLIED" in shared, "the shared pass reads the last applied name"
|
||||
assert "if (previous && previous !== name) {" in shared, (
|
||||
"the re-apply clause is gated on a previous name that differs "
|
||||
"(the boot no-op stays byte-identical)"
|
||||
)
|
||||
assert "document.title.replaceAll(previous, name)" in shared
|
||||
assert "node.nodeValue.replaceAll(previous, name)" in shared
|
||||
assert 'window.BOR_BRAND_APPLIED = name;' in shared, (
|
||||
"the pass records what the document now shows, so the NEXT "
|
||||
"re-apply replaces the right string"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# settings.js — the panel re-applies, gated on the three brand keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_js_settle_paths_reapply_the_brand() -> None:
|
||||
"""The INVERTED task-01 pin: every settle path of the Settings panel
|
||||
(Save, the per-row Reset, Clear all) re-applies the brand to the open
|
||||
document through the shared pass — AFTER the phase-168 catalogue
|
||||
re-GET, which stays the catalogue's own refresh (untouched). Clear
|
||||
all passes all three keys (clearing hands every brand string back to
|
||||
the environment); the Reset passes its own key; Save passes the keys
|
||||
that actually landed — including the prefix of a batch that stopped
|
||||
at a later failure (K4), because that value DID take effect."""
|
||||
js = _read(SETTINGS_JS)
|
||||
assert APPLY_BRAND in js, (
|
||||
"task 03 inverted the task-01 absence pin: the panel now reaches "
|
||||
"the shared brand pass"
|
||||
)
|
||||
for header in SETTINGS_SETTLE_PATHS:
|
||||
body = _body(js, header)
|
||||
assert "await reapplyBrand(" in body, (
|
||||
f"{header} must re-apply the brand on settle"
|
||||
)
|
||||
assert "await loadSettings()" in body, (
|
||||
f"the catalogue re-GET stays ({header}) — the phase-168 "
|
||||
"refresh the re-apply EXTENDS, never replaces"
|
||||
)
|
||||
_every_marker_preceded_by(body, "await reapplyBrand(", "await loadSettings()")
|
||||
save = _body(js, "async function saveSettings()")
|
||||
assert "await reapplyBrand(saved)" in save, (
|
||||
"Save re-applies the keys that landed (the K4 prefix included)"
|
||||
)
|
||||
assert "await reapplyBrand([key])" in _body(js, "async function resetRow("), (
|
||||
"the per-row Reset re-applies the key it handed back to the env"
|
||||
)
|
||||
assert "await reapplyBrand(BRAND_KEYS)" in _body(
|
||||
js, "async function confirmClearAll("
|
||||
), "Clear all re-applies every brand key"
|
||||
|
||||
|
||||
def _brand_keys_line(js: str) -> str:
|
||||
"""The ``BRAND_KEYS`` declaration (the gate's data)."""
|
||||
start = js.index("const BRAND_KEYS = [")
|
||||
end = js.index("];", start)
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def test_settings_js_gates_the_reapply_on_the_three_brand_keys() -> None:
|
||||
"""The gate itself, in ONE helper: only ``app_name`` /
|
||||
``input_placeholder`` / ``footer_text`` re-apply. A save of any of
|
||||
the ~72 other rows costs no extra request and no DOM churn — and the
|
||||
helper is the single place the panel reaches ``/api/config``, so the
|
||||
surfaces cannot drift (the pin: exactly one shared-pass call and one
|
||||
config fetch in the file)."""
|
||||
js = _read(SETTINGS_JS)
|
||||
helper = _body(js, "async function reapplyBrand(")
|
||||
keys_line = _brand_keys_line(js)
|
||||
assert tuple(BRAND_KEYS) == (
|
||||
"app_name",
|
||||
"input_placeholder",
|
||||
"footer_text",
|
||||
), "the phase-190 brand trio is the panel's own key set"
|
||||
for key in BRAND_KEYS:
|
||||
assert f'"{key}"' in keys_line, (
|
||||
f"the gate must name {key!r} — otherwise its save never re-applies"
|
||||
)
|
||||
assert "if (!keys.some((key) => BRAND_KEYS.includes(key))) return;" in helper, (
|
||||
"a settle that touched no brand key makes NO re-apply request"
|
||||
)
|
||||
assert js.count(SHARED_CALL) == 1, (
|
||||
"ONE shared-pass call site in the panel — inside the helper"
|
||||
)
|
||||
assert js.count(CONFIG_FETCH) == 1, (
|
||||
"ONE /api/config fetch in the panel module (the helper's)"
|
||||
)
|
||||
assert '{ cache: "no-store" }' in helper, (
|
||||
"never a cached brand — the same no-store read the boot makes"
|
||||
)
|
||||
|
||||
|
||||
def test_settings_js_reapply_is_single_flight_under_the_existing_guard() -> None:
|
||||
"""The §7.4 never-stale guard, EXTENDED rather than forked: the Save
|
||||
button's own ``saveInFlight`` flag still owns the save (the re-apply
|
||||
is awaited before it drops, so a second Save cannot start while the
|
||||
config fetch is out), and the ONE flag inside the shared helper covers
|
||||
the settle paths that reach it — a second Save / Reset / Clear all
|
||||
never stacks a second ``/api/config`` fetch."""
|
||||
js = _read(SETTINGS_JS)
|
||||
helper = _body(js, "async function reapplyBrand(")
|
||||
assert "let brandReapplyInFlight = false;" in js
|
||||
assert "if (brandReapplyInFlight) return;" in helper, (
|
||||
"one re-apply at a time — a second settle never stacks a fetch"
|
||||
)
|
||||
assert "brandReapplyInFlight = true;" in helper
|
||||
finally_arm = helper[helper.index("} finally {") :]
|
||||
assert "brandReapplyInFlight = false;" in finally_arm, (
|
||||
"the flag clears in the finally — a failed re-apply can never "
|
||||
"strand the guard on (the house stuck-button contract)"
|
||||
)
|
||||
save = _body(js, "async function saveSettings()")
|
||||
guard_off = save.index("saveInFlight = false;")
|
||||
assert save.index("await reapplyBrand(saved)") < guard_off, (
|
||||
"the brand re-apply sits INSIDE the saveInFlight scope — the "
|
||||
"existing guard's reach, not a parallel one"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# theme.js — the tab re-applies after Save and after Reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_js_save_and_reset_reapply_the_brand() -> None:
|
||||
"""The Theme tab's two settle paths run the SAME shared pass after
|
||||
the phase-92 palette reconcile: the tab no longer EDITS the strings
|
||||
(A1) but a Reset nulls the stored row, and a legacy baked-in string
|
||||
then FALLS BACK (panel row → env) — the open document must re-read
|
||||
it without a reload. One code path for both surfaces' semantics
|
||||
(``reapplyBrand``, called by Save and by Reset)."""
|
||||
js = _read(THEME_JS)
|
||||
save = _body(js, "async function saveTheme()")
|
||||
reset = _body(js, "async function resetTheme()")
|
||||
for name, body in (("saveTheme", save), ("resetTheme", reset)):
|
||||
assert "await reapplyBrand()" in body, (
|
||||
f"{name} re-applies the brand strings on success"
|
||||
)
|
||||
assert body.index("applyServedTheme(settings)") < body.index(
|
||||
"await reapplyBrand()"
|
||||
), "the palette reconcile keeps its place; the brand follows it"
|
||||
assert js.count(SHARED_CALL) == 1, (
|
||||
"ONE shared-pass call site in the tab module — inside its helper"
|
||||
)
|
||||
assert js.count(CONFIG_FETCH) == 1, (
|
||||
"ONE /api/config fetch in the tab module, and the SAME statement "
|
||||
"the panel uses (one shared semantics, one shape)"
|
||||
)
|
||||
helper = _body(js, "async function reapplyBrand()")
|
||||
assert "if (brandReapplyInFlight) return;" in helper, (
|
||||
"one re-apply at a time across Save and Reset (§7.4)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the failure arm — never a blanked brand (both surfaces)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_failed_reapply_leaves_the_dom_at_its_last_applied_state() -> None:
|
||||
"""The phase-168 "never a blanked panel" precedent, applied to the
|
||||
brand: in BOTH helpers a non-2xx returns BEFORE the shared pass runs,
|
||||
and a network/JSON failure is swallowed with no DOM write at all —
|
||||
the open page keeps the name, footer and placeholder it already
|
||||
shows, and the save's own status / error line is the visibility."""
|
||||
for path in (SETTINGS_JS, THEME_JS):
|
||||
js = _read(path)
|
||||
helper = _body(js, "async function reapplyBrand(")
|
||||
assert "if (!r.ok) return;" in helper, (
|
||||
f"{path.name}: a non-2xx /api/config must never reach the DOM"
|
||||
)
|
||||
assert helper.index("if (!r.ok) return;") < helper.index(
|
||||
SHARED_CALL
|
||||
), f"{path.name}: the shared pass runs only on a settled payload"
|
||||
assert "} catch {" in helper, (
|
||||
f"{path.name}: an unreachable app / non-JSON body is caught, "
|
||||
"not thrown (the brand never blanks)"
|
||||
)
|
||||
catch_arm = helper[helper.index("} catch {") : helper.index("} finally {")]
|
||||
for token in BRAND_DOM_TOKENS:
|
||||
assert token not in catch_arm, (
|
||||
f"{path.name}: the failure arm writes nothing ({token!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the ownership pins carried unchanged from task 01
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_js_never_writes_the_brand_dom_directly() -> None:
|
||||
"""The shared-pass ownership pin — GREEN before, during AND after the
|
||||
phase: the four brand DOM writes (title, ``.brand-text``, the composer
|
||||
placeholder, ``.footer-text``) belong to the ONE brand path in
|
||||
``brand.js``; the Settings panel must never duplicate one of them
|
||||
inline. Task 03's live-apply goes through the shared
|
||||
``applyBrandFromConfig`` call, so this file stays free of the tokens
|
||||
even after the fix (no second copy of the DOM path)."""
|
||||
js = _read(SETTINGS_JS)
|
||||
for token in BRAND_DOM_TOKENS:
|
||||
assert token not in js, (
|
||||
f"settings.js must not own the brand DOM write {token!r} — "
|
||||
"the shared brand.js pass is its ONE owner"
|
||||
)
|
||||
|
||||
|
||||
def test_theme_js_save_and_reset_reconcile_colors_only() -> None:
|
||||
"""GREEN pre-phase AND through the phase: ``saveTheme``'s (and
|
||||
``resetTheme``'s) success path calls ``applyServedTheme`` — the
|
||||
phase-92 COLOR reconcile — and neither writes the brand DOM (no
|
||||
title, no ``.brand-text``, no composer placeholder, no footer line).
|
||||
Task 03's live brand reaches the document through the shared call,
|
||||
which is exactly what this pin keeps true."""
|
||||
js = _read(THEME_JS)
|
||||
save = _body(js, "async function saveTheme()")
|
||||
reset = _body(js, "async function resetTheme()")
|
||||
assert "applyServedTheme(settings)" in save, (
|
||||
"the success path keeps the phase-92 color reconcile"
|
||||
)
|
||||
assert "applyServedTheme(settings)" in reset, (
|
||||
"the reset path keeps the phase-92 color reconcile"
|
||||
)
|
||||
for name, body in (("saveTheme", save), ("resetTheme", reset)):
|
||||
for token in BRAND_DOM_TOKENS:
|
||||
assert token not in body, (
|
||||
f"{name} must stay colors-only at the DOM level — "
|
||||
f"the brand write {token!r} belongs to the shared brand pass"
|
||||
)
|
||||
@@ -292,7 +292,7 @@ def test_router_writes_active_state_title_and_meta() -> None:
|
||||
assert "Generate and revoke the API tokens that let people use the app." in js
|
||||
assert 'theme: "Theme · Brain of Reese"' in js
|
||||
assert (
|
||||
"Set the palette and branding — the theme is baked into every served page, "
|
||||
"Set the palette — the theme is baked into every served page, "
|
||||
"live on the first paint."
|
||||
) in js
|
||||
assert 'settings: "Settings · Brain of Reese"' in js
|
||||
@@ -1444,20 +1444,26 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"#theme-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
# The static form skeleton (the E2E-stable-selectors house
|
||||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
||||
# and the 17 labeled type=color palette inputs (the 9 identity
|
||||
# variables, then the 8 semantic state variables — phase 93 —
|
||||
# one per theming.COLOR_FIELDS field).
|
||||
# convention): the 17 labeled type=color palette inputs (the 9
|
||||
# identity variables, then the 8 semantic state variables — phase
|
||||
# 93 — one per theming.COLOR_FIELDS field), and NOTHING else: the
|
||||
# phase-190 (A1) strip removed the "Branding" fieldset and its
|
||||
# three string inputs — the admin Settings panel owns the brand
|
||||
# strings, so the tab neither renders nor sends them.
|
||||
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||||
"the #theme-form must be STATIC markup in the shell"
|
||||
)
|
||||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||
assert re.search(
|
||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||||
), f"missing the visible label for #{field_id}"
|
||||
assert re.search(
|
||||
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
|
||||
), f"#{field_id} must be a text input with maxlength=300"
|
||||
assert f'id="{field_id}"' not in body, (
|
||||
f"#{field_id} is gone from the Theme tab (phase 190 A1)"
|
||||
)
|
||||
assert f'for="{field_id}"' not in body, f"#{field_id}'s label is gone too"
|
||||
assert "maxlength=\"300\"" not in body, (
|
||||
"the 300-char string inputs were the branding fieldset's — gone"
|
||||
)
|
||||
assert not re.search(r"<legend[^>]*>\s*Branding\s*</legend>", body), (
|
||||
"the Branding fieldset is gone (phase 190 A1)"
|
||||
)
|
||||
for field_id in (
|
||||
"theme-bg",
|
||||
"theme-surface",
|
||||
|
||||
@@ -221,6 +221,71 @@ def test_get_engine_creates_once_and_caches() -> None:
|
||||
assert first is second
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# applied_override_keys — "which keys does the panel own?" (phase 190)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _KeyResult:
|
||||
"""The ``session.execute(select(AppSetting.key))`` shape — the one
|
||||
call :func:`applied_override_keys` makes on the caller's session."""
|
||||
|
||||
def __init__(self, keys: list[str]) -> None:
|
||||
self._keys = keys
|
||||
|
||||
def scalars(self) -> _KeyResult:
|
||||
return self
|
||||
|
||||
def all(self) -> list[str]:
|
||||
return list(self._keys)
|
||||
|
||||
|
||||
class _RecordingSession:
|
||||
"""A stand-in session: it answers the key read and counts the calls
|
||||
(the resolver's "one read for all three string fields" contract)."""
|
||||
|
||||
def __init__(self, keys: list[str]) -> None:
|
||||
self.keys = keys
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, _stmt: object) -> _KeyResult:
|
||||
self.calls += 1
|
||||
return _KeyResult(self.keys)
|
||||
|
||||
|
||||
def test_applied_override_keys_filters_orphans_and_locked_rows(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from typing import Any, cast
|
||||
"""Only a row the overlay would ACTUALLY apply counts: an orphan row
|
||||
(a field a later release removed) and a LOCKED A3 row (a
|
||||
hand-inserted ``database_url`` — read-only, never in effect) are
|
||||
excluded, so a row that changes nothing can never win a brand
|
||||
string from the Theme-tab fallback (phase 190 A1)."""
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
|
||||
session = _RecordingSession(
|
||||
["app_name", "database_url", "embedding_dim", "no_such_setting"]
|
||||
)
|
||||
assert settings_overlay.applied_override_keys(
|
||||
cast(Any, session)
|
||||
) == frozenset({"app_name"})
|
||||
assert session.calls == 1
|
||||
|
||||
|
||||
def test_applied_override_keys_kill_switch_reads_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from typing import Any, cast
|
||||
"""``BOR_SETTINGS_DB=0`` means NO row is in effect (D6) — the helper
|
||||
says so WITHOUT touching the database, so ``effective_settings``
|
||||
falls through to the Theme-tab row instead of shadowing it with a
|
||||
row the resolver ignores."""
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
|
||||
session = _RecordingSession(["app_name"])
|
||||
assert settings_overlay.applied_override_keys(cast(Any, session)) == frozenset()
|
||||
assert session.calls == 0 # short-circuited before any statement
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge — the defensive fallback (a validator change in a later release
|
||||
# blames a field no override owns and no single drop heals the set: the
|
||||
|
||||
@@ -3,7 +3,10 @@ pins).
|
||||
|
||||
The Theme tab edits ALL 17 palette variables (the 9 identity + the 8
|
||||
semantic state colors — B3 revised, owner permission 2026-09-10,
|
||||
TODO.md L3) plus the 3 branding strings: 20 form fields total. The
|
||||
TODO.md L3) and NOTHING else since phase 190 (task 02, A1: the admin
|
||||
Settings panel is the single writable owner of the 3 brand strings, so
|
||||
the tab's "Branding" fieldset, its three string inputs and the string
|
||||
half of its PUT body are gone). The
|
||||
live behavior (live preview, the Save/Reset PUT body, the served-theme
|
||||
sync, the contrast warnings) is E2E-gated (``tests/e2e/
|
||||
test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04);
|
||||
@@ -12,20 +15,25 @@ design depends on, so a silent regression is caught without a browser
|
||||
(house pattern: ``tests/unit/test_big_read_progress.py`` reads
|
||||
frontend sources and asserts on their mechanisms):
|
||||
|
||||
* ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order
|
||||
(the 3 branding strings, then the 17 colors in the server's
|
||||
``theming.COLOR_FIELDS`` order — identity, brand, then state):
|
||||
everything that iterates FIELDS (live preview, ``collectBody``'s PUT
|
||||
body, ``clearPreview``, ``applyServedTheme``'s tag content) covers
|
||||
the 8 semantic pickers automatically only if this order holds;
|
||||
* ``theme.js`` ``FIELDS`` — exactly 17 entries (the colors ONLY — no
|
||||
``kind: "string"`` entry may come back) in the FORM's order, the
|
||||
server's ``theming.COLOR_FIELDS`` order (identity, brand, then
|
||||
state): everything that iterates FIELDS (live preview,
|
||||
``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``'
|
||||
tag content) covers the 8 semantic pickers automatically only if this
|
||||
order holds — and the body ``collectBody`` builds carries no string
|
||||
key either (the phase-190 pin: an old cached client's save can no
|
||||
longer re-bake a brand string);
|
||||
* ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA
|
||||
(4.5:1) pairs: the five identity pairs + the three semantic
|
||||
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
|
||||
(decorative borders, no contrast duty — the same rule as
|
||||
``--line`` / ``--grid-line``); the docstring mirror is pinned in
|
||||
``tests/unit/test_theming.py``;
|
||||
* ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids
|
||||
+ visible labels; the 17 color inputs are ``type="color"`` and ship
|
||||
* ``index.html`` ``#view-theme`` — the 17 color inputs carry E2E-stable
|
||||
ids + visible labels, and the three removed string inputs stay absent
|
||||
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
|
||||
the phase-190 A1 strip); the color inputs are ``type="color"`` and ship
|
||||
the BUILT-IN static values — asserted against
|
||||
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
|
||||
house drift pattern — the same guard ``test_theming.py`` runs on
|
||||
@@ -88,16 +96,24 @@ def _root_declarations() -> dict[str, str]:
|
||||
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
|
||||
|
||||
|
||||
# ---------- theme.js: FIELDS — the 20 form fields, in order ----------
|
||||
# ---------- theme.js: FIELDS — the 17 color form fields, in order ----------
|
||||
|
||||
|
||||
def test_fields_lists_all_20_in_form_order() -> None:
|
||||
"""``FIELDS`` has EXACTLY 20 entries — the 3 branding strings,
|
||||
then the 17 color fields — in the form's own order (the palette
|
||||
fieldset's 9 identity pickers, then the State colors fieldset's 8
|
||||
semantic pickers), each with its E2E-stable ``theme-*`` id and
|
||||
kind (the color entries are ``kind: "color"`` — the live preview,
|
||||
``collectBody``, and the served-theme sync all key on it)."""
|
||||
def test_fields_lists_the_17_colors_and_no_strings() -> None:
|
||||
"""``FIELDS`` has EXACTLY 17 entries — the color fields ONLY — in the
|
||||
form's own order (the palette fieldset's 9 identity pickers, then
|
||||
the State colors fieldset's 8 semantic pickers), each with its
|
||||
E2E-stable ``theme-*`` id and kind (the color entries are
|
||||
``kind: "color"`` — the live preview, ``collectBody``, and the
|
||||
served-theme sync all key on it).
|
||||
|
||||
Phase 190 (task 02, A1) is what makes this a CONTRACT and not a
|
||||
coincidence: the admin Settings panel is the single writable owner
|
||||
of ``app_name`` / ``input_placeholder`` / ``footer_text``, so no
|
||||
entry may carry ``kind: "string"`` — an entry means a populated
|
||||
input AND a PUT-body key, and a string key is exactly how the
|
||||
Theme tab used to bake the then-effective name into its row and
|
||||
shadow every later panel change (``TODO.md`` L3)."""
|
||||
js = _js()
|
||||
start = js.index("const FIELDS = [")
|
||||
end = js.index("];", start)
|
||||
@@ -105,11 +121,8 @@ def test_fields_lists_all_20_in_form_order() -> None:
|
||||
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
|
||||
js[start:end],
|
||||
)
|
||||
assert len(fields) == 20, f"FIELDS must list exactly 20 entries, got {len(fields)}"
|
||||
assert len(fields) == 17, f"FIELDS must list exactly 17 entries, got {len(fields)}"
|
||||
assert [(f, i) for f, i, _ in fields] == [
|
||||
("app_name", "theme-app-name"),
|
||||
("input_placeholder", "theme-placeholder"),
|
||||
("footer_text", "theme-footer"),
|
||||
("bg", "theme-bg"),
|
||||
("surface", "theme-surface"),
|
||||
("ink", "theme-ink"),
|
||||
@@ -127,11 +140,14 @@ def test_fields_lists_all_20_in_form_order() -> None:
|
||||
("accent_bg", "theme-accent-bg"),
|
||||
("accent_ink", "theme-accent-ink"),
|
||||
("accent_line", "theme-accent-line"),
|
||||
], "FIELDS must list the 20 fields in the form's order"
|
||||
assert all(kind == "color" for _, _, kind in fields[3:]), (
|
||||
"the 17 palette entries are all kind color"
|
||||
], "FIELDS must list the 17 color fields in the form's order"
|
||||
assert all(kind == "color" for _, _, kind in fields), (
|
||||
"every palette entry is kind color — no string field is back in the tab"
|
||||
)
|
||||
assert all(kind == "string" for _, _, kind in fields[:3])
|
||||
for name in theming.STRING_FIELDS:
|
||||
assert f'{{ field: "{name}"' not in js[start:end], (
|
||||
f"{name} left the Theme tab in phase 190 — the panel owns it"
|
||||
)
|
||||
|
||||
|
||||
def test_fields_color_order_is_the_server_color_fields_order() -> None:
|
||||
@@ -153,6 +169,28 @@ def test_fields_color_order_is_the_server_color_fields_order() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_collectbody_builds_the_17_colors_and_no_string_keys() -> None:
|
||||
"""``collectBody`` — the PUT body the Save button sends — is
|
||||
FIELDS-driven and therefore carries the 17 colors ONLY: no string
|
||||
key appears anywhere in its source (phase 190, A1), and the old
|
||||
trimmed-string branch (``value.trim() || null`` — the bake) is gone.
|
||||
The server ignores body strings defensively too
|
||||
(``app/api/ui_settings.py``), so this is the client half of "a
|
||||
Theme-tab save can never shadow the Settings panel again"."""
|
||||
js = _js()
|
||||
start = js.index("function collectBody()")
|
||||
end = js.index("\n }", start)
|
||||
body_src = js[start:end]
|
||||
assert body_src, "collectBody must exist"
|
||||
for name in theming.STRING_FIELDS:
|
||||
assert name not in body_src, f"{name} must never ride the PUT body"
|
||||
assert "value.trim()" not in body_src, "the string-trim bake branch must be gone"
|
||||
assert "for (const f of FIELDS)" in body_src, (
|
||||
"the body stays FIELDS-driven — one ordering source for the PUT"
|
||||
)
|
||||
assert "isHex(value)" in body_src, "the colors keep their hex-or-null rule"
|
||||
|
||||
|
||||
# ---------- theme.js: PAIRS — exactly the eight WCAG pairs ----------
|
||||
|
||||
|
||||
@@ -197,23 +235,24 @@ def test_line_vars_are_excluded_from_pairs() -> None:
|
||||
)
|
||||
|
||||
|
||||
# ---------- index.html: the #view-theme form (20 inputs) ----------
|
||||
# ---------- index.html: the #view-theme form (17 color inputs) ----------
|
||||
|
||||
|
||||
def test_theme_view_carries_all_20_labeled_inputs() -> None:
|
||||
"""All 20 inputs are STATIC markup in the shell (the E2E-stable
|
||||
selectors convention): each with a visible ``<label for>``; the 17
|
||||
palette inputs are ``type="color"`` (the 3 branding inputs
|
||||
``type="text"``)."""
|
||||
def test_theme_view_carries_the_17_labeled_color_inputs() -> None:
|
||||
"""The 17 color inputs are STATIC markup in the shell (the
|
||||
E2E-stable selectors convention), each with a visible
|
||||
``<label for>`` and ``type="color"`` — and NO string input is in the
|
||||
view (phase 190, task 02: the "Branding" fieldset and its
|
||||
``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer``
|
||||
inputs are gone, so the tab can neither show nor send a brand
|
||||
string; the same absence is asserted in the browser by
|
||||
``tests/e2e/test_admin_theme_tab.py``)."""
|
||||
body = _theme_view(_html())
|
||||
text_ids = ("theme-app-name", "theme-placeholder", "theme-footer")
|
||||
for field_id in text_ids:
|
||||
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
|
||||
f"missing the visible label for #{field_id}"
|
||||
)
|
||||
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="text"[^>]*>', body), (
|
||||
f"#{field_id} must be a text input"
|
||||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||
assert f'id="{field_id}"' not in body, (
|
||||
f"#{field_id} must be gone from the Theme tab (phase 190 A1)"
|
||||
)
|
||||
assert f'for="{field_id}"' not in body, f"#{field_id}'s label must be gone"
|
||||
for field in theming.COLOR_FIELDS:
|
||||
field_id = f"theme-{field.replace('_', '-')}"
|
||||
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
|
||||
|
||||
+219
-3
@@ -29,6 +29,20 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
|
||||
ellipsis. The :root 17-variable set itself stays pinned by
|
||||
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
|
||||
phase adds no palette variable, B3).
|
||||
* Phase 190 (tasks 01 + 02): the brand-string OWNERSHIP pins (A1, owner
|
||||
confirmed 2026-10-18) — the Settings-panel row (``app_settings``,
|
||||
phase 168) is the single writable owner of the three strings, so the
|
||||
resolution order is panel row → ``ui_settings`` (Theme-tab) row →
|
||||
env. Task 01 pinned the order as a strict ``xfail`` (the baked-in tab
|
||||
row SHADOWED the panel — the TODO.md L3 incident); task 02 flipped
|
||||
``effective_settings`` and removed the marker, so the ownership pin
|
||||
(``test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row``)
|
||||
is GREEN here. Beside it: the no-panel-row fallback pin (the tab row
|
||||
still applies — the A1 fallback a legacy baked-in row lands in, green
|
||||
before AND after the flip), the PER-FIELD pin (a panel row for one
|
||||
string never touches the other two), and the kill-switch pin
|
||||
(``BOR_SETTINGS_DB=0`` → no row is in effect, so the tab row keeps
|
||||
its phase-91 fallback).
|
||||
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
|
||||
renderer, byte-identical for the built-in palette (the golden
|
||||
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
|
||||
@@ -47,12 +61,13 @@ import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
import pytest
|
||||
from sqlalchemy import select, text, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.config import Settings, invalidate_settings
|
||||
from app.core import theming
|
||||
from app.models import UiSettings
|
||||
from app.models import AppSetting, UiSettings
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
|
||||
@@ -72,6 +87,16 @@ def _start_row_missing(db: Session) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _clear_panel_rows(db: Session) -> None:
|
||||
"""Phase 190: the ``app_settings`` rows (the Settings-panel surface,
|
||||
phase 168) are global state exactly like the single ``ui_settings``
|
||||
row — wipe them so the ownership pins start AND end with an empty
|
||||
panel (a surviving row would shadow every later suite's strings
|
||||
once the overlay is enabled)."""
|
||||
db.execute(text("DELETE FROM app_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _root_declarations() -> dict[str, str]:
|
||||
"""The ``--name: value`` declarations of styles.css's (first)
|
||||
``:root`` block, comments stripped, in file order."""
|
||||
@@ -133,6 +158,38 @@ def _env_settings() -> Settings:
|
||||
)
|
||||
|
||||
|
||||
def _panel_settings() -> Settings:
|
||||
"""The OVERLAY-RESOLVED ``Settings`` (phase 190): the object
|
||||
:func:`app.config.get_settings` hands every consumer when the
|
||||
Settings panel holds ``app_name`` / ``input_placeholder`` /
|
||||
``footer_text`` rows — the DB rows layered over the env base
|
||||
(``_env_settings`` standing for that base). The ownership pin
|
||||
pairs this explicit object with REAL committed ``app_settings``
|
||||
rows so the contract holds whichever side of the overlay the
|
||||
resolver consults — the panel row is the source, never the bare
|
||||
env value.
|
||||
"""
|
||||
return Settings(
|
||||
app_name="Panel Name",
|
||||
input_placeholder="Panel placeholder…",
|
||||
footer_text="Panel footer",
|
||||
)
|
||||
|
||||
|
||||
def _seed_panel_rows(db: Session) -> None:
|
||||
"""The three Settings-panel rows, committed (production writes them
|
||||
through ``PUT /api/admin/settings/{key}`` — the resolver only ever
|
||||
sees the table, so seeding it directly IS the panel state)."""
|
||||
db.add_all(
|
||||
[
|
||||
AppSetting(key="app_name", value="Panel Name"),
|
||||
AppSetting(key="input_placeholder", value="Panel placeholder…"),
|
||||
AppSetting(key="footer_text", value="Panel footer"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
|
||||
|
||||
|
||||
@@ -193,6 +250,165 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- phase 190 (task 01): the brand-string ownership (A1) ----------
|
||||
|
||||
|
||||
def test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A1 (owner-confirmed 2026-10-18): the Settings panel is the SINGLE
|
||||
writable owner of the three brand strings, so an ``app_settings``
|
||||
row beats a conflicting ``ui_settings`` (Theme-tab) row for EVERY
|
||||
one of them — the resolution order is panel row → tab row → env.
|
||||
|
||||
GREEN since task 02 flipped the order (phase 190 task 01 pinned it
|
||||
as a strict ``xfail`` on the defect: ``effective_settings`` used to
|
||||
return the ``ui_settings`` value whenever it was a non-empty string,
|
||||
so a Theme-tab save BAKED the then-effective name into the row and
|
||||
every later Settings-panel change was SHADOWED — the exact
|
||||
``TODO.md`` L3 incident, "changing the App name in settings broke,
|
||||
it no longer updates the name").
|
||||
"""
|
||||
_start_row_missing(db)
|
||||
_clear_panel_rows(db)
|
||||
_seed_panel_rows(db)
|
||||
db.add(
|
||||
UiSettings(
|
||||
id=1,
|
||||
app_name="Tab Name",
|
||||
input_placeholder="Tab placeholder…",
|
||||
footer_text="Tab footer",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # the overlay is the panel's mechanism
|
||||
try:
|
||||
effective = theming.effective_settings(db, _panel_settings())
|
||||
assert effective["app_name"] == "Panel Name" # pre-fix: "Tab Name" (the shadow)
|
||||
assert effective["input_placeholder"] == "Panel placeholder…"
|
||||
assert effective["footer_text"] == "Panel footer"
|
||||
# The ownership flip is STRINGS-only — the tab row keeps the
|
||||
# colors half untouched (the 17-color palette is unchanged).
|
||||
assert effective["bg"] == theming.BUILTIN_COLORS["bg"]
|
||||
assert effective["brand"] == theming.BUILTIN_COLORS["brand"]
|
||||
finally:
|
||||
_clear_panel_rows(db)
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
|
||||
|
||||
def test_effective_strings_fall_back_to_ui_settings_row_without_panel_row(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The A1 FALLBACK (no data migration): a ``ui_settings`` row set and
|
||||
NO Settings-panel row means the tab value still applies (a legacy
|
||||
baked-in row keeps rendering what it holds until the panel
|
||||
overrides it) — the env value stays the LAST resort.
|
||||
|
||||
GREEN pre-phase AND post-phase: the pin that protects the fallback
|
||||
branch of the new order. An implementation that lets the plain env
|
||||
``Settings`` value beat the tab row (instead of consulting the
|
||||
actual ``app_settings`` rows for a real override) fails here.
|
||||
"""
|
||||
_start_row_missing(db)
|
||||
_clear_panel_rows(db)
|
||||
db.add(
|
||||
UiSettings(
|
||||
id=1,
|
||||
app_name="Tab Name",
|
||||
input_placeholder="Tab placeholder…",
|
||||
footer_text="Tab footer",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # overlay live, panel table simply empty
|
||||
try:
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert effective["app_name"] == "Tab Name"
|
||||
assert effective["input_placeholder"] == "Tab placeholder…"
|
||||
assert effective["footer_text"] == "Tab footer"
|
||||
finally:
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
|
||||
|
||||
def test_effective_strings_resolve_per_field_panel_then_tab_then_env(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The order is PER FIELD, not per row (phase 190 task 02): one
|
||||
Settings-panel row owns exactly its own key, so ``app_name`` reads
|
||||
the panel while ``input_placeholder`` falls to the tab row and
|
||||
``footer_text`` — absent from BOTH rows — falls to the env value.
|
||||
The panel row that equals the env value still wins, too: presence,
|
||||
never a value compare (a compare is how the shadow would sneak
|
||||
back — an owner who sets the panel name to what the env already
|
||||
says would see a legacy tab bake instead)."""
|
||||
_start_row_missing(db)
|
||||
_clear_panel_rows(db)
|
||||
db.add(AppSetting(key="app_name", value="Panel Name"))
|
||||
db.add(UiSettings(id=1, app_name="Tab Name", input_placeholder="Tab placeholder…"))
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
|
||||
env = _env_settings()
|
||||
try:
|
||||
effective = theming.effective_settings(
|
||||
db,
|
||||
Settings(
|
||||
app_name="Panel Name",
|
||||
input_placeholder=env.input_placeholder,
|
||||
footer_text=env.footer_text,
|
||||
),
|
||||
)
|
||||
assert effective["app_name"] == "Panel Name" # the panel row (tab row ignored)
|
||||
assert effective["input_placeholder"] == "Tab placeholder…" # no panel row → tab
|
||||
assert effective["footer_text"] == env.footer_text # neither row → env
|
||||
# A panel row that EQUALS the env value still owns the field —
|
||||
# the tab row must not resurrect itself on a value tie.
|
||||
db.execute(
|
||||
update(AppSetting).where(AppSetting.key == "app_name").values(
|
||||
value=env.app_name
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
tied = theming.effective_settings(db, Settings(app_name=env.app_name))
|
||||
assert tied["app_name"] == env.app_name
|
||||
assert tied["input_placeholder"] == "Tab placeholder…"
|
||||
finally:
|
||||
_clear_panel_rows(db)
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
|
||||
|
||||
def test_effective_strings_ignore_panel_rows_when_the_overlay_is_off(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The ``BOR_SETTINGS_DB`` kill switch (phase 168 D6) governs the
|
||||
ownership too: with the overlay off NO row is in effect (so
|
||||
``settings.app_name`` is the plain env value), and the Theme-tab row
|
||||
keeps its phase-91 fallback rather than being shadowed by a row the
|
||||
resolver never applies."""
|
||||
_start_row_missing(db)
|
||||
_clear_panel_rows(db)
|
||||
_seed_panel_rows(db)
|
||||
db.add(UiSettings(id=1, app_name="Tab Name"))
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
|
||||
try:
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert effective["app_name"] == "Tab Name" # no override in effect → the tab row
|
||||
assert effective["input_placeholder"] == _env_settings().input_placeholder
|
||||
assert effective["footer_text"] == _env_settings().footer_text
|
||||
finally:
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
|
||||
_clear_panel_rows(db)
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
|
||||
|
||||
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
|
||||
"""``settings=None`` (the design's call shape) resolves the env
|
||||
fallback from the cached :func:`app.config.get_settings` — the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Unit: the admin UI-settings API (phase 91, task 01).
|
||||
"""Unit: the admin UI-settings API (phase 91, task 01; colors-only since
|
||||
phase 190, task 02).
|
||||
|
||||
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
|
||||
contract and the GET/PUT persistence on the single ``ui_settings`` row
|
||||
@@ -6,13 +7,18 @@ contract and the GET/PUT persistence on the single ``ui_settings`` row
|
||||
the 8 semantic state):
|
||||
|
||||
* PUT validation — the 422s NAME the offending field (fixed details):
|
||||
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
|
||||
prefix, 3-digit shorthand, 8 hex chars, missing ``#``) — identity AND
|
||||
semantic fields alike (the loops are ``COLOR_FIELDS``-driven);
|
||||
a non-``#rrggbb`` color (wrong prefix, 3-digit shorthand, 8 hex
|
||||
chars, missing ``#``) — identity AND semantic fields alike (the loops
|
||||
are ``COLOR_FIELDS``-driven);
|
||||
* the STRINGS are accepted and IGNORED (phase 190, A1 — the admin
|
||||
Settings panel is the single writable owner of ``app_name`` /
|
||||
``input_placeholder`` / ``footer_text``): no body string is written,
|
||||
whatever its length or whitespace, and the stored columns are left
|
||||
exactly as they were (never cleared) — so an old cached client's Save
|
||||
cannot re-bake a name and shadow the panel;
|
||||
* normalization — colors are lowercased on store; a color EQUAL to its
|
||||
built-in is stored as NULL (the owner-locked rule: "save the defaults"
|
||||
must leave the row empty — the no-op injection contract); an empty /
|
||||
whitespace-only string is the clear operation (NULL);
|
||||
must leave the row empty — the no-op injection contract);
|
||||
* GET — the effective merge (a partial row reports the DB values over
|
||||
the env/built-in defaults);
|
||||
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
|
||||
@@ -65,25 +71,33 @@ def _row(db: Session) -> UiSettings | None:
|
||||
return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
|
||||
|
||||
def test_put_too_long_string_422_names_the_field(
|
||||
def test_put_string_fields_are_ignored_not_validated(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
|
||||
that field; a rejected PUT half-writes nothing; exactly 300 still
|
||||
passes (the column is VARCHAR(300))."""
|
||||
"""Phase 190 (A1): the 3 string fields of the body are DROPPED before
|
||||
the handler reads them — no trim, no length check, no 422. A
|
||||
400-char name (the old 422 case) is a plain 200 that writes nothing,
|
||||
and the response reports the effective (env) value; the palette in
|
||||
the same body still saves. An old cached client can therefore never
|
||||
re-bake a brand string from this surface — and can never be blocked
|
||||
by a validation error on a field this API no longer owns."""
|
||||
for field in theming.STRING_FIELDS:
|
||||
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
|
||||
assert r.status_code == 422, (field, r.text)
|
||||
assert r.json()["detail"] == f"{field} is too long (max 300)"
|
||||
# A whitespace-padded 301 is still 301 after the trim…
|
||||
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
|
||||
assert r.status_code == 422, (field, r.text)
|
||||
# No rejected PUT created the row — the upsert runs after validation.
|
||||
assert _row(db) is None
|
||||
# Exactly 300 passes — stored, trimmed.
|
||||
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["app_name"] == "y" * 300
|
||||
r = admin_client.put(
|
||||
"/api/ui-settings", json={field: "x" * 301, "brand": "#818cf8"}
|
||||
)
|
||||
assert r.status_code == 200, (field, r.text)
|
||||
assert r.json()[field] == getattr(get_settings(), field)
|
||||
db.expire_all()
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert getattr(row, field) is None, f"{field} must never be stored"
|
||||
assert row.brand == "#818cf8" # the colors half still saves beside it
|
||||
# And the row the PUT created holds no string at all.
|
||||
db.expire_all()
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
for field in theming.STRING_FIELDS:
|
||||
assert getattr(row, field) is None
|
||||
|
||||
|
||||
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
@@ -210,27 +224,35 @@ def test_put_semantic_built_in_is_stored_as_null(
|
||||
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
|
||||
|
||||
|
||||
def test_put_empty_string_is_the_clear_operation(
|
||||
def test_put_cannot_clear_a_stored_string(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A whitespace-only (or empty) string trims to empty → NULL — the
|
||||
clear operation, not a 422 and not a stored blank: the effective
|
||||
value falls back to the env default."""
|
||||
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
|
||||
assert r.status_code == 200, r.text
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.app_name is None
|
||||
assert row.footer_text is None
|
||||
# The response reports the effective (env) fallback, not "".
|
||||
assert r.json()["app_name"] == get_settings().app_name
|
||||
assert r.json()["footer_text"] == get_settings().footer_text
|
||||
"""The A1 fallback survives the write strip: a legacy row that holds
|
||||
a baked-in string keeps it through an empty / whitespace / explicit
|
||||
``null`` PUT — the Theme tab neither writes nor CLEARS the strings
|
||||
(clearing one is the panel's ``DELETE /api/admin/settings/{key}``).
|
||||
The response keeps reporting the effective value, so the old client
|
||||
still shows what the deployment shows."""
|
||||
db.add(UiSettings(id=1, app_name="Legacy bake", footer_text="Legacy footer"))
|
||||
db.commit()
|
||||
|
||||
for body in ({"app_name": " ", "footer_text": ""}, {"app_name": None}):
|
||||
r = admin_client.put("/api/ui-settings", json=body)
|
||||
assert r.status_code == 200, (body, r.text)
|
||||
db.expire_all()
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.app_name == "Legacy bake" # never written, never cleared
|
||||
assert row.footer_text == "Legacy footer"
|
||||
# The response reports the EFFECTIVE value (the stored fallback).
|
||||
assert r.json()["app_name"] == "Legacy bake"
|
||||
assert r.json()["footer_text"] == "Legacy footer"
|
||||
|
||||
|
||||
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
|
||||
"""GET reports the DB values over the defaults, column by column: a
|
||||
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
|
||||
built-ins and the env strings — all 12 keys, no nulls."""
|
||||
built-ins and the env strings — all 20 keys, no nulls."""
|
||||
db.add(UiSettings(id=1, bg="#123456"))
|
||||
db.commit()
|
||||
r = admin_client.get("/api/ui-settings")
|
||||
@@ -258,19 +280,19 @@ def test_upsert_creates_then_updates_the_id_1_row(
|
||||
row = _row(db)
|
||||
assert row is not None and row.id == 1
|
||||
assert row.brand == "#123abc"
|
||||
assert row.app_name == "First"
|
||||
assert row.app_name is None # phase 190 A1: the body's string is dropped
|
||||
|
||||
r2 = admin_client.put(
|
||||
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["brand"] == "#abcdef"
|
||||
assert r2.json()["input_placeholder"] == "Second"
|
||||
assert r2.json()["input_placeholder"] == get_settings().input_placeholder
|
||||
|
||||
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||
rows = db.execute(select(UiSettings)).scalars().all()
|
||||
assert len(rows) == 1, "the upsert must never create a second row"
|
||||
assert rows[0].id == 1
|
||||
assert rows[0].brand == "#abcdef" # updated, not appended
|
||||
assert rows[0].input_placeholder == "Second" # the new string landed
|
||||
assert rows[0].app_name is None # absent in the second body → NULL
|
||||
assert rows[0].input_placeholder is None # the strings are never written
|
||||
assert rows[0].app_name is None
|
||||
|
||||
Reference in New Issue
Block a user