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),
|
round trip + INVALID self-heal + drift guard),
|
||||||
`tests/integration/test_chunks_hnsw_recall.py` (the tier's captured
|
`tests/integration/test_chunks_hnsw_recall.py` (the tier's captured
|
||||||
statement names the index, `SET LOCAL` reaches the connection, recall at
|
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
|
`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
|
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
|
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
|
The overlay can be switched off for the whole process with
|
||||||
`BOR_SETTINGS_DB=0` (env-only; the panel stays visible and says so).
|
`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>`)
|
### Shared chat (`/shared/<token>`)
|
||||||
|
|
||||||
A read-only snapshot of any conversation, shareable via a link. Open to
|
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.
|
to `.env` and adjust.
|
||||||
|
|
||||||
**DB-over-env overrides (phase 168):** the precedence for every setting
|
**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
|
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
|
(`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
|
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
|
### Customizing the look
|
||||||
|
|
||||||
Every identity string is an env var: `BOR_APP_NAME` (display name),
|
Every identity string defaults from an env var: `BOR_APP_NAME` (display
|
||||||
`BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and
|
name), `BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and
|
||||||
`BOR_FOOTER_TEXT` (footer line) — all served by `GET /api/config` and
|
`BOR_FOOTER_TEXT` (footer line) — all served by `GET /api/config` and
|
||||||
applied by `assets/brand.js` at boot.
|
applied by `assets/brand.js` at boot.
|
||||||
|
|
||||||
The colors are set from the admin **Theme tab** (`/theme.html`,
|
**The admin Settings panel (`/settings.html`) is the single writable
|
||||||
admin-only — phase 91): the 17 palette colors (the 9 identity colors plus
|
owner of those three brand strings** (phase 190, A1). Their resolution
|
||||||
the 8 semantic state colors — success/error/accent families) and the
|
order is **panel row → Theme-tab row → env**: saving `app_name` there
|
||||||
three strings above are edited with text fields and color pickers,
|
wins over everything, and it beats a value an old Theme-tab save baked
|
||||||
persisted in the `ui_settings` table, and injected into every served page
|
into `ui_settings` (the fix for "changing the App name in settings
|
||||||
as an inline `<style>` BEFORE first paint (no red flash, no pop-in). The
|
broke, it no longer updates the name"). `PUT /api/ui-settings` still
|
||||||
browser-tab **favicon** follows the same effective palette: `GET
|
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
|
/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 process cache — a theme change is visible on the next
|
||||||
request, no restart) and degrades to the built-in art with 200 on a
|
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
|
phase-122 image flag (UI affordance gating), and the phase-62 UI
|
||||||
customization strings (composer placeholder, footer line).
|
customization strings (composer placeholder, footer line).
|
||||||
|
|
||||||
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
|
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
|
resolved by the SAME :func:`app.core.theming.effective_settings` resolver
|
||||||
wins when set, env is the fallback), resolved by the SAME
|
the ``/api/ui-settings`` API uses, so the brand layer and the tab can
|
||||||
:func:`app.core.theming.effective_settings` resolver the
|
never disagree. Phase 190 (task 02, A1) fixed WHO owns them: the admin
|
||||||
``/api/ui-settings`` API uses, so the brand layer and the tab can never
|
Settings panel's ``app_settings`` row (phase 168) first, then the
|
||||||
disagree. The route opens a short-lived session (the sync-endpoint
|
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).
|
house pattern — the route is sync, matching the middleware world).
|
||||||
|
|
||||||
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
|
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
|
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
|
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,
|
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
|
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
|
||||||
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
|
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
|
||||||
403 on every route (only the admin themes the deployment).
|
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
|
* ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's
|
||||||
DB-over-env / DB-over-built-in merge, B1): a missing row reports the
|
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
|
env strings + the built-in palette, so a fresh tab shows the live
|
||||||
theme. Creates nothing.
|
theme. Creates nothing. The three strings stay in the response
|
||||||
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
|
READ-ONLY (old-client compat, and the resolver's order makes them the
|
||||||
string is trimmed (empty → NULL, >300 → 422 naming the field), each
|
EFFECTIVE ones — the panel's value when the panel owns the field).
|
||||||
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
|
* ``PUT /api/ui-settings`` — a FULL replacement of the row's 17 COLORS:
|
||||||
naming the field), and — the owner-locked normalization — a color
|
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"
|
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
|
leaves the row empty and the served HTML stays byte-identical (the
|
||||||
no-op injection contract, task 02). Upserts the id-1 row (SELECT →
|
no-op injection contract, task 02). The 3 string fields of the body
|
||||||
update-or-insert); a concurrent PUT is single-admin — last writer
|
are ACCEPTED AND IGNORED (phase 190 A1 — the request model keeps them
|
||||||
wins. Returns the new effective values.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -49,28 +62,6 @@ router = APIRouter(
|
|||||||
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
|
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
|
||||||
#: lowercased on store so the stored/tagged hex is canonical).
|
#: lowercased on store so the stored/tagged hex is canonical).
|
||||||
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
_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]:
|
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
|
Reads only: a missing row means "defaults" (the env strings + the
|
||||||
built-in palette), so a fresh deployment's tab shows the live theme
|
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))
|
return UiSettingsOut(**theming.effective_settings(db, settings))
|
||||||
|
|
||||||
@@ -116,24 +111,30 @@ def update_ui_settings(
|
|||||||
settings: Settings = Depends(get_settings), # noqa: B008
|
settings: Settings = Depends(get_settings), # noqa: B008
|
||||||
db: Session = Depends(get_db), # noqa: B008
|
db: Session = Depends(get_db), # noqa: B008
|
||||||
) -> UiSettingsOut:
|
) -> 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
|
normalized — see the module docstring), then report the new
|
||||||
effective values.
|
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
|
Upsert on the id-1 row (SELECT → update-or-insert; the Python-side
|
||||||
``default=1`` supplies the PK on the insert). Concurrency is
|
``default=1`` supplies the PK on the insert). Concurrency is
|
||||||
single-admin (one owner, one tab) — the last writer wins and no
|
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
|
lock is taken: a lost race just means the other admin's PUT is the
|
||||||
effective one.
|
effective one.
|
||||||
"""
|
"""
|
||||||
strings = _validate_strings(payload)
|
|
||||||
colors = _validate_colors(payload)
|
colors = _validate_colors(payload)
|
||||||
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||||
if row is None:
|
if row is None:
|
||||||
row = UiSettings(id=1)
|
row = UiSettings(id=1)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
for field in theming.STRING_FIELDS:
|
|
||||||
setattr(row, field, strings[field])
|
|
||||||
for field in theming.COLOR_FIELDS:
|
for field in theming.COLOR_FIELDS:
|
||||||
setattr(row, field, colors[field])
|
setattr(row, field, colors[field])
|
||||||
db.commit()
|
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
|
one WARNING per process naming the exception class ("settings
|
||||||
overrides unavailable — env values in effect"). Both "once per process"
|
overrides unavailable — env values in effect"). Both "once per process"
|
||||||
guards are module-level flags, reset by :func:`reset_state_for_tests`.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -45,6 +51,7 @@ from pydantic import ValidationError
|
|||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
|
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -125,6 +132,38 @@ def load_overrides() -> dict[str, str]:
|
|||||||
return {}
|
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:
|
def _coerce(field_name: str, raw: str) -> Any:
|
||||||
"""Coerce one env-native string row value for the candidate set.
|
"""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).
|
pairs).
|
||||||
|
|
||||||
Effective-value resolution (:func:`effective_settings`) — the DB-over-
|
Effective-value resolution (:func:`effective_settings`) — the DB-over-
|
||||||
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
|
env / DB-over-built-in merge (B1, owner-locked 2026-09-09) resolved
|
||||||
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
|
column-by-column:
|
||||||
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
|
* **the three brand strings** (``app_name`` / ``input_placeholder`` /
|
||||||
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
|
``footer_text``) — **Settings-panel row → Theme-tab row → env**
|
||||||
layer), so the tab and the running UI can never disagree.
|
(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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -99,6 +115,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
|
from app.core import settings_overlay
|
||||||
from app.models import UiSettings
|
from app.models import UiSettings
|
||||||
|
|
||||||
#: The 17 built-in palette colors, keyed by BARE variable name (no
|
#: 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).
|
#: API, and the ``theme_style_tag`` renderer (task 02).
|
||||||
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
|
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
|
||||||
|
|
||||||
#: The 3 display strings the ``ui_settings`` row carries — env fallback
|
#: The 3 brand strings the ``ui_settings`` row carries. Since phase 190
|
||||||
#: (B1: unlike the colors, the env vars stay the strings' default).
|
#: (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")
|
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
|
||||||
|
|
||||||
|
|
||||||
@@ -150,12 +171,22 @@ def effective_settings(
|
|||||||
defaults, column by column:
|
defaults, column by column:
|
||||||
|
|
||||||
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
|
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
|
||||||
— the DB value when it is a non-empty string, else the env value
|
— the three-way order of phase 190 (A1), per field:
|
||||||
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
|
**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
|
* **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8
|
||||||
semantic state) — the DB value when not ``None``, else
|
semantic state) — the DB value when not ``None``, else
|
||||||
:data:`BUILTIN_COLORS` (B1: no env fallback for colors — the
|
: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
|
A missing row (``GET`` creates nothing) means "defaults" — the env
|
||||||
strings + the built-in palette. The ``settings`` parameter names the
|
strings + the built-in palette. The ``settings`` parameter names the
|
||||||
@@ -167,8 +198,15 @@ def effective_settings(
|
|||||||
if settings is None:
|
if settings is None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
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] = {}
|
effective: dict[str, str] = {}
|
||||||
for field in STRING_FIELDS:
|
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
|
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)
|
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
|
||||||
for key in COLOR_FIELDS:
|
for key in COLOR_FIELDS:
|
||||||
|
|||||||
+11
-1
@@ -563,6 +563,14 @@ class UiSettings(Base):
|
|||||||
overview). :func:`app.core.theming.effective_settings` resolves the
|
overview). :func:`app.core.theming.effective_settings` resolves the
|
||||||
effective 20 values both the ``GET /api/ui-settings`` and
|
effective 20 values both the ``GET /api/ui-settings`` and
|
||||||
``GET /api/config`` endpoints serve.
|
``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"
|
__tablename__ = "ui_settings"
|
||||||
@@ -571,7 +579,9 @@ class UiSettings(Base):
|
|||||||
#: id=1 precedent — Python-side default; the migration carries no
|
#: id=1 precedent — Python-side default; the migration carries no
|
||||||
#: server default because the row is created only by the PUT upsert).
|
#: server default because the row is created only by the PUT upsert).
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
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)
|
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||||
input_placeholder: 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)
|
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||||
|
|||||||
+20
-14
@@ -2077,20 +2077,24 @@ class TokenAuthRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UiSettingsIn(BaseModel):
|
class UiSettingsIn(BaseModel):
|
||||||
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
|
"""``PUT /api/ui-settings`` body (phase 91, task 01; colors-only since
|
||||||
replacement of the single ``ui_settings`` row.
|
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
|
Every field is ``str | None`` — present = a new value, ``null``/
|
||||||
trimmed; empty after the trim is the CLEAR operation, stored as
|
absent = "back to the default" (stored as NULL — the Reset button's
|
||||||
NULL; colors must be ``#rrggbb`` and are lowercased on store),
|
all-null PUT is exactly the "defaults" operation). The API layer
|
||||||
``null``/absent = "back to the default" (stored as NULL — the Reset
|
runs the hex validation so the 422 details name the offending field
|
||||||
button's all-null PUT is exactly the "defaults" operation). The
|
(the house fixed-detail style); the built-in→NULL normalization (a
|
||||||
API layer runs the trim/length/hex validation so the 422 details
|
color equal to its built-in is stored as NULL — "save the defaults"
|
||||||
name the offending field (the house fixed-detail style); the
|
must leave the row empty, the no-op injection contract) happens
|
||||||
built-in→NULL normalization (a color equal to its built-in is
|
there too, next to the palette it normalizes against.
|
||||||
stored as NULL — "save the defaults" must leave the row empty, the
|
|
||||||
no-op injection contract) happens there too, next to the palette
|
The three string fields stay on the model for OLD-CLIENT COMPAT and
|
||||||
it normalizes against.
|
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
|
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
|
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
|
the LIVE theme — a fresh (row-missing) deployment reports the env
|
||||||
strings and the built-in palette (the semantic colors report their
|
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
|
app_name: str
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
* aria-label, the input label, the meta descriptions);
|
* aria-label, the input label, the meta descriptions);
|
||||||
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
||||||
* settled config also carries the two UI-customization
|
* 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
|
* passes and INDEPENDENT of them (they apply even when the
|
||||||
* name is the default/empty). Each empty value is a no-op —
|
* name is the default/empty). Each empty value is a no-op —
|
||||||
* an unset deployment stays byte-identical:
|
* an unset deployment stays byte-identical:
|
||||||
@@ -54,11 +54,37 @@
|
|||||||
* (the loadHealth house style: progressive enhancement, the page
|
* (the loadHealth house style: progressive enhancement, the page
|
||||||
* never breaks).
|
* 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,
|
* No-op property: with the customization env vars (BOR_APP_NAME,
|
||||||
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers
|
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers
|
||||||
* with the template defaults themselves — the name IS the literal, the
|
* with the template defaults themselves — the name IS the literal, the
|
||||||
* placeholder and footer are the phase-61 copy (re-setting them is
|
* 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
|
/* The synchronous default — set BEFORE any fetch, so module scripts
|
||||||
@@ -76,6 +102,12 @@ window.BOR_DOCS_REPO_CONFIGURED = false;
|
|||||||
sync with it. */
|
sync with it. */
|
||||||
const BRAND_LITERAL = "Brain of Reese";
|
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
|
/* The name is operator-controlled: HTML-escape it before it touches
|
||||||
innerHTML (the markdown.js escape pattern — local on purpose, no
|
innerHTML (the markdown.js escape pattern — local on purpose, no
|
||||||
cross-module import for a 5-line helper). */
|
cross-module import for a 5-line helper). */
|
||||||
@@ -108,9 +140,17 @@ const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
|
|||||||
);
|
);
|
||||||
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
|
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
|
||||||
|
|
||||||
function applyBrand() {
|
/* THE shared brand pass (phase 190, task 03) — the ONE owner of the
|
||||||
BOR_CONFIG_PROMISE.then((cfg) => {
|
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 name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
|
||||||
|
const previous =
|
||||||
|
typeof window.BOR_BRAND_APPLIED === "string" ? window.BOR_BRAND_APPLIED : "";
|
||||||
if (name) {
|
if (name) {
|
||||||
// A configured name: apply it (empty / missing: the default
|
// A configured name: apply it (empty / missing: the default
|
||||||
// stands — and the phase-62 keys below are INDEPENDENT of the
|
// stands — and the phase-62 keys below are INDEPENDENT of the
|
||||||
@@ -176,6 +216,44 @@ function applyBrand() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(previous)) {
|
||||||
|
el.setAttribute(attr, v.replaceAll(previous, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (el.tagName === "META") {
|
||||||
|
const v = el.getAttribute("content");
|
||||||
|
if (v && v.includes(previous)) {
|
||||||
|
el.setAttribute("content", v.replaceAll(previous, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name this document now shows — the source string the NEXT
|
||||||
|
// re-apply replaces (phase 190, task 03).
|
||||||
|
window.BOR_BRAND_APPLIED = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||||
@@ -207,7 +285,19 @@ function applyBrand() {
|
|||||||
el.textContent = footerText;
|
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
|
/* 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
|
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
|
||||||
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
||||||
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
|
"/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)
|
"/settings.html": "settings", // phase 168 task 05: the Settings view (admin settings panel)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ const DESCRIPTIONS = {
|
|||||||
history:
|
history:
|
||||||
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
|
"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.",
|
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.",
|
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 →
|
* button, Escape / No cancels) → DELETE /api/admin/settings →
|
||||||
* the status line `Cleared N overrides.` (N from the
|
* the status line `Cleared N overrides.` (N from the
|
||||||
* response) + a re-GET.
|
* 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
|
* • search — #settings-filter: a case-insensitive substring over
|
||||||
* the label + BOR_* name cells, applied on input with NO fetch
|
* the label + BOR_* name cells, applied on input with NO fetch
|
||||||
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
|
* (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,
|
/* ---------- Save (dirty rows only — stop at the first failure,
|
||||||
K4; the §7.4 never-stale lifecycle) ---------- */
|
K4; the §7.4 never-stale lifecycle) ---------- */
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
@@ -499,6 +553,9 @@ export async function mount(root) {
|
|||||||
);
|
);
|
||||||
const input = root.querySelector("#set-" + failure.key);
|
const input = root.querySelector("#set-" + failure.key);
|
||||||
if (input) input.focus();
|
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 {
|
} else {
|
||||||
/* One status line (role=status) — a saved restart-required
|
/* One status line (role=status) — a saved restart-required
|
||||||
row says so (the owner is never told "saved" and then sees
|
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" : ""),
|
(anyRestart ? " — applies on the next restart" : ""),
|
||||||
);
|
);
|
||||||
await loadSettings(); // every badge/value is the settled state
|
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;
|
saveInFlight = false;
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
@@ -535,6 +596,7 @@ export async function mount(root) {
|
|||||||
}
|
}
|
||||||
announce(`${envVar} is back to the environment value.`);
|
announce(`${envVar} is back to the environment value.`);
|
||||||
await loadSettings(); // the badge falls back to env/default
|
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
|
/* ---------- Clear all overrides (the house two-step inline
|
||||||
@@ -570,6 +632,7 @@ export async function mount(root) {
|
|||||||
restore();
|
restore();
|
||||||
announce(`Cleared ${cleared} overrides.`);
|
announce(`Cleared ${cleared} overrides.`);
|
||||||
await loadSettings(); // every badge falls back to env/default
|
await loadSettings(); // every badge falls back to env/default
|
||||||
|
await reapplyBrand(BRAND_KEYS); // every brand string may have moved
|
||||||
}
|
}
|
||||||
|
|
||||||
function showClearAllConfirm() {
|
function showClearAllConfirm() {
|
||||||
|
|||||||
+106
-49
@@ -1,5 +1,7 @@
|
|||||||
/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
|
/* 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
|
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
|
||||||
* shape): the router (assets/router.js) lazy-imports this module on
|
* 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 (the #nav-theme link is already hidden by header.js — the
|
||||||
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
|
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
|
||||||
* /api/ui-settings request is ever made outside the admin branch.
|
* /api/ui-settings request is ever made outside the admin branch.
|
||||||
* • load — GET /api/ui-settings → populate the 20 inputs with the
|
* • load — GET /api/ui-settings → populate the 17 color inputs with
|
||||||
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
|
* the EFFECTIVE values (the resolver's DB-over-env /
|
||||||
* merge): the tab always shows the live theme — env defaults when
|
* DB-over-built-in merge): the tab always shows the live theme —
|
||||||
* the row is empty. A failed fetch keeps the static form (the
|
* the built-in palette when the row is empty. A failed fetch keeps
|
||||||
* built-in values ship in the inputs) and shows #theme-error with
|
* the static form (the built-in values ship in the inputs) and
|
||||||
* a retry (the loadHealth house style — never a blanked panel).
|
* shows #theme-error with a retry (the loadHealth house style —
|
||||||
* • live preview (colors only, B4) — on `input` of any of the 17
|
* never a blanked panel). The response still carries the 3 brand
|
||||||
* color pickers the value is written straight onto <html> as an
|
* strings (read-only, old-client compat) — populate() simply has no
|
||||||
* inline custom property, so the WHOLE page repaints (every view,
|
* field to write them into.
|
||||||
* the header) while the owner is picking. Text fields have NO page
|
* • live preview (colors only) — on `input` of any of the 17 color
|
||||||
* effect: the 3 strings keep the brand.js runtime application
|
* pickers the value is written straight onto <html> as an inline
|
||||||
* (owner-locked B4) — they apply via the /api/config boot fetch on
|
* custom property, so the WHOLE page repaints (every view, the
|
||||||
* the NEXT page load, and the sub-copy says so.
|
* 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
|
* • favicon live preview (phase 177, A4 — owner-confirmed
|
||||||
* 2026-10-13) — a pick of one of the THREE driving fields
|
* 2026-10-13) — a pick of one of the THREE driving fields
|
||||||
* (``surface`` / ``brand`` / ``brand_ink`` — the artwork's
|
* (``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
|
* the admin opens the tab (a normal load is a no-op — the served
|
||||||
* tag and the overrides agree).
|
* tag and the overrides agree).
|
||||||
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
|
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
|
||||||
* PUT /api/ui-settings with the 20 form values (a cleared/empty
|
* PUT /api/ui-settings with the 17 form colors (always their
|
||||||
* text field → null; colors always their current hex — the
|
* current hex — the server's built-in→NULL normalization keeps the
|
||||||
* server's built-in→NULL normalization keeps the row empty when
|
* row empty when the owner saves the defaults; NO string key in
|
||||||
* the owner saves the defaults) → 200: #theme-result "Theme
|
* the body, phase 190 A1) → 200: #theme-result "Theme
|
||||||
* saved." (role=status), refetch + re-populate (canonical state),
|
* saved." (role=status), refetch + re-populate (canonical state),
|
||||||
* reconcile the open document to the settled values (#bor-theme
|
* reconcile the open document to the settled values (#bor-theme
|
||||||
* text + the <html> overrides — the OPEN page paints the saved
|
* 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
|
* field), the form is KEPT (the owner fixes + retries); any other
|
||||||
* non-2xx: the fixed error line; a network error: the "is the app
|
* non-2xx: the fixed error line; a network error: the "is the app
|
||||||
* reachable?" line.
|
* reachable?" line.
|
||||||
* • Reset — the same lifecycle ("Resetting…") with all 20 values
|
* • Reset — the same lifecycle ("Resetting…") with all 17 colors
|
||||||
* null (the API's documented "defaults" operation) → #theme-result
|
* 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
|
* "Reset to the built-in theme." → refetch + re-populate (the
|
||||||
* env/built-in defaults) → reconcile the open document: the tag
|
* env/built-in defaults) → reconcile the open document: the tag
|
||||||
* is REMOVED (effective = the built-ins → content null) and the
|
* is REMOVED (effective = the built-ins → content null) and the
|
||||||
* <html> overrides are dropped (a failed refetch still drops
|
* <html> overrides are dropped (a failed refetch still drops
|
||||||
* them — the picks are stale once the reset landed).
|
* 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
|
* • WCAG contrast (the phase-93 design's EIGHT pairs — the pairs
|
||||||
* the layout actually pairs, see app/core/theming.py's docstring —
|
* the layout actually pairs, see app/core/theming.py's docstring —
|
||||||
* this file's PAIRS is its client-side mirror, and the two must
|
* 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 SAVE_LABEL = "Save theme";
|
||||||
const RESET_LABEL = "Reset to defaults";
|
const RESET_LABEL = "Reset to defaults";
|
||||||
|
|
||||||
/* The 20 form fields, in the form's order: `field` is the API key
|
/* The 17 color form fields, in the form's order: `field` is the API
|
||||||
(the input's name attribute), `id` the E2E-stable element id,
|
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
|
`kind` how the value is read for a PUT — a color field always
|
||||||
empty after the trim sends null (the server stores NULL = "use
|
sends its current #rrggbb (the server's built-in→NULL
|
||||||
the default"); a color field always sends its current #rrggbb
|
normalization keeps the row empty when the owner saves the
|
||||||
(the server's built-in→NULL normalization keeps the row empty
|
defaults). THE 3 BRAND STRINGS ARE NOT HERE (phase 190, A1 —
|
||||||
when the owner saves the defaults). The 17 color fields follow
|
owner-confirmed 2026-10-18): the admin Settings panel is the
|
||||||
the server's theming.COLOR_FIELDS order (the 9 identity
|
single writable owner of app_name / input_placeholder /
|
||||||
variables, then the 8 semantic state variables — phase 93), so
|
footer_text, so the tab renders no string input and its PUT body
|
||||||
everything FIELDS-driven (live preview, collectBody,
|
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. */
|
applyServedTheme's tag content) stays in COLOR_FIELDS order. */
|
||||||
const FIELDS = [
|
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: "bg", id: "theme-bg", kind: "color" },
|
||||||
{ field: "surface", id: "theme-surface", kind: "color" },
|
{ field: "surface", id: "theme-surface", kind: "color" },
|
||||||
{ field: "ink", id: "theme-ink", 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
|
* inline style beats the served <style id="bor-theme"> :root, so
|
||||||
* the whole page repaints live while the owner is picking. An empty
|
* the whole page repaints live while the owner is picking. An empty
|
||||||
* value removes the override (the page falls back to the served
|
* value removes the override (the page falls back to the served
|
||||||
* theme). Text fields write NOTHING here — the strings apply via
|
* theme). Only the 17 color pickers bind here — the tab has no
|
||||||
* brand.js on the next page load (the sub-copy says so). */
|
* string input at all since phase 190 (A1). */
|
||||||
function previewColor(field, value) {
|
function previewColor(field, value) {
|
||||||
if (value) {
|
if (value) {
|
||||||
document.documentElement.style.setProperty(cssVar(field), 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
|
/* GET /api/ui-settings → populate the 17 color inputs with the
|
||||||
values (the tab always shows the live theme — env defaults when
|
EFFECTIVE values (the tab always shows the live theme — the
|
||||||
the row is empty) and re-check the eight pairs (a SAVED palette
|
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
|
can itself fail AA — the warning then tracks it). A failed fetch
|
||||||
keeps the static form + shows #theme-error with a retry (the
|
keeps the static form + shows #theme-error with a retry (the
|
||||||
loadHealth house style — never a blanked panel). Returns the
|
loadHealth house style — never a blanked panel). Returns the
|
||||||
@@ -524,18 +544,17 @@ export async function mount(root) {
|
|||||||
/* ---------- the PUT (Save + Reset share it) ---------- */
|
/* ---------- the PUT (Save + Reset share it) ---------- */
|
||||||
|
|
||||||
function collectBody() {
|
function collectBody() {
|
||||||
/* All 20 fields — the 3 strings + the 17 colors (the 9 identity
|
/* The 17 colors ONLY (the 9 identity + the 8 semantic state) — no
|
||||||
+ the 8 semantic state, phase 93). */
|
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 = {};
|
const body = {};
|
||||||
for (const f of FIELDS) {
|
for (const f of FIELDS) {
|
||||||
const input = inputs[f.field];
|
const input = inputs[f.field];
|
||||||
const value = input ? input.value : "";
|
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;
|
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() {
|
async function saveTheme() {
|
||||||
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
|
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
@@ -592,11 +644,12 @@ export async function mount(root) {
|
|||||||
/* A failed refetch keeps the current <html> overrides on purpose:
|
/* A failed refetch keeps the current <html> overrides on purpose:
|
||||||
the PUT body came from these very inputs, so they ARE the saved
|
the PUT body came from these very inputs, so they ARE the saved
|
||||||
palette — never revert onto the stale tag (the phase-91 defect). */
|
palette — never revert onto the stale tag (the phase-91 defect). */
|
||||||
|
await reapplyBrand(); // the strings ride the same settle (task 03)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetTheme() {
|
async function resetTheme() {
|
||||||
const body = {};
|
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);
|
const outcome = await putSettings(body, "Resetting…", resetBtn);
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
showError(outcome.detail);
|
showError(outcome.detail);
|
||||||
@@ -611,6 +664,11 @@ export async function mount(root) {
|
|||||||
clearPreview(); // the reset landed — the picks are stale
|
clearPreview(); // the reset landed — the picks are stale
|
||||||
restoreFavicon(); // stale picks — back to the route URL
|
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) ----------
|
/* ---------- 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
|
/* Bindings — armed BEFORE the first load: a fast owner can start
|
||||||
picking while the GET is still out; the preview writes are
|
picking while the GET is still out; the preview writes are
|
||||||
idempotent and the settled load re-populates afterwards. Color
|
idempotent and the settled load re-populates afterwards. The 17
|
||||||
inputs drive the live preview + the contrast re-check; text
|
color inputs drive the live preview + the contrast re-check (the
|
||||||
inputs drive neither (B4 — the strings apply on the next page
|
brand strings are the Settings panel's surface, phase 190 A1). */
|
||||||
load, the sub-copy says so). */
|
|
||||||
for (const f of FIELDS) {
|
for (const f of FIELDS) {
|
||||||
const input = inputs[f.field];
|
const input = inputs[f.field];
|
||||||
if (!input || f.kind !== "color") continue;
|
if (!input || f.kind !== "color") continue;
|
||||||
|
|||||||
+17
-48
@@ -1275,8 +1275,9 @@
|
|||||||
|
|
||||||
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
|
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
|
||||||
folded view (the phase-76 fold pattern, the phase-79 Tokens
|
folded view (the phase-76 fold pattern, the phase-79 Tokens
|
||||||
view as the most recent precedent): the admin-only palette +
|
view as the most recent precedent): the admin-only palette
|
||||||
branding editor. /theme.html serves THIS document (the shell
|
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
|
route in app/main.py); the router shows this section for that
|
||||||
pathname. The CSS-file theming (BOR_THEME, phase 62) is
|
pathname. The CSS-file theming (BOR_THEME, phase 62) is
|
||||||
retired (task 03): the effective theme is injected into every
|
retired (task 03): the effective theme is injected into every
|
||||||
@@ -1297,7 +1298,7 @@
|
|||||||
<div class="container theme-shell">
|
<div class="container theme-shell">
|
||||||
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
|
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
|
||||||
#sources-gate pattern (phase 16) and the same .sources-gate
|
#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
|
locks (B5, owner-locked 2026-09-09). Visible for
|
||||||
anonymous, hidden for the admin (theme.js). The gate's
|
anonymous, hidden for the admin (theme.js). The gate's
|
||||||
Sign in returns to the Theme view (the header's ?next=
|
Sign in returns to the Theme view (the header's ?next=
|
||||||
@@ -1308,7 +1309,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2 id="theme-gate-title">Sign in to change the theme</h2>
|
<h2 id="theme-gate-title">Sign in to change the theme</h2>
|
||||||
<p class="sources-gate-sub">
|
<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.
|
document an answer cites — stays open to everyone.
|
||||||
</p>
|
</p>
|
||||||
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
|
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
|
||||||
@@ -1325,7 +1326,9 @@
|
|||||||
Changes preview live as you pick;
|
Changes preview live as you pick;
|
||||||
<strong>Save theme</strong> bakes the palette into every
|
<strong>Save theme</strong> bakes the palette into every
|
||||||
page it is served on — it applies on the first paint, no
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1333,50 +1336,16 @@
|
|||||||
the bindings — effective-value populate, the live preview,
|
the bindings — effective-value populate, the live preview,
|
||||||
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
|
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
|
||||||
in #theme-contrast). No real submit: both buttons are
|
in #theme-contrast). No real submit: both buttons are
|
||||||
type="button"; maxlength=300 mirrors the server's 300-char
|
type="button". The 17 color inputs ship the BUILT-IN
|
||||||
limit (the server re-validates — 422 naming the field).
|
values (app/core/theming.py BUILTIN_COLORS) — task 05
|
||||||
The color inputs ship the BUILT-IN values (app/core/
|
re-populates them with the EFFECTIVE values on mount.
|
||||||
theming.py BUILTIN_COLORS) — task 05 re-populates them
|
Phase 190 (task 02, A1): the old "Branding" fieldset and
|
||||||
with the EFFECTIVE values on mount. -->
|
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>
|
<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">
|
<fieldset class="theme-group">
|
||||||
<!-- The eight text/background pairs (the five identity
|
<!-- The eight text/background pairs (the five identity
|
||||||
pairs + the three semantic ink-on-bg pairs from the
|
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
|
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
|
||||||
admin sees the "Theme" nav link and the form (gate hidden); the
|
admin sees the "Theme" nav link and the form (gate hidden); the
|
||||||
20 inputs show the effective defaults (the 3 template strings +
|
17 color inputs show the effective defaults (the built-in hexes —
|
||||||
the 17 built-in hexes — the 9 identity + the 8 semantic state,
|
the 9 identity + the 8 semantic state, phase 93 — parsed out of
|
||||||
phase 93 — parsed out of ``styles.css``'s ``:root`` IN-TEST — the
|
``styles.css``'s ``:root`` IN-TEST — the suite can never drift from
|
||||||
suite can never drift from the stylesheet); Save
|
the stylesheet); Save
|
||||||
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
|
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
|
||||||
held, then restored) and lands the role=status "Theme saved.";
|
held, then restored) and lands the role=status "Theme saved.";
|
||||||
the inputs re-populate to the saved values; the persisted row is
|
the inputs re-populate to the saved values; the persisted row is
|
||||||
the one saved; and the saved non-AA palette lists its failing
|
the one saved; and the saved non-AA palette lists its failing
|
||||||
pair in ``#theme-contrast`` without blocking the save
|
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
|
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
|
||||||
should load immediately, not pop in": after a save, the RAW
|
should load immediately, not pop in": after a save, the RAW
|
||||||
served HTML of ``/`` carries exactly one ``<style
|
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
|
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
|
||||||
anonymous context — and the computed ``:root`` custom properties
|
anonymous context — and the computed ``:root`` custom properties
|
||||||
equal the saved hexes at load (the inline tag precedes every
|
equal the saved hexes at load (the inline tag precedes every
|
||||||
stylesheet application). The 3 strings stay on the brand.js boot
|
stylesheet application). Phase 190 (A1) turned the other half of the
|
||||||
fetch (the B4 split: colors pre-paint, strings via the fetch).
|
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:
|
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
|
||||||
anonymous meets ``#theme-gate`` (sign-in link
|
anonymous meets ``#theme-gate`` (sign-in link
|
||||||
``?next=/theme.html``) with ``#theme-content`` hidden and the
|
``?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":
|
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
|
||||||
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
|
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
|
||||||
the role=status "Reset to the built-in theme.", re-populates the
|
the role=status "Reset to the built-in theme.", re-populates the
|
||||||
20 defaults, serves NO theme tag, and the served bytes equal a
|
17 built-in colors, serves NO theme tag, and the served bytes equal
|
||||||
row-less deployment byte for byte (the no-op injection
|
a row-less deployment byte for byte (the no-op injection
|
||||||
contract).
|
contract).
|
||||||
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
|
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
|
||||||
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
|
``--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 sqlalchemy import text
|
||||||
|
|
||||||
from app.config import Settings
|
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.db import SessionLocal
|
||||||
from app.models import UiSettings
|
from app.models import UiSettings
|
||||||
from e2e.auth_helpers import login, login_with_token
|
from e2e.auth_helpers import login, login_with_token
|
||||||
@@ -133,14 +144,10 @@ PALETTE: dict[str, str] = {
|
|||||||
"accent_ink": "#d9c37a",
|
"accent_ink": "#d9c37a",
|
||||||
"accent_line": "#b8963e",
|
"accent_line": "#b8963e",
|
||||||
}
|
}
|
||||||
APP_NAME = "Theme E2E"
|
# The three brand strings are GONE from this surface (phase 190, A1 —
|
||||||
PLACEHOLDER = "Ask the themed brain…"
|
# the admin Settings panel is their single writable owner), so this
|
||||||
FOOTER = "E2E footer"
|
# suite carries no saved-string fixture any more: a palette save writes
|
||||||
SAVED_STRINGS: dict[str, str] = {
|
# colors only, and every string assertion reads the env/template default.
|
||||||
"app_name": APP_NAME,
|
|
||||||
"input_placeholder": PLACEHOLDER,
|
|
||||||
"footer_text": FOOTER,
|
|
||||||
}
|
|
||||||
|
|
||||||
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
|
#: 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).
|
#: (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:
|
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
|
||||||
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
|
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
|
||||||
seed — the UI save itself is test 1's job)."""
|
(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)
|
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.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:
|
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")
|
page.unroute("**/api/ui-settings")
|
||||||
|
|
||||||
|
|
||||||
def _fill_theme_form(
|
#: The three string inputs the Theme tab used to render (phase 91) —
|
||||||
page: Page,
|
#: REMOVED in phase 190 (A1: the admin Settings panel is the single
|
||||||
palette: dict[str, str],
|
#: writable owner of the brand strings). Every pin below asserts they
|
||||||
strings: dict[str, str] | None = None,
|
#: count 0 in the tab DOM.
|
||||||
) -> None:
|
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
|
||||||
"""Fill the 20 inputs: the 3 text fields (``strings``, default
|
|
||||||
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
|
|
||||||
phase 93)."""
|
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
|
||||||
text_values = strings if strings is not None else SAVED_STRINGS
|
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
|
||||||
page.fill("#theme-app-name", text_values["app_name"])
|
The tab has no string inputs since phase 190 (A1)."""
|
||||||
page.fill("#theme-placeholder", text_values["input_placeholder"])
|
|
||||||
page.fill("#theme-footer", text_values["footer_text"])
|
|
||||||
for field, value in palette.items():
|
for field, value in palette.items():
|
||||||
page.fill(COLOR_INPUT_IDS[field], value)
|
page.fill(COLOR_INPUT_IDS[field], value)
|
||||||
|
|
||||||
|
|
||||||
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
|
def _expect_form_values(page: Page, colors: dict[str, str]) -> None:
|
||||||
"""Assert all 20 inputs show the given effective values."""
|
"""Assert the 17 color 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"])
|
|
||||||
for field in COLOR_FIELDS:
|
for field in COLOR_FIELDS:
|
||||||
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
|
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:
|
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
defaults = _template_defaults()
|
|
||||||
builtin = _builtin_colors()
|
builtin = _builtin_colors()
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
login(page, app_url, next="/theme.html")
|
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-gate")).to_be_hidden()
|
||||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||||
|
|
||||||
# The 20 inputs show the EFFECTIVE defaults: the 3 template
|
# The 17 color inputs show the EFFECTIVE defaults: the built-in
|
||||||
# strings + the 17 built-in hexes (9 identity + 8 semantic)
|
# hexes (9 identity + 8 semantic) parsed straight out of
|
||||||
# parsed straight out of styles.css's :root (the resolver's
|
# styles.css's :root (the resolver's missing-row branch).
|
||||||
# missing-row branch).
|
_expect_form_values(page, builtin)
|
||||||
_expect_form_values(page, defaults, builtin)
|
|
||||||
|
|
||||||
# Set a distinct palette + the 3 strings, then Save through the
|
# Set a distinct palette, then Save through the real form — the PUT
|
||||||
# real form — the PUT held so the §7.4 in-flight state is
|
# held so the §7.4 in-flight state is observable deterministically.
|
||||||
# observable deterministically.
|
|
||||||
_fill_theme_form(page, PALETTE)
|
_fill_theme_form(page, PALETTE)
|
||||||
_hold_theme_puts(page)
|
_hold_theme_puts(page)
|
||||||
try:
|
try:
|
||||||
@@ -448,19 +456,20 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
|||||||
finally:
|
finally:
|
||||||
_release_theme_puts(page)
|
_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).
|
# 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
|
# The row landed in Postgres (the id-1 single row) with its 17
|
||||||
# — every palette color differs from its built-in, so nothing
|
# colors — every palette color differs from its built-in, so nothing
|
||||||
# collapsed to NULL).
|
# 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:
|
with SessionLocal() as db:
|
||||||
row = db.get(UiSettings, 1)
|
row = db.get(UiSettings, 1)
|
||||||
assert row is not None, "the PUT must upsert the id-1 row"
|
assert row is not None, "the PUT must upsert the id-1 row"
|
||||||
assert row.app_name == APP_NAME
|
for field in STRING_FIELDS:
|
||||||
assert row.input_placeholder == PLACEHOLDER
|
assert getattr(row, field) is None, f"{field} must never be stored"
|
||||||
assert row.footer_text == FOOTER
|
|
||||||
for field in COLOR_FIELDS:
|
for field in COLOR_FIELDS:
|
||||||
assert getattr(row, field) == PALETTE[field]
|
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")
|
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
|
# 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
|
# + the computed palette at load (the no-pop-in proof), and the
|
||||||
# strings via the boot fetch
|
# 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 + "/")
|
anon.goto(app_url + "/")
|
||||||
_assert_raw_tag(anon.content(), PALETTE)
|
_assert_raw_tag(anon.content(), PALETTE)
|
||||||
_wait_theme_computed(anon, PALETTE)
|
_wait_theme_computed(anon, PALETTE)
|
||||||
# The B4 split: the 3 strings are NOT pre-paint — they apply
|
# Phase 190 (A1): the palette save wrote NO brand string, so
|
||||||
# post-fetch via the /api/config boot fetch (brand.js) on the
|
# the anonymous page renders the env/template strings through
|
||||||
# anonymous page too: the name (header brand + window
|
# the SAME brand.js boot fetch (name + window global, the
|
||||||
# global), the placeholder, and the footer line.
|
# composer placeholder, the footer line) — saving a theme can no
|
||||||
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
|
# longer rename the deployment, which is the owner-visible half
|
||||||
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
|
# 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(
|
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:
|
finally:
|
||||||
if anon_ctx is not None:
|
if anon_ctx is not None:
|
||||||
anon_ctx.close()
|
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)
|
# 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(
|
def test_reset_restores_the_builtin_byte_identical(
|
||||||
page: Page, app_url: str, db_ready: None
|
page: Page, app_url: str, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
defaults = _template_defaults()
|
|
||||||
builtin = _builtin_colors()
|
builtin = _builtin_colors()
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
login(page, app_url, next="/theme.html")
|
login(page, app_url, next="/theme.html")
|
||||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||||
# The form settles on the effective defaults (the row-less
|
# The form settles on the effective defaults (the row-less
|
||||||
# state — the autouse clean truncated the row).
|
# 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
|
# Save a distinct theme through the UI (the reset must undo a
|
||||||
# REAL save)…
|
# REAL save)…
|
||||||
@@ -672,9 +752,9 @@ def test_reset_restores_the_builtin_byte_identical(
|
|||||||
finally:
|
finally:
|
||||||
_release_theme_puts(page)
|
_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)…
|
# 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
|
# …and the WCAG warning is gone (the built-in palette passes all
|
||||||
# eight pairs).
|
# eight pairs).
|
||||||
expect(page.locator("#theme-contrast")).to_be_hidden()
|
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_ink": "#d9c37a",
|
||||||
"accent_line": "#b8963e",
|
"accent_line": "#b8963e",
|
||||||
}
|
}
|
||||||
APP_NAME = "Theme E2E"
|
# No saved-string fixture since phase 190 (A1): the admin Settings
|
||||||
PLACEHOLDER = "Ask the themed brain…"
|
# panel owns the brand strings, so this surface saves COLORS only and
|
||||||
FOOTER = "E2E footer"
|
# every string assertion reads the env/template default.
|
||||||
SAVED_STRINGS: dict[str, str] = {
|
|
||||||
"app_name": APP_NAME,
|
|
||||||
"input_placeholder": PLACEHOLDER,
|
|
||||||
"footer_text": FOOTER,
|
|
||||||
}
|
|
||||||
|
|
||||||
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
|
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
|
||||||
#: own markup — the static E2E-stable-selectors house convention).
|
#: 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:
|
def _login_to_theme(page: Page, app_url: str) -> None:
|
||||||
"""The theme view's mount-time load has SETTLED: its populate is
|
"""Log in straight into the Theme view AND wait for its mount-time
|
||||||
the last visible step — the app-name input carries the effective
|
``GET /api/ui-settings`` to ANSWER — the settle signal.
|
||||||
default (the static markup ships it empty, so only a settled
|
|
||||||
``loadSettings`` can have written it). From here on, no in-flight
|
Phase 190 (task 02) moved it: the settle used to be read off the
|
||||||
mount load can race the test's own save/reset refetch (an
|
``#theme-app-name`` input (the only input the static markup ships
|
||||||
out-of-order settle would re-reconcile the open document onto a
|
empty, so a value in it proved ``populate`` had run) — that input is
|
||||||
stale read)."""
|
gone with the tab's string fieldset, so the network is the source of
|
||||||
expect(page.locator("#theme-app-name")).to_have_value(
|
truth now. From here on, no in-flight mount load can race the test's
|
||||||
_template_defaults()["app_name"], timeout=15_000
|
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]:
|
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:
|
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
|
||||||
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
|
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
|
||||||
seed — the UI save itself is test 1's job)."""
|
(the API seed — the UI save path is this module's own tests' job).
|
||||||
body = {**PALETTE, **SAVED_STRINGS}
|
|
||||||
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
|
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.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:
|
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")
|
page.unroute("**/api/ui-settings")
|
||||||
|
|
||||||
|
|
||||||
def _fill_theme_form(
|
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
|
||||||
page: Page,
|
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
|
||||||
palette: dict[str, str],
|
The tab has no string inputs since phase 190 (A1)."""
|
||||||
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"])
|
|
||||||
for field, value in palette.items():
|
for field, value in palette.items():
|
||||||
page.fill(COLOR_INPUT_IDS[field], value)
|
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:
|
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
builtin = _builtin_colors()
|
builtin = _builtin_colors()
|
||||||
page.set_default_timeout(30_000)
|
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-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
|
# Fill the 17 color inputs and Save through the real form — the PUT
|
||||||
# through the real form — the PUT held so the §7.4 in-flight state
|
# held so the §7.4 in-flight state
|
||||||
# is observable deterministically (the same lifecycle assertions
|
# is observable deterministically (the same lifecycle assertions
|
||||||
# as the phase-91 suite — the save's contract is unchanged).
|
# as the phase-91 suite — the save's contract is unchanged).
|
||||||
_fill_theme_form(page, PALETTE)
|
_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).
|
# feed the PUT body, the live preview, and the refetch).
|
||||||
for field in COLOR_FIELDS:
|
for field in COLOR_FIELDS:
|
||||||
page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
|
page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
|
||||||
# The 3 strings stay default (untouched — they hold the effective
|
# The tab has no string inputs (phase 190, A1) — the 3 brand
|
||||||
# template defaults the module server pins).
|
# strings are the Settings panel's, so nothing here can touch them.
|
||||||
|
|
||||||
# Capture the PUT body the form builds (the wiring proof: all 17
|
# Capture the PUT body the form builds (the wiring proof: the 17
|
||||||
# grays ride the wire together with the 3 default strings).
|
# grays ride the wire and NOTHING else — no string key, phase 190).
|
||||||
captured: dict[str, Any] = {}
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
def handle(route: Route) -> None:
|
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).
|
# hidden (it re-checks the saved state on the save's refetch).
|
||||||
expect(page.locator("#theme-contrast")).to_be_hidden()
|
expect(page.locator("#theme-contrast")).to_be_hidden()
|
||||||
|
|
||||||
# The PUT carried every field: the 17 grays + the 3 default strings
|
# The PUT carried the 17 grays and NOTHING else (phase 190, A1:
|
||||||
# (the strings were never touched, so they ride at their defaults).
|
# 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")
|
body = captured.get("body")
|
||||||
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
|
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
|
# The inputs re-populate to the SAVED (effective) grays (the
|
||||||
# save's refetch is the canonical state)…
|
# 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]:
|
def _login_to_theme(page: Page, app_url: str) -> None:
|
||||||
"""The 3 template strings from the CODE defaults (derived from
|
"""Log in straight into the Theme view AND wait for its mount-time
|
||||||
the class fields — never drifts from ``app/config.py``; the
|
``GET /api/ui-settings`` to ANSWER — the settle signal.
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Phase 190 (task 02) moved it with the tab's string fieldset: the
|
||||||
def _wait_mount_settled(page: Page) -> None:
|
settle used to be read off the (now removed) ``#theme-app-name``
|
||||||
"""The theme view's mount-time load has SETTLED: its populate
|
input, the only input the static markup shipped empty. The network
|
||||||
is the last visible step — the app-name input carries the
|
is the source of truth now."""
|
||||||
effective default (the static markup ships it empty, so only a
|
with page.expect_response(
|
||||||
settled ``loadSettings`` can have written it)."""
|
lambda r: "/api/ui-settings" in r.url, timeout=30_000
|
||||||
expect(page.locator("#theme-app-name")).to_have_value(
|
) as info:
|
||||||
_template_defaults()["app_name"], timeout=15_000
|
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:
|
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."""
|
a non-driving pick (``#theme-bg``) never touches the href."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
|
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
|
||||||
login(page, app_url, next="/theme.html")
|
_login_to_theme(page, app_url)
|
||||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
|
||||||
_wait_mount_settled(page)
|
|
||||||
|
|
||||||
# A clean start: the href is the route (no row → the golden
|
# A clean start: the href is the route (no row → the golden
|
||||||
# art served by the route).
|
# 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)."""
|
re-reads the DB per request, no process cache)."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
externals, _d, failed_data, csp = _zero_external(page)
|
externals, _d, failed_data, csp = _zero_external(page)
|
||||||
login(page, app_url, next="/theme.html")
|
_login_to_theme(page, app_url)
|
||||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
|
||||||
_wait_mount_settled(page)
|
|
||||||
|
|
||||||
# A live pick first — so the save has a data: href to restore.
|
# A live pick first — so the save has a data: href to restore.
|
||||||
for field in ("surface", "brand", "brand_ink"):
|
for field in ("surface", "brand", "brand_ink"):
|
||||||
@@ -588,9 +578,10 @@ def test_save_restores_the_route_url(
|
|||||||
assert _favicon_href(page) == "/favicon.svg"
|
assert _favicon_href(page) == "/favicon.svg"
|
||||||
|
|
||||||
# The settled palette governs: the route serves the saved
|
# The settled palette governs: the route serves the saved
|
||||||
# palette's bytes (the PUT saved all 20 form values — the 3
|
# palette's bytes (the PUT saved the 17 form colors — the 3
|
||||||
# driving colors + the 14 built-ins (→ NULL) + the 3 template
|
# driving colors + the 14 built-ins (→ NULL); the brand strings are
|
||||||
# strings; the effective palette is the 3 + the built-ins).
|
# 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}
|
effective = {**theming.BUILTIN_COLORS, **DRIVING}
|
||||||
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
|
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
|
||||||
assert r.status_code == 200
|
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)."""
|
(the reset nulled the row — the built-ins govern)."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
externals, _d, failed_data, csp = _zero_external(page)
|
externals, _d, failed_data, csp = _zero_external(page)
|
||||||
login(page, app_url, next="/theme.html")
|
_login_to_theme(page, app_url)
|
||||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
|
||||||
_wait_mount_settled(page)
|
|
||||||
|
|
||||||
# Save first — the route is themed (the reset then has a real
|
# Save first — the route is themed (the reset then has a real
|
||||||
# state to settle back from).
|
# state to settle back from).
|
||||||
@@ -672,9 +661,7 @@ def test_view_refresh_restores_the_route_url(
|
|||||||
golden bytes)."""
|
golden bytes)."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
externals, _d, failed_data, csp = _zero_external(page)
|
externals, _d, failed_data, csp = _zero_external(page)
|
||||||
login(page, app_url, next="/theme.html")
|
_login_to_theme(page, app_url)
|
||||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
|
||||||
_wait_mount_settled(page)
|
|
||||||
|
|
||||||
# A live pick — the href is the data: one the re-show restores.
|
# A live pick — the href is the data: one the re-show restores.
|
||||||
for field in ("surface", "brand", "brand_ink"):
|
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 +
|
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.
|
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
|
**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
|
(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
|
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
|
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:
|
def _describe(nodes: list[dict[str, Any]]) -> str:
|
||||||
return ", ".join(
|
return ", ".join(
|
||||||
f"{node.get('Node Type')}[{node.get('Index Name') or node.get('Relation Name') or '?'}]"
|
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 = Config() # no alembic.ini — env.py reads the URL from app config
|
||||||
config.set_main_option("script_location", "alembic")
|
config.set_main_option("script_location", "alembic")
|
||||||
try:
|
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:
|
if preexisting > MAX_RELATION_ROWS:
|
||||||
pytest.skip(
|
pytest.skip(
|
||||||
f"{RELATION} holds {preexisting} rows: the exact reference is a Python "
|
f"{RELATION} holds {preexisting} rows: the exact reference is a Python "
|
||||||
@@ -384,15 +428,34 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
|
|||||||
session.rollback()
|
session.rollback()
|
||||||
command.upgrade(config, "0029") # the ONE definition of the index
|
command.upgrade(config, "0029") # the ONE definition of the index
|
||||||
session.rollback()
|
session.rollback()
|
||||||
# Planner statistics for the plan pin: a freshly loaded relation has
|
# The premise of every pin below, checked where it is established: an
|
||||||
# none, and a row estimate of 0 makes "seq scan + top-N sort" look free.
|
# 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(f"ANALYZE {RELATION}"))
|
||||||
|
session.execute(text("ANALYZE documents"))
|
||||||
session.commit()
|
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}
|
stored: dict[Any, list[float]] = {row[0]: row[1] or [] for row in rows}
|
||||||
assert len(stored) >= preexisting + CORPUS_CHUNKS, (
|
assert len(stored) >= preexisting_embedded + CORPUS_CHUNKS, (
|
||||||
f"expected {CORPUS_CHUNKS} fixture rows on top of {preexisting}, "
|
f"expected {CORPUS_CHUNKS} fixture rows on top of "
|
||||||
f"the relation holds {len(stored)}"
|
f"{preexisting_embedded} embedded rows, the relation holds {len(stored)}"
|
||||||
)
|
)
|
||||||
# The exact sets are the expensive half (30 × N dot products); the two
|
# 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.
|
# 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],
|
"exact": [_exact_top(stored, query) for query in queries],
|
||||||
"chunk_ids": set(stored),
|
"chunk_ids": set(stored),
|
||||||
"plan_sql": _vector_statement_sql(),
|
"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:
|
finally:
|
||||||
session.rollback()
|
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
|
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
|
a ``finally`` — a leaked ``off`` would slow every later test on this pooled
|
||||||
connection — and the assertion beside it proves the lever took effect.
|
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.rollback()
|
||||||
session.execute(text("SET enable_seqscan = off"))
|
session.execute(text("SET enable_seqscan = off"))
|
||||||
|
session.execute(text("SET enable_bitmapscan = off"))
|
||||||
|
|
||||||
|
|
||||||
def _release_index_scan(session: Session) -> None:
|
def _release_index_scan(session: Session) -> None:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
session.execute(text("RESET enable_seqscan"))
|
session.execute(text("RESET enable_seqscan"))
|
||||||
|
session.execute(text("RESET enable_bitmapscan"))
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
@@ -467,14 +546,38 @@ 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
|
Explaining the captured statement is also what proves D1's "no query
|
||||||
rewrite": the ``<=>`` the retriever already emits is exactly the operator
|
rewrite": the ``<=>`` the retriever already emits is exactly the operator
|
||||||
``vector_cosine_ops`` serves.
|
``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"])
|
sql = hnsw_corpus["plan_sql"]
|
||||||
|
if hnsw_corpus["pristine"]:
|
||||||
|
nodes = _plan(db, sql)
|
||||||
assert _uses_index(nodes, INDEX_NAME), (
|
assert _uses_index(nodes, INDEX_NAME), (
|
||||||
f"{INDEX_NAME} is not in the plan — the incident is back: [{_describe(nodes)}]"
|
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), (
|
assert not _seq_scans(nodes, RELATION), (
|
||||||
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
|
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()
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
@@ -482,19 +585,27 @@ def test_plan_without_the_index_seq_scans_the_relation(
|
|||||||
db: Session, hnsw_corpus: dict[str, Any]
|
db: Session, hnsw_corpus: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vacuity guard: the same statement, same session, same statistics, with
|
"""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
|
``SET LOCAL enable_indexscan/indexonlyscan/bitmapscan = off`` rather than
|
||||||
``DROP INDEX CONCURRENTLY`` + rebuild would cost a second graph build and
|
dropping the index: a ``DROP INDEX CONCURRENTLY`` + rebuild would cost a
|
||||||
leave the corpus unindexed if the rebuild failed, while this GUC is
|
second graph build and leave the corpus unindexed if the rebuild failed,
|
||||||
transaction-scoped (it dies with the rollback) and removes exactly the
|
while these GUCs are transaction-scoped (they die with the rollback) and
|
||||||
access path under test. Without this test the plan pin would pass vacuously
|
together remove every access path that reaches an index — including the
|
||||||
on any plan that merely mentions some index.
|
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"]
|
sql = hnsw_corpus["plan_sql"]
|
||||||
db.rollback()
|
db.rollback()
|
||||||
db.execute(text("SET LOCAL enable_indexscan = off"))
|
db.execute(text("SET LOCAL enable_indexscan = off"))
|
||||||
db.execute(text("SET LOCAL enable_indexonlyscan = 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)
|
nodes = _plan(db, sql)
|
||||||
assert _seq_scans(nodes, RELATION), (
|
assert _seq_scans(nodes, RELATION), (
|
||||||
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
|
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"])
|
depth_nodes = _plan(db, hnsw_corpus["plan_sql"])
|
||||||
assert _uses_index(depth_nodes, INDEX_NAME), (
|
assert _uses_index(depth_nodes, INDEX_NAME), (
|
||||||
f"this recall run at ef_search={EF_PIN} did not use {INDEX_NAME} even "
|
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 "
|
"with sequential AND bitmap scans refused — the pin would be "
|
||||||
f"and calling it parity: [{_describe(depth_nodes)}]"
|
"measuring a scan and calling it parity (index validity: "
|
||||||
|
f"{_index_validity(db)}): [{_describe(depth_nodes)}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
overlaps: list[float] = []
|
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,
|
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
|
||||||
and the two effective strings are the entire response.
|
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.
|
Real Postgres (``podman compose up -d db``); no LLM involved.
|
||||||
|
|
||||||
Requires: podman compose up -d db
|
Requires: podman compose up -d db
|
||||||
@@ -30,12 +45,30 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import Session
|
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.core import theming
|
||||||
from app.main import app as fastapi_app
|
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
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_state(db: Session) -> Iterator[None]:
|
def clean_state(db: Session) -> Iterator[None]:
|
||||||
@@ -60,6 +93,44 @@ def _admin_client() -> TestClient:
|
|||||||
return c
|
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:
|
def test_anonymous_get_and_put_403(client: TestClient) -> None:
|
||||||
"""Router-level ``require_admin``: both routes are 403 ``admin only``
|
"""Router-level ``require_admin``: both routes are 403 ``admin only``
|
||||||
for the unsigned-in caller (one fixed detail — no enumeration)."""
|
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:
|
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
|
||||||
"""The admin passes the gate on both methods: GET reports the
|
"""The admin passes the gate on both methods: GET reports the
|
||||||
effective defaults (row missing), PUT persists + reports the new
|
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})
|
client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||||
|
|
||||||
r = client.get("/api/ui-settings")
|
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"},
|
json={"app_name": "Reese Brain", "brand": "#818cf8"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
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"
|
assert r.json()["brand"] == "#818cf8"
|
||||||
|
|
||||||
r = client.get("/api/ui-settings")
|
r = client.get("/api/ui-settings")
|
||||||
assert r.status_code == 200
|
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"
|
assert r.json()["brand"] == "#818cf8"
|
||||||
# Untouched fields stay at their defaults (DB-over-env / -built-in).
|
# Untouched fields stay at their defaults (DB-over-env / -built-in).
|
||||||
assert r.json()["footer_text"] == get_settings().footer_text
|
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(
|
def test_api_config_returns_db_strings_after_admin_put(
|
||||||
client: TestClient, db: Session
|
client: TestClient, db: Session, overlay_panel: None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
|
"""B1 with a set row: once the admin has saved the three brand
|
||||||
(the frontend's boot fetch — no admin needed) serves the DB strings
|
strings, the ANONYMOUS /api/config (the frontend's boot fetch — no
|
||||||
over the env values; the untouched fields keep the env values; the
|
admin needed) serves them over the env values; the untouched fields
|
||||||
five-key set is unchanged (the retired ``theme`` key is absent)."""
|
keep the env values; the six-key set is unchanged (the retired
|
||||||
admin = _admin_client()
|
``theme`` key is absent).
|
||||||
r = admin.put(
|
|
||||||
"/api/ui-settings",
|
Phase 190 (A1) moved the writer: the strings are seeded through the
|
||||||
json={
|
Settings panel's surface (``app_settings`` + the overlay, the phase
|
||||||
"app_name": "Brain of Testy",
|
168 ``PUT /api/admin/settings/{key}`` rows), because the Theme-tab
|
||||||
"input_placeholder": "Ask the vault…",
|
PUT no longer writes them — the DB-over-env contract this pin owns
|
||||||
"footer_text": "Powered by my own models",
|
is the same one, now owned by the panel."""
|
||||||
},
|
_seed_panel_rows(db)
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
|
|
||||||
r = client.get("/api/config")
|
r = client.get("/api/config")
|
||||||
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
|
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert set(body) == _config_keys()
|
assert set(body) == _config_keys()
|
||||||
assert body["app_name"] == "Brain of Testy"
|
assert body["app_name"] == _PANEL_STRINGS["app_name"]
|
||||||
assert body["input_placeholder"] == "Ask the vault…"
|
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
|
||||||
assert body["footer_text"] == "Powered by my own models"
|
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
|
||||||
# The colors never ride /api/config (the pre-paint injection is
|
# The colors never ride /api/config (the pre-paint injection is
|
||||||
# task 02; brand.js's surface is the five keys).
|
# task 02; brand.js's surface is the five keys).
|
||||||
assert "theme" not in body
|
assert "theme" not in body
|
||||||
assert body["version"] == get_settings().app_version
|
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 "Generate and revoke the API tokens that let people use the app." in js
|
||||||
assert 'theme: "Theme · Brain of Reese"' in js
|
assert 'theme: "Theme · Brain of Reese"' in js
|
||||||
assert (
|
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."
|
"live on the first paint."
|
||||||
) in js
|
) in js
|
||||||
assert 'settings: "Settings · Brain of Reese"' 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)"
|
"#theme-content must ship hidden (anonymous-safe)"
|
||||||
)
|
)
|
||||||
# The static form skeleton (the E2E-stable-selectors house
|
# The static form skeleton (the E2E-stable-selectors house
|
||||||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
# convention): the 17 labeled type=color palette inputs (the 9
|
||||||
# and the 17 labeled type=color palette inputs (the 9 identity
|
# identity variables, then the 8 semantic state variables — phase
|
||||||
# variables, then the 8 semantic state variables — phase 93 —
|
# 93 — one per theming.COLOR_FIELDS field), and NOTHING else: the
|
||||||
# one per theming.COLOR_FIELDS field).
|
# 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), (
|
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||||||
"the #theme-form must be STATIC markup in the shell"
|
"the #theme-form must be STATIC markup in the shell"
|
||||||
)
|
)
|
||||||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||||
assert re.search(
|
assert f'id="{field_id}"' not in body, (
|
||||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
f"#{field_id} is gone from the Theme tab (phase 190 A1)"
|
||||||
), f"missing the visible label for #{field_id}"
|
)
|
||||||
assert re.search(
|
assert f'for="{field_id}"' not in body, f"#{field_id}'s label is gone too"
|
||||||
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
|
assert "maxlength=\"300\"" not in body, (
|
||||||
), f"#{field_id} must be a text input with maxlength=300"
|
"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 (
|
for field_id in (
|
||||||
"theme-bg",
|
"theme-bg",
|
||||||
"theme-surface",
|
"theme-surface",
|
||||||
|
|||||||
@@ -221,6 +221,71 @@ def test_get_engine_creates_once_and_caches() -> None:
|
|||||||
assert first is second
|
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
|
# 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
|
# 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
|
The Theme tab edits ALL 17 palette variables (the 9 identity + the 8
|
||||||
semantic state colors — B3 revised, owner permission 2026-09-10,
|
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
|
live behavior (live preview, the Save/Reset PUT body, the served-theme
|
||||||
sync, the contrast warnings) is E2E-gated (``tests/e2e/
|
sync, the contrast warnings) is E2E-gated (``tests/e2e/
|
||||||
test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04);
|
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
|
(house pattern: ``tests/unit/test_big_read_progress.py`` reads
|
||||||
frontend sources and asserts on their mechanisms):
|
frontend sources and asserts on their mechanisms):
|
||||||
|
|
||||||
* ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order
|
* ``theme.js`` ``FIELDS`` — exactly 17 entries (the colors ONLY — no
|
||||||
(the 3 branding strings, then the 17 colors in the server's
|
``kind: "string"`` entry may come back) in the FORM's order, the
|
||||||
``theming.COLOR_FIELDS`` order — identity, brand, then state):
|
server's ``theming.COLOR_FIELDS`` order (identity, brand, then
|
||||||
everything that iterates FIELDS (live preview, ``collectBody``'s PUT
|
state): everything that iterates FIELDS (live preview,
|
||||||
body, ``clearPreview``, ``applyServedTheme``'s tag content) covers
|
``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``'
|
||||||
the 8 semantic pickers automatically only if this order holds;
|
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
|
* ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA
|
||||||
(4.5:1) pairs: the five identity pairs + the three semantic
|
(4.5:1) pairs: the five identity pairs + the three semantic
|
||||||
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
|
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
|
||||||
(decorative borders, no contrast duty — the same rule as
|
(decorative borders, no contrast duty — the same rule as
|
||||||
``--line`` / ``--grid-line``); the docstring mirror is pinned in
|
``--line`` / ``--grid-line``); the docstring mirror is pinned in
|
||||||
``tests/unit/test_theming.py``;
|
``tests/unit/test_theming.py``;
|
||||||
* ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids
|
* ``index.html`` ``#view-theme`` — the 17 color inputs carry E2E-stable
|
||||||
+ visible labels; the 17 color inputs are ``type="color"`` and ship
|
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
|
the BUILT-IN static values — asserted against
|
||||||
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
|
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
|
||||||
house drift pattern — the same guard ``test_theming.py`` runs on
|
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))
|
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:
|
def test_fields_lists_the_17_colors_and_no_strings() -> None:
|
||||||
"""``FIELDS`` has EXACTLY 20 entries — the 3 branding strings,
|
"""``FIELDS`` has EXACTLY 17 entries — the color fields ONLY — in the
|
||||||
then the 17 color fields — in the form's own order (the palette
|
form's own order (the palette fieldset's 9 identity pickers, then
|
||||||
fieldset's 9 identity pickers, then the State colors fieldset's 8
|
the State colors fieldset's 8 semantic pickers), each with its
|
||||||
semantic pickers), each with its E2E-stable ``theme-*`` id and
|
E2E-stable ``theme-*`` id and kind (the color entries are
|
||||||
kind (the color entries are ``kind: "color"`` — the live preview,
|
``kind: "color"`` — the live preview, ``collectBody``, and the
|
||||||
``collectBody``, and the served-theme sync all key on it)."""
|
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()
|
js = _js()
|
||||||
start = js.index("const FIELDS = [")
|
start = js.index("const FIELDS = [")
|
||||||
end = js.index("];", start)
|
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*\}',
|
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
|
||||||
js[start:end],
|
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] == [
|
assert [(f, i) for f, i, _ in fields] == [
|
||||||
("app_name", "theme-app-name"),
|
|
||||||
("input_placeholder", "theme-placeholder"),
|
|
||||||
("footer_text", "theme-footer"),
|
|
||||||
("bg", "theme-bg"),
|
("bg", "theme-bg"),
|
||||||
("surface", "theme-surface"),
|
("surface", "theme-surface"),
|
||||||
("ink", "theme-ink"),
|
("ink", "theme-ink"),
|
||||||
@@ -127,11 +140,14 @@ def test_fields_lists_all_20_in_form_order() -> None:
|
|||||||
("accent_bg", "theme-accent-bg"),
|
("accent_bg", "theme-accent-bg"),
|
||||||
("accent_ink", "theme-accent-ink"),
|
("accent_ink", "theme-accent-ink"),
|
||||||
("accent_line", "theme-accent-line"),
|
("accent_line", "theme-accent-line"),
|
||||||
], "FIELDS must list the 20 fields in the form's order"
|
], "FIELDS must list the 17 color fields in the form's order"
|
||||||
assert all(kind == "color" for _, _, kind in fields[3:]), (
|
assert all(kind == "color" for _, _, kind in fields), (
|
||||||
"the 17 palette entries are all kind color"
|
"every palette entry is kind color — no string field is back in the tab"
|
||||||
|
)
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
assert all(kind == "string" for _, _, kind in fields[:3])
|
|
||||||
|
|
||||||
|
|
||||||
def test_fields_color_order_is_the_server_color_fields_order() -> None:
|
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 ----------
|
# ---------- 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:
|
def test_theme_view_carries_the_17_labeled_color_inputs() -> None:
|
||||||
"""All 20 inputs are STATIC markup in the shell (the E2E-stable
|
"""The 17 color inputs are STATIC markup in the shell (the
|
||||||
selectors convention): each with a visible ``<label for>``; the 17
|
E2E-stable selectors convention), each with a visible
|
||||||
palette inputs are ``type="color"`` (the 3 branding inputs
|
``<label for>`` and ``type="color"`` — and NO string input is in the
|
||||||
``type="text"``)."""
|
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())
|
body = _theme_view(_html())
|
||||||
text_ids = ("theme-app-name", "theme-placeholder", "theme-footer")
|
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||||
for field_id in text_ids:
|
assert f'id="{field_id}"' not in body, (
|
||||||
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
|
f"#{field_id} must be gone from the Theme tab (phase 190 A1)"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
assert f'for="{field_id}"' not in body, f"#{field_id}'s label must be gone"
|
||||||
for field in theming.COLOR_FIELDS:
|
for field in theming.COLOR_FIELDS:
|
||||||
field_id = f"theme-{field.replace('_', '-')}"
|
field_id = f"theme-{field.replace('_', '-')}"
|
||||||
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
|
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
|
ellipsis. The :root 17-variable set itself stays pinned by
|
||||||
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
|
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
|
||||||
phase adds no palette variable, B3).
|
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
|
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
|
||||||
renderer, byte-identical for the built-in palette (the golden
|
renderer, byte-identical for the built-in palette (the golden
|
||||||
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
|
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 pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
import pytest
|
||||||
|
from sqlalchemy import select, text, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings, invalidate_settings
|
||||||
from app.core import theming
|
from app.core import theming
|
||||||
from app.models import UiSettings
|
from app.models import AppSetting, UiSettings
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
|
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
|
||||||
@@ -72,6 +87,16 @@ def _start_row_missing(db: Session) -> None:
|
|||||||
db.commit()
|
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]:
|
def _root_declarations() -> dict[str, str]:
|
||||||
"""The ``--name: value`` declarations of styles.css's (first)
|
"""The ``--name: value`` declarations of styles.css's (first)
|
||||||
``:root`` block, comments stripped, in file order."""
|
``: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) ----------
|
# ---------- 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()
|
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:
|
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
|
||||||
"""``settings=None`` (the design's call shape) resolves the env
|
"""``settings=None`` (the design's call shape) resolves the env
|
||||||
fallback from the cached :func:`app.config.get_settings` — the
|
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
|
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
|
||||||
contract and the GET/PUT persistence on the single ``ui_settings`` row
|
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):
|
the 8 semantic state):
|
||||||
|
|
||||||
* PUT validation — the 422s NAME the offending field (fixed details):
|
* PUT validation — the 422s NAME the offending field (fixed details):
|
||||||
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
|
a non-``#rrggbb`` color (wrong prefix, 3-digit shorthand, 8 hex
|
||||||
prefix, 3-digit shorthand, 8 hex chars, missing ``#``) — identity AND
|
chars, missing ``#``) — identity AND semantic fields alike (the loops
|
||||||
semantic fields alike (the loops are ``COLOR_FIELDS``-driven);
|
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
|
* normalization — colors are lowercased on store; a color EQUAL to its
|
||||||
built-in is stored as NULL (the owner-locked rule: "save the defaults"
|
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 /
|
must leave the row empty — the no-op injection contract);
|
||||||
whitespace-only string is the clear operation (NULL);
|
|
||||||
* GET — the effective merge (a partial row reports the DB values over
|
* GET — the effective merge (a partial row reports the DB values over
|
||||||
the env/built-in defaults);
|
the env/built-in defaults);
|
||||||
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
|
* 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()
|
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
|
admin_client: TestClient, db: Session
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
|
"""Phase 190 (A1): the 3 string fields of the body are DROPPED before
|
||||||
that field; a rejected PUT half-writes nothing; exactly 300 still
|
the handler reads them — no trim, no length check, no 422. A
|
||||||
passes (the column is VARCHAR(300))."""
|
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:
|
for field in theming.STRING_FIELDS:
|
||||||
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
|
r = admin_client.put(
|
||||||
assert r.status_code == 422, (field, r.text)
|
"/api/ui-settings", json={field: "x" * 301, "brand": "#818cf8"}
|
||||||
assert r.json()["detail"] == f"{field} is too long (max 300)"
|
)
|
||||||
# A whitespace-padded 301 is still 301 after the trim…
|
assert r.status_code == 200, (field, r.text)
|
||||||
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
|
assert r.json()[field] == getattr(get_settings(), field)
|
||||||
assert r.status_code == 422, (field, r.text)
|
db.expire_all()
|
||||||
# No rejected PUT created the row — the upsert runs after validation.
|
row = _row(db)
|
||||||
assert _row(db) is None
|
assert row is not None
|
||||||
# Exactly 300 passes — stored, trimmed.
|
assert getattr(row, field) is None, f"{field} must never be stored"
|
||||||
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
|
assert row.brand == "#818cf8" # the colors half still saves beside it
|
||||||
assert r.status_code == 200, r.text
|
# And the row the PUT created holds no string at all.
|
||||||
assert r.json()["app_name"] == "y" * 300
|
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:
|
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"]
|
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
|
admin_client: TestClient, db: Session
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A whitespace-only (or empty) string trims to empty → NULL — the
|
"""The A1 fallback survives the write strip: a legacy row that holds
|
||||||
clear operation, not a 422 and not a stored blank: the effective
|
a baked-in string keeps it through an empty / whitespace / explicit
|
||||||
value falls back to the env default."""
|
``null`` PUT — the Theme tab neither writes nor CLEARS the strings
|
||||||
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
|
(clearing one is the panel's ``DELETE /api/admin/settings/{key}``).
|
||||||
assert r.status_code == 200, r.text
|
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)
|
row = _row(db)
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row.app_name is None
|
assert row.app_name == "Legacy bake" # never written, never cleared
|
||||||
assert row.footer_text is None
|
assert row.footer_text == "Legacy footer"
|
||||||
# The response reports the effective (env) fallback, not "".
|
# The response reports the EFFECTIVE value (the stored fallback).
|
||||||
assert r.json()["app_name"] == get_settings().app_name
|
assert r.json()["app_name"] == "Legacy bake"
|
||||||
assert r.json()["footer_text"] == get_settings().footer_text
|
assert r.json()["footer_text"] == "Legacy footer"
|
||||||
|
|
||||||
|
|
||||||
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
|
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
|
"""GET reports the DB values over the defaults, column by column: a
|
||||||
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
|
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.add(UiSettings(id=1, bg="#123456"))
|
||||||
db.commit()
|
db.commit()
|
||||||
r = admin_client.get("/api/ui-settings")
|
r = admin_client.get("/api/ui-settings")
|
||||||
@@ -258,19 +280,19 @@ def test_upsert_creates_then_updates_the_id_1_row(
|
|||||||
row = _row(db)
|
row = _row(db)
|
||||||
assert row is not None and row.id == 1
|
assert row is not None and row.id == 1
|
||||||
assert row.brand == "#123abc"
|
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(
|
r2 = admin_client.put(
|
||||||
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
|
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
|
||||||
)
|
)
|
||||||
assert r2.status_code == 200, r2.text
|
assert r2.status_code == 200, r2.text
|
||||||
assert r2.json()["brand"] == "#abcdef"
|
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)
|
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||||
rows = db.execute(select(UiSettings)).scalars().all()
|
rows = db.execute(select(UiSettings)).scalars().all()
|
||||||
assert len(rows) == 1, "the upsert must never create a second row"
|
assert len(rows) == 1, "the upsert must never create a second row"
|
||||||
assert rows[0].id == 1
|
assert rows[0].id == 1
|
||||||
assert rows[0].brand == "#abcdef" # updated, not appended
|
assert rows[0].brand == "#abcdef" # updated, not appended
|
||||||
assert rows[0].input_placeholder == "Second" # the new string landed
|
assert rows[0].input_placeholder is None # the strings are never written
|
||||||
assert rows[0].app_name is None # absent in the second body → NULL
|
assert rows[0].app_name is None
|
||||||
|
|||||||
Reference in New Issue
Block a user