Compare commits
2
Commits
140a343b92
...
c6ffa050a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ffa050a6 | ||
|
|
47456a44df |
@@ -0,0 +1,51 @@
|
|||||||
|
# Phase 190 — The Settings-panel app name is the app name again (and saves apply live)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "changing the App name in settings broke, it no longer updates the name."
|
||||||
|
**Story:** n/a (owner report)
|
||||||
|
**Depends on:** none functionally (builds on the completed phase-168 admin Settings panel and phase-91/92 admin Theme tab).
|
||||||
|
|
||||||
|
## Evidence (diagnosed from the code, read this not the chat)
|
||||||
|
|
||||||
|
1. **Two writers for one setting.** `app_name` (and `input_placeholder` / `footer_text`) is now writable from TWO admin surfaces: the Settings panel (`/api/admin/settings/app_name` → `app_settings` table → the `app.config` DB-over-env overlay, phase 168) and the Theme tab (`/api/ui-settings` PUT → the single `ui_settings` row, phase 91).
|
||||||
|
2. **The Theme tab row WINS.** `app.core.theming.effective_settings` (L144–177) resolves each string as: the `ui_settings` row value when it is a non-empty string, **else** `settings.<field>` (the env value, which — via the phase-168 overlay — is the Settings-panel row value when one exists). So the order is Theme-tab row → Settings-panel row → env.
|
||||||
|
3. **A Theme-tab save bakes the strings in.** `theme.js` `collectBody()` (L526) collects ALL 20 fields — the 3 string inputs are pre-populated with the EFFECTIVE value on load — and `PUT /api/ui-settings` writes every non-empty string into the row. So the moment an admin saves ANY color from the Theme tab, the current app name is baked into `ui_settings.app_name`, and from then on **every later Settings-panel app-name change is shadowed** by that baked row. This is exactly "it no longer updates the name" — the Settings panel shows the new value with its `db` badge, but `/api/config` (which serves `effective_settings`) reports the Theme tab's baked name.
|
||||||
|
4. **No live brand update, on either surface.** `settings.js` re-GETs the catalogue after a save but never re-applies the brand strings to the open document; `theme.js` after a save runs `applyServedTheme(settings)` which reconciles the COLOR palette (phase 92) but never touches `document.title`, `.brand-text`, the footer or the composer placeholder. `brand.js` runs the brand path once at boot from `/api/config`. So even when the server-side value is right, the open page keeps showing the old name until a manual reload.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The Settings panel is the single writable owner of the three brand strings and always wins; the Theme tab becomes colors-only (its PUT no longer writes the strings, its form drops them — for legacy baked-in rows the panel still wins); and a save in either surface re-applies the brand strings to the open document immediately, so the change is visible the moment "save" is clicked (the phase-168 contract extended to the display).
|
||||||
|
|
||||||
|
## ASSUMPTIONS (owner-confirmed 2026-10-18)
|
||||||
|
- **A1 — Panel wins, strings stripped from the Theme tab.** The Settings-panel row beats the Theme-tab row for `app_name` / `input_placeholder` / `footer_text` (no data migration — existing baked-in rows simply stop shadowing the panel; with no panel row they still apply as the documented fallback). The Theme tab's form drops the three string inputs and its PUT body no longer carries them (the server ignores string fields in the PUT body defensively — an old client's save can no longer re-bake a string). The Theme tab's GET keeps reporting the effective strings (read-only, old-client compat).
|
||||||
|
- **A2 — Live brand on save.** After a successful Settings-panel save/clear or Theme-tab save/reset, the open document's `document.title`, `.brand-text`, the footer and the composer placeholder are re-applied from a fresh `/api/config` — no reload. The boot path (`brand.js`) is byte-identical.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- `app/core/theming.py` — `effective_settings` (L144–177: the string resolution to change), `STRING_FIELDS`, `COLOR_FIELDS`, `BUILTIN_COLORS`; `app/api/config.py` — `GET /api/config` (serves `effective_settings` + `source` per string); `app/api/ui_settings.py` — the phase-91 admin API (GET reports 20 fields, PUT writes 3 strings + 17 colors, DELETE resets the row); `app/core/settings_overlay.py` + `app/config.py` `invalidate_settings` (L1129) — the phase-168 DB-over-env overlay (the panel rows; `settings.app_name` already resolves through it); `app/api/admin_settings.py` (phase 168, the panel surface).
|
||||||
|
- `frontend/assets/brand.js` — the boot-time brand path (title / `.brand-text` / footer / placeholder from `/api/config`); `frontend/assets/theme.js` — `FIELDS` (L183–185 the 3 string fields), `collectBody` (L526), `saveTheme` (L580, `applyServedTheme` L589), `resetTheme` (L597); `frontend/assets/settings.js` — the phase-168 panel (its header names the phase-168 contract "the change should take effect on clicking 'save'"); `frontend/index.html` — the Theme-tab markup (`#theme-app-name` L1354 et al., the three string rows to remove).
|
||||||
|
- `tests/unit/test_theming.py` (the `effective_settings` pins), `tests/integration/test_ui_settings_api.py` (the phase-91 API pins), `tests/e2e/test_admin_theme_tab.py` (fills `#theme-app-name` — must move with the strip), `tests/e2e/test_settings_panel.py` (the phase-168 pins), `tests/unit/test_save_chat_ui.py` (JS marker-pin convention).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_reproduce_shadow_and_staleness.md` — failing tests pinning both defects (the shadowing through `effective_settings` / `/api/config`, the Theme-tab PUT string bake, the no-live-update behavior).
|
||||||
|
2. `02_precedence_and_strip_theme_tab.md` — A1: the resolution order flips to panel → tab → env; the Theme tab drops the 3 string inputs and its PUT stops writing them; the pinned theme-tab suites move.
|
||||||
|
3. `03_live_brand_on_save.md` — A2: one shared `applyBrandFromConfig` pass (the `brand.js` DOM path extracted, boot behavior byte-identical) re-applied after a Settings-panel save/clear and a Theme-tab save/reset.
|
||||||
|
4. `04_e2e_and_gates.md` — the dedicated Playwright suite (the incident regression through the real UI), the full gates, the docs pass, the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- **Unit:** `tests/unit/test_theming.py` — the NEW resolution order pins (panel row beats a conflicting `ui_settings` row; with no panel row the tab row still applies — the A1 fallback); the pre-phase pins that do not involve a panel row stay green.
|
||||||
|
- **Integration:** `tests/integration/test_ui_settings_api.py` — a Theme-tab PUT with string fields in the body **ignores** them (the stored row's string columns are untouched / the response no longer round-trips a changed string); `tests/integration/` Settings-panel pins (phase 168) green — the panel write + `invalidate_settings` path is untouched.
|
||||||
|
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_app_name_settings_fix.py`, run in isolation — (a) the incident regression: set the name in the Settings panel → save a color in the Theme tab → the name still shows the panel's value (pre-fix: the baked tab value); (b) live apply: change the app name in the Settings panel → `document.title`, `.brand-text`, the footer and the composer placeholder update WITHOUT a reload; (c) a Theme-tab save no longer bakes the name (a later panel change is visible on reload); (d) a panel reset (DELETE) falls back to env live.
|
||||||
|
- **Regression:** `tests/e2e/test_settings_panel.py`, `tests/e2e/test_admin_theme_tab.py` (moved in task 02), `tests/e2e/test_theme_save_and_coverage.py` green.
|
||||||
|
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`); the frontend change is marker-pinned per the house JS convention.
|
||||||
|
- **Lint/types:** `uv run ruff check . && uv run pyright`.
|
||||||
|
|
||||||
|
## Documentation & Consistency
|
||||||
|
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
|
||||||
|
- `README.md`: the settings/theme section notes the single-writer ownership (the Settings panel owns the brand strings; the Theme tab is colors-only) and the live-apply behavior (task 04). `AGENTS.md`: the phase-168 "settings overlay" description gains the resolution-order clause if it names one (verify, do not add if absent).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `effective_settings` resolves the 3 strings panel-row → tab-row → env (unit pins, both branches).
|
||||||
|
- [ ] `PUT /api/ui-settings` with string fields in the body does not write them (integration pin); the Theme-tab form no longer renders the 3 inputs (E2E pin).
|
||||||
|
- [ ] A Settings-panel app-name change is visible in `document.title`, `.brand-text`, the footer and the placeholder without a reload (E2E); the Theme-tab color save no longer shadows it (E2E regression of the incident).
|
||||||
|
- [ ] the `brand.js` boot path is byte-identical (marker pin) — no double-apply at boot.
|
||||||
|
- [ ] test suite green, coverage >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed phases (the 17-color palette, the phase-168 panel, the env fallback all behave as before).
|
||||||
|
- [ ] one atomic Conventional Commits commit with `--no-gpg-sign`.
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Task 01 — Reproduce and pin the two defects (shadowing + no live update)
|
||||||
|
|
||||||
|
**Phase:** `190_app_name_settings_fix` · **Source:** `TODO.md:4 — "changing the App name in settings broke, it no longer updates the name."`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Before touching behavior: pin BOTH defects with failing tests — (a) the `ui_settings` row shadows the Settings-panel row in `effective_settings` and therefore in `GET /api/config`, and a Theme-tab PUT bakes the effective strings into the row (the mechanism of "it no longer updates"); (b) a Settings-panel save never re-applies the brand strings to the open document (marker pin on the ABSENT behavior, inverted in task 03).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/unit/test_theming.py` — NEW pins (failing until task 02):
|
||||||
|
- `test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row` — an `app_settings` row (via the phase-168 overlay / a `Settings` with `app_name="Panel Name"`) + a `ui_settings` row with `app_name="Tab Name"` → `effective_settings["app_name"] == "Panel Name"` (today: `"Tab Name"` — the documented A1 order). Same pair for `input_placeholder` and `footer_text`.
|
||||||
|
- `test_effective_strings_fall_back_to_ui_settings_row_without_panel_row` — `ui_settings` row set, no panel row (the `Settings` at the env default) → the tab value applies (the A1 fallback this pin protects — it must stay GREEN pre-phase and post-phase).
|
||||||
|
- The pre-existing pins that assume tab-over-env with NO panel row stay green unedited.
|
||||||
|
2. `tests/integration/test_ui_settings_api.py` — NEW pins (failing until task 02):
|
||||||
|
- `test_put_ignores_string_fields_in_body` — a `PUT /api/ui-settings` whose body carries `app_name` / `input_placeholder` / `footer_text` DIFFERENT from the stored row's values and from the effective value → 200; the stored row's string columns are UNCHANGED (re-GET shows the old values); the 17 colors in the same body ARE stored (the colors half of the contract survives).
|
||||||
|
- `test_get_reports_effective_strings_read_only` — with a conflicting panel row, `GET /api/ui-settings` reports the EFFECTIVE (panel) value for the strings (read-only view; the A1 order reaches the API).
|
||||||
|
3. `tests/unit/test_brand_live.py` (NEW module) — marker pins on the CURRENT (absent) behavior, documented as "inverted in task 03":
|
||||||
|
- `settings.js` contains NO call that re-applies `document.title` / `.brand-text` after a save (assert the marker is ABSENT pre-phase — the test asserts the file's save path does not reference the brand DOM ids; task 03 inverts the expectation);
|
||||||
|
- `theme.js` `saveTheme`'s success path calls `applyServedTheme` (colors only) and does NOT touch the brand DOM (marker pin — stays green through the phase; the live-apply added in task 03 is asserted there, not here).
|
||||||
|
4. Verify: `uv run pytest tests/unit/test_theming.py tests/integration/test_ui_settings_api.py tests/unit/test_brand_live.py -v` — the NEW pins FAIL (documented), everything else green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: the pins above — the shadowing mechanism, the string-bake, the fallback (green pre-phase), the absent-live-behavior markers.
|
||||||
|
- Coverage: **>90%** on `app/` (no app code changes this task — the pins exercise existing code).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] the 3 `effective_settings` order pins FAIL pre-fix (documented in the test docstrings: "fixed by task 02").
|
||||||
|
- [ ] the string-bake integration pin FAILS pre-fix (documented); the colors-half pin is GREEN pre-fix.
|
||||||
|
- [ ] the live-behavior marker pins pass (asserting the ABSENT behavior, inverted in task 03).
|
||||||
|
- [ ] full test suite **100% passing** except the documented failing pins (the failing pins are the task's artifact — the phase's E2E gate runs them post-fix).
|
||||||
|
- [ ] no behavior change in completed work (app/ untouched).
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Task 02 — The panel wins; the Theme tab becomes colors-only (A1)
|
||||||
|
|
||||||
|
**Phase:** `190_app_name_settings_fix` · **Source:** `TODO.md:4 — "changing the App name in settings broke, it no longer updates the name."`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Flip the string resolution order to Settings-panel row → Theme-tab row → env (no data migration — existing baked-in rows stop shadowing the panel), and strip the three string inputs from the Theme tab: the form drops them, `collectBody` stops collecting them, and the server's `PUT /api/ui-settings` persists ONLY the 17 colors (string fields in the body are ignored defensively — an old client's save can no longer re-bake a string; string columns are left untouched, not cleared, so the A1 fallback survives).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/core/theming.py` — `effective_settings` (L170–173), the string loop becomes a three-way resolution per field:
|
||||||
|
- **panel row** — `getattr(settings, field)` (the `Settings` passed by the route; the phase-168 overlay makes it the panel value when an `app_settings` row exists, else env) is used when a panel row exists for that key. The panel presence is read through the SAME overlay source the `Settings` object carries: the overlay's `app_settings` row map (`app.core.settings_overlay.load_overrides()` — the one query the overlay already caches per process; the route passes the settled `Settings`, so the check is `field in the override map` — implement as `has_override(field)` on the overlay module, one cached read, no second query per field);
|
||||||
|
- **else the tab row** — the `ui_settings` row value when it is a non-empty string (the phase-91 fallback, now second);
|
||||||
|
- **else the env value** — `getattr(settings, field)` (identical expression to today's fallback).
|
||||||
|
- The 17-color half is UNTOUCHED (row → `BUILTIN_COLORS`). The docstring's resolution table is rewritten (the old "DB value when non-empty, else env" becomes the three-way table).
|
||||||
|
- `app/api/config.py` — the per-string `source` badge (`db` / `env`): `db` now means the PANEL row; the tab row yields the existing non-`db` value (verify the current `source` vocabulary — if it is a two-value `db`/`env` pair, `db` = any stored row wins, unchanged; do NOT grow the vocabulary).
|
||||||
|
2. `app/api/ui_settings.py` — the PUT handler:
|
||||||
|
- persist ONLY the 17 `COLOR_FIELDS` from the body; the three string columns are NEVER written (the request model keeps accepting the fields — old-client compat — the handler drops them before persisting); the stored row's string values are left exactly as stored (a legacy baked-in row keeps its value; the A1 order decides visibility);
|
||||||
|
- the DELETE (reset) path: UNCHANGED (it nulls the whole row — including the strings; a reset is a user decision, and the A1 fallback then falls through to env, which is the documented reset behavior).
|
||||||
|
- the GET: unchanged (reports the effective strings — with the new order, a conflicting panel value is what the tab page pre-populates its (now removed) inputs with in an old client; the read-only report stays).
|
||||||
|
3. `frontend/assets/theme.js`:
|
||||||
|
- `FIELDS` (L183–185): the three `kind: "string"` entries are REMOVED — `FIELDS` becomes the 17 color fields only; everything FIELDS-driven (live preview, `collectBody`, `applyServedTheme`'s tag content) stays in `COLOR_FIELDS` order (the comment at L180–181 already says so — it becomes true);
|
||||||
|
- `collectBody` (L526): the body is the 17 colors only (no string keys at all);
|
||||||
|
- the string-specific docstring lines (L183–185 region + any "20 fields" counts in the module header) become "17 colors."
|
||||||
|
4. `frontend/index.html` — the Theme-tab markup: the three string rows (`#theme-app-name` L1354 et al., the placeholder + footer rows) are REMOVED from the form; the section count / any "3 strings + 17 colors" comment becomes colors-only.
|
||||||
|
5. `tests/unit/test_theming.py` — the task-01 failing pins now PASS unedited (they name the A1 order); the no-panel-row fallback pin stays green.
|
||||||
|
6. `tests/integration/test_ui_settings_api.py` — the task-01 string-bake pin now PASSES (body strings ignored, colors stored, string columns untouched).
|
||||||
|
7. The pinned theme-tab E2E suites that fill the removed inputs move:
|
||||||
|
- `tests/e2e/test_admin_theme_tab.py` — every step that fills `#theme-app-name` / `#theme-placeholder` / `#theme-footer` (or asserts their presence) is RETARGETED: the tab renders 17 color inputs only (a new pin: the three string inputs are ABSENT from the tab DOM — `page.locator("#theme-app-name")` count 0); a save still round-trips the 17 colors through the GET pre-population.
|
||||||
|
- `tests/e2e/test_theme_save_and_coverage.py` / any other suite that seeds a string through the tab: the string is now seeded through the Settings panel (the `test_settings_panel.py` helper) or the API (the integration test covers the wire; the E2E keeps its color assertions).
|
||||||
|
8. `tests/unit/test_theme_ui.py` (or the module that marker-pins `theme.js` — verify the name in `tests/unit/`) — marker pins: `FIELDS` has no `kind: "string"` entry; `collectBody`'s body has no string keys (the source-marker convention); the `saveTheme` success path still calls `applyServedTheme`.
|
||||||
|
9. Verify: `uv run pytest tests/unit/test_theming.py tests/integration/test_ui_settings_api.py -v` green; `uv run pytest tests/e2e/test_admin_theme_tab.py tests/e2e/test_settings_panel.py -v --no-cov` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the A1 order pins (both branches) + the marker pins (tab has no string fields).
|
||||||
|
- Integration: string-ignored PUT pin + the phase-91 color pins green unedited.
|
||||||
|
- E2E (regression, run in isolation): `test_admin_theme_tab.py` (moved) + `test_settings_panel.py` green.
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `effective_settings` resolves the 3 strings panel → tab → env (both-branch unit pins green).
|
||||||
|
- [ ] `PUT /api/ui-settings` with string fields in the body stores no string change and stores the colors (integration pin green).
|
||||||
|
- [ ] the Theme-tab DOM renders 17 color inputs only; the 3 string inputs are absent (E2E pin).
|
||||||
|
- [ ] the phase-91 color contract (GET pre-population, save, reset) is green unedited.
|
||||||
|
- [ ] full test suite **100% passing**.
|
||||||
|
- [ ] no behavior change in completed work (the 17 colors, the phase-168 panel, the env fallback, the DELETE-reset behavior all as before).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 03 — The save is visible without a reload (A2)
|
||||||
|
|
||||||
|
**Phase:** `190_app_name_settings_fix` · **Source:** `TODO.md:4 — "changing the App name in settings broke, it no longer updates the name."` (the phase-168 header contract: "the change should take effect on clicking 'save'")
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A successful save in the Settings panel (or a per-row reset) and a save/reset in the Theme tab re-applies the brand strings to the OPEN document immediately — `document.title`, `.brand-text`, the footer and the composer placeholder — from a fresh `GET /api/config`, with no reload. The boot path in `brand.js` is extracted into the shared pass byte-identically, so boot and live-apply are ONE function and the never-stale contract (PLAN §7.4) covers the strings the way it already covers the palette (phase 92 `applyServedTheme`).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/brand.js` — extract the DOM path (title / `.brand-text` / footer / placeholder assignment from the `/api/config` payload) into ONE named function `applyBrandFromConfig(cfg)` (same file, same module scope — no new file, no new CDN; `brand.js` stays the single owner of the brand DOM):
|
||||||
|
- the BOOT call site (`initBrand` / the boot fetch-then-apply) calls `applyBrandFromConfig(cfg)` — the observable boot behavior is byte-identical (same request, same assignments, same order);
|
||||||
|
- the function is exported to the module's existing export surface (the `window` / module pattern `brand.js` already uses — verify the convention; `theme.js` and `settings.js` import from the same pattern).
|
||||||
|
2. `frontend/assets/settings.js` — the save path:
|
||||||
|
- after a SUCCESSFUL per-key PUT (`PUT /api/admin/settings/<key>`, the phase-168 success note) and after a SUCCESSFUL per-row reset (DELETE) and the reset-all: `await fetch("/api/config")` → `applyBrandFromConfig(cfg)` — ONLY when the saved/removed key is one of the three brand strings (`app_name` / `input_placeholder` / `footer_text`); a save of any other key does NOT re-apply the brand (no wasted request, no DOM churn on 72 other rows);
|
||||||
|
- the §7.4 guard: while the re-apply fetch is in flight, a second save does not stack a second fetch (the existing `saveInFlight` guard's scope — extend, do not fork);
|
||||||
|
- the existing post-save re-GET of the catalogue is UNTOUCHED (it stays the catalogue's own refresh; the brand re-apply is additive and independent).
|
||||||
|
3. `frontend/assets/theme.js` — the save/reset path:
|
||||||
|
- `saveTheme` success (after the existing `applyServedTheme(settings)` at L589) and `resetTheme` success: `await fetch("/api/config")` → `applyBrandFromConfig(cfg)`. (The tab no longer edits the strings, but a RESET nulls the stored row — with a legacy baked-in string that reset makes the effective string FALL BACK (panel row → env), so the open document must re-read it; the save path keeps the same call — one code path for both, the body change is the colors.)
|
||||||
|
- the fetch-failure path: the brand keeps its last applied state (never a blanked title/footer — the phase-168 "never a blanked panel" precedent, applied to the brand: a failed re-apply leaves the DOM as-is; the save's own error note is the visibility).
|
||||||
|
4. `tests/unit/test_brand_live.py` — the task-01 absent-behavior marker pins are INVERTED:
|
||||||
|
- `settings.js`'s save path references `applyBrandFromConfig` (the marker is now PRESENT) and gates the re-apply on the three string keys (the source marker names the key check);
|
||||||
|
- `theme.js`'s `saveTheme`/`resetTheme` success paths reference `applyBrandFromConfig` (marker present);
|
||||||
|
- `brand.js` exports `applyBrandFromConfig` and the boot call site calls it (marker — the extraction, not a duplication: `initBrand`/boot contains no second copy of the four DOM assignments — they exist only in the shared function).
|
||||||
|
5. Verify: `uv run pytest tests/unit/test_brand_live.py -v` green; `uv run pytest tests/e2e/test_settings_panel.py -v --no-cov` green (the phase-168 suite — its save flow now also triggers the brand re-apply; its pins must stay green — the re-apply is additive); `uv run pytest` green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the inverted marker pins (shared function exists; both surfaces call it; the boot path is the extraction — single copy).
|
||||||
|
- Integration: none (pure frontend — the wire contract is task 02's; `/api/config` is unchanged).
|
||||||
|
- E2E: covered by task 04 (the owner-visible arm).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — the task is frontend; the app-side gate stays green).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `applyBrandFromConfig` is ONE function in `brand.js`; the boot call site uses it; the four DOM assignments exist in exactly one place (marker pins).
|
||||||
|
- [ ] `settings.js` re-applies the brand after a save/reset of a brand-string key only; `theme.js` re-applies after save and reset (marker pins).
|
||||||
|
- [ ] a failed re-apply leaves the DOM at its last applied state (marker pin on the failure path).
|
||||||
|
- [ ] `test_settings_panel.py` green unedited.
|
||||||
|
- [ ] no behavior change in completed work (the boot path, the phase-168 catalogue refresh, the phase-92 palette sync all as before).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Task 04 — The phase E2E suite, the full gates, the docs pass, the commit
|
||||||
|
|
||||||
|
**Phase:** `190_app_name_settings_fix` · **Source:** `TODO.md:4`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The dedicated Playwright suite proves the incident regression and the live-apply contract through the real UI (run in isolation per AGENTS rule 4); the full gates pass; `README.md` / `AGENTS.md` are consistent with the post-phase app; one atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_app_name_settings_fix.py` (NEW — the module docstring names the incident: "a Theme-tab save used to bake the effective name into `ui_settings` and shadow every later Settings-panel change") — the app under test is the standard module-app pattern (the phase-168/91 suites' fixture — verify the convention in `tests/e2e/conftest.py` / the sibling suites):
|
||||||
|
- **(a) the incident regression (server-side, at the wire):** with an env default name, the admin sets the app name via the Settings panel UI (type + save, the phase-168 flow) → `GET /api/config` (via `page.request`) reports the panel value with the `db` source; then the admin opens the Theme tab, changes ONE color and saves → `GET /api/config` STILL reports the panel value (pre-fix: the baked tab value); the stored `ui_settings` row's string columns are unchanged (the API-level assertion — `page.request` GET `/api/ui-settings`).
|
||||||
|
- **(b) live apply, panel:** change the app name in the Settings panel (a brand-string row) → after the save note, WITHOUT a reload: `document.title`, `.brand-text` (the header), the footer text and the composer placeholder all carry the new name (the four assertions in one test — the A2 contract).
|
||||||
|
- **(c) live apply, tab reset:** with a legacy `ui_settings` row pre-seeded (the API seeds the row directly — a baked `app_name`), a Theme-tab Reset → the open document's brand falls back (panel row or env — seed NO panel row so the fallback is env) WITHOUT a reload.
|
||||||
|
- **(d) the boot path is byte-identical:** a fresh page load (no save) shows the env/panel name exactly as the pre-phase `brand.js` did (the `test_admin_theme_tab.py` boot pin, moved here if the moved suite no longer covers it).
|
||||||
|
- **(e) the tab DOM is colors-only:** the Theme tab renders 17 color inputs and NO `#theme-app-name` / placeholder / footer string inputs (the task-02 pin, asserted in the isolated suite as well).
|
||||||
|
2. Regression, run in isolation: `tests/e2e/test_admin_theme_tab.py` (moved in task 02), `tests/e2e/test_settings_panel.py`, `tests/e2e/test_theme_save_and_coverage.py` — all green unedited after the moves.
|
||||||
|
3. The full gates, in order:
|
||||||
|
- `uv run pytest` (unit + integration) — 100% passing;
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`;
|
||||||
|
- `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` — green in isolation;
|
||||||
|
- `uv run ruff check . && uv run pyright` — clean.
|
||||||
|
4. Documentation pass (the phase file's Documentation section): `README.md` + `AGENTS.md` reviewed in their entirety against the post-phase app; the settings/theme section notes the single-writer ownership (Settings panel owns the 3 brand strings, panel-row → tab-row → env order) and the live-apply behavior; `AGENTS.md` corrected only where it names the old order.
|
||||||
|
5. Commit: one atomic, professional commit (Conventional Commits, `--no-gpg-sign`), e.g.
|
||||||
|
`fix(settings): the Settings-panel app name wins over the Theme tab and saves apply live (phase 190)` — the body names the two defects (the `ui_settings` shadow bake, the absent live re-apply), the A1/A2 decisions, and the moved theme-tab suites. `.agents/phases/todo/190_app_name_settings_fix/` moves to `.agents/phases/complete/` in the SAME commit (AGENTS rule 8).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: the suite above, isolated; the three regression suites green.
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
- Lint/types: clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `tests/e2e/test_app_name_settings_fix.py` green in isolation — (a) the incident regression (the panel value survives a Theme-tab color save), (b) the four-DOM live apply without reload, (c) the tab-reset fallback live, (d) the byte-identical boot, (e) the colors-only tab DOM.
|
||||||
|
- [ ] `uv run pytest` 100% passing; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] `README.md` / `AGENTS.md` consistent (the single-writer + live-apply clauses).
|
||||||
|
- [ ] the phase directory is in `.agents/phases/complete/`; one atomic commit with `--no-gpg-sign`.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Phase 191 — Sources carry a name, a label and tags — and the name focuses retrieval
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L4 — "Sources need a name, a label, tag(s) so you can focus the model when prompting if you have a project name or a standard name you reference things by."
|
||||||
|
**Story:** n/a (owner directive)
|
||||||
|
**Depends on:** none functionally (builds on the completed phase-119 name-signal selection bonus, the phase-31/142 KB overview, the phase-76/35/38/49/90 Sources page).
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
|
||||||
|
- **D1 — the data (task 01).** A new single-purpose table `source_labels` (alembic **0030**, additive, no backfill — a source without a row has no name/label/tags and behaves exactly as pre-phase):
|
||||||
|
- `source` — `String(120)` **PK**: the source name EXACTLY as `Document.source` carries it (the derived basename — the clone dir / local dir / upload file stem); the join key to the documents and to the `git_sources` row (via `_url_basename` / the local path basename);
|
||||||
|
- `display_name` — `Text`, nullable, ≤ 120 chars (the trimmed value; the owner's name for the source — "the git server");
|
||||||
|
- `label` — `Text`, nullable, ≤ 200 chars (the short one-line description/category — "self-hosted forge + Gitea");
|
||||||
|
- `tags` — `JSONB`, `NOT NULL`, server default `'[]'` (the focus tokens, each ≤ 32 chars, ≤ 10 tags — the owner's "tag(s)");
|
||||||
|
- `updated_at` — `DateTime(timezone=True)`, server default now, onupdate now.
|
||||||
|
- **The display name is an ALIAS ONLY** (owner-confirmed assumption): `Document.source`, the `source/path` document identity, the read/grep/`show_text` locators, the citations and the KB tree are ALL untouched by a label row — renaming a source never invalidates a saved chat, a shared page or a verbatim locator. The derived name stays the wire identity; the display name rides beside it.
|
||||||
|
- The ORM model `SourceLabel` (the `SourcesMeta` single-column style, `app/models.py`) + `app/rag/source_labels.py` (the new module — `app/rag/sources_meta.py` is the phase-53 version counter, NOT this feature; no name collision): `get_label(db, source)`, `set_label(db, source, display_name, label, tags)` (upsert, flush-only — the caller commits, the phase-53 session convention), `all_labels(db)`, `label_for(db, source)` → a frozen `SourceLabelView` (display_name / label / tags, `None` when absent), `question_name_hits(labels, question)` → the matching rule below.
|
||||||
|
- **D2 — the matching rule (task 04).** A source is a SOURCE-NAME HIT when at least one `name_hit_tokens(question)` candidate (the phase-119 class-agnostic token list, reused UNCHANGED — `app/rag/retriever.py` L191) equals, case-insensitively (the `_normalize_name` form), a token of the source's `display_name`, `label` or any `tags` entry. The phase-119 digit-class rule does NOT apply to user-given names (the owner's names are not paths — "gitea" must hit even though it is digitless; the phase-119 digit rule exists to keep prose tokens from matching PATHS, and user names have no prose-precision problem: a 4-char minimum token length (`NAME_TOKEN_MIN_LEN`, reused) is the only filter, applied to the SOURCE side too — a 2-char tag never matches). One query per turn: the label rows (a handful) are loaded once and the match is in Python (the phase-119 one-query convention).
|
||||||
|
- **D3 — the boost (task 04).** A NEW bounded selection-time bonus, the phase-119 `name_hit_bonus` pattern end to end, a SEPARATE knob (the two signals stay independently kill-able):
|
||||||
|
- `source_name_hit_bonus: float = 0.005` (default = the phase-119 starting point; validator `>= 0`, `0` = off; `BOR_SOURCE_NAME_HIT_BONUS` env; the `settings_catalog` metadata entry — the catalogue-completeness pin is extended, NOT waived);
|
||||||
|
- `RetrievedChunk` gains the additive field `source_name_hit: bool = False` (the `name_hit` docstring pattern: default `False` keeps every ordinary construction valid; `fuse`'s `replace()` copies it; the double-hit merge ORs it in — a vector row that is also the source-name hit keeps the flag);
|
||||||
|
- the flag is set at the retrieval boundary in `app/api/chat.py` (the call sites where `select_suggested` / `select_related` / `weak_hit_titles` run — L537–572 and L875 — the question is in scope): for every chunk whose `document.source` is a D2 hit, `rc.source_name_hit = True` (a loop over the few dozen retrieved chunks against the handful of hit sources — never a new query);
|
||||||
|
- `_selection_order` (L754) gains the second bonus parameter: `effective_score = best_fused + (bonus if any name_hit) + (source_bonus if any source_name_hit)` — each bonus per DOCUMENT, applied ONCE, at most two bonuses per document (bounded: 0.010 on the fused scale, the same scale phase 119 proved); `source_bonus == 0` (or no flagged chunk) → the walk is BYTE-IDENTICAL to the pre-phase walk (the phase-119 kill-switch contract, LOCKED A3 — a new unit pin mirrors the phase-119 one);
|
||||||
|
- the three application sites (`select_suggested` L537/L875, `select_related` L538, `weak_hit_titles` L572) pass `get_settings().source_name_hit_bonus`; the bonus lives in the SELECTION layer only — `score` / `cosine` / `fts_hit` / `query_log.top_score` are untouched (the phase-119 LOCKED A3 clause, mirrored).
|
||||||
|
- **D4 — the prompt surfacing (task 05).** The model learns the name→docs mapping from the prompt, not from retrieval alone:
|
||||||
|
- the KB overview's INPUT lines (`app/rag/overview.py` `build_overview_prompt`, L148 — `source — path — title — summary`) render `display_name — path — …` when set (the raw source name is REPLACED in the lite model's input only — the stored overview text is the lite model's prose, which then naturally uses the owner's name; the overview row's `source` field in the DB is untouched);
|
||||||
|
- the suggested-tier `<documents>` block (the per-doc line in `app/rag/prompts.py`, the phase-31 section builder) renders `display_name (label)` before the path when set — e.g. `the git server (self-hosted forge) — docs/gitea.md — …` (the parenthetical only when BOTH are set; one of the two → just the present one; neither → the pre-phase line, byte-identical).
|
||||||
|
- **D5 — the API (task 02).** `app/api/source_labels.py` (NEW router, the phase-91 admin gate — `require_admin` on every route; registered in `app/main.py` alongside the other admin routers):
|
||||||
|
- `GET /api/source-labels` → `{"labels": [ {source, display_name, label, tags} … ]}` — the FULL source catalogue: the union of (a) every `git_sources` row's derived source name (git + local, the `effective_sources` rows — an env-only deployment lists the env source names) and (b) every `SELECT DISTINCT source FROM documents` name NOT covered by (a) (the uploads) — one query for (b), the handful of `git_sources` rows for (a); each entry carries its label row or nulls;
|
||||||
|
- `PUT /api/source-labels/{source}` (path param ≤ 120) body `{display_name?, label?, tags?}` → the upsert; validation (the phase-121 boundary style — the framework's standard 422): `display_name` trimmed ≤ 120 (empty string / whitespace-only = NULL), `label` trimmed ≤ 200 (same), `tags` ≤ 10 entries, each a trimmed ≤ 32 non-empty token, de-duplicated case-insensitively preserving first case, `[]` legal (clears); an explicit `null` clears that field (an omitted field leaves it — the phase-168 partial-update shape); a `source` that matches NO git row and NO document → **404** (no orphan label rows — a label for a source that does not exist would be a dead boost input);
|
||||||
|
- `DELETE /api/source-labels/{source}` → row removed (404 when absent) — the reset.
|
||||||
|
- **D6 — the UI (task 03).** The Sources page (`#view-git-sources`, `git-sources.js`) — per-source name/label/tags editor for EVERY source kind (git, local, upload — the upload rows are the same table's extra section, phase 49/90):
|
||||||
|
- one expandable row per source (the house pattern — the phase-121 token field is the closest precedent): three fields (Name / Label / Tags, the last a comma-or-Enter token input, the phase-89 ignore-paths token input is the precedent) + Save / Reset, the §7.4 never-stale save state ("Saving…" → "saved" / the one-line error note, the phase-168 shape);
|
||||||
|
- the saved state renders back from the GET (the pre-population — the phase-91 convention); the derived source name is the row's identity (read-only, the `git-sources.js` name cell — the display name is a SEPARATE field, D1);
|
||||||
|
- the chat page's source cluster chips and the RAG page's source tree keep the DERIVED name (D1 — no identity change anywhere); the display name surfaces in the chat only through the prompt (D4) and the verbatim panel caption is untouched (it names `source/path` — the identity).
|
||||||
|
|
||||||
|
## ASSUMPTIONS (owner-confirmed 2026-10-18)
|
||||||
|
- **A1 (D1):** the display name is an alias — the `Document.source` / `source/path` identity is untouched.
|
||||||
|
- **A2 (D2/D3):** the focus mechanism is the new bounded `source_name_hit_bonus` selection boost (default 0.005, `0` = off) + the KB-overview / suggested-tier surfacing (D4).
|
||||||
|
- **A3 (D5):** admin-gated like the phase-91/168 admin surfaces; labels for non-existent sources are rejected (404).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- `app/models.py` (the `SourcesMeta` L— single-row precedent; the `GitSource` row — the `url`/`path`/`kind` columns the derived names come from), `app/rag/git_sources.py` (`_url_basename` L89, `effective_sources` L285 — the git/local source-name derivation), alembic `0029` (the last migration — 0030 is next), `app/rag/retriever.py` (`RetrievedChunk` L276+, `name_hit_tokens` L191, `_name_hit_chunks` L576, `_selection_order` L754, the three bonus application sites L838/L987/L1022), `app/api/chat.py` (the selection call sites L537–572, L875), `app/config.py` (`name_hit_bonus` L431 + validator L964 — the knob pattern), `app/core/settings_catalog.py` (L225 — the metadata entry pattern), `app/rag/overview.py` (`build_overview_prompt` L148 — the input lines), `app/rag/prompts.py` (the `<knowledge_base>` section L561, the suggested-tier doc lines), `app/api/git_sources.py` (the router pattern — admin gate, the upload status), `frontend/assets/git-sources.js` (the Sources page), `tests/fixtures/retrieval_battery.txt` + `scripts/eval_retrieval.py` (the battery), `tests/integration/` (the phase-119 pins — the kill-switch pattern to mirror).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_source_labels_table.md` — the `SourceLabel` model, alembic 0030, the `app/rag/source_labels.py` helpers (D1/D2 pure half).
|
||||||
|
2. `02_source_labels_api.md` — the admin-gated router (D5): the catalogue GET, the validated PUT upsert, the 404s, the DELETE.
|
||||||
|
3. `03_source_labels_ui.md` — the per-source name/label/tags editor on the Sources page (D6), all three source kinds.
|
||||||
|
4. `04_source_name_boost.md` — the `source_name_hit_bonus` knob + the `source_name_hit` flag + the `_selection_order` second bonus (D3), the kill-switch pins, the battery run.
|
||||||
|
5. `05_prompt_surfacing.md` — the KB-overview input lines + the suggested-tier doc lines carry the display name / label (D4).
|
||||||
|
6. `06_e2e_and_gates.md` — the dedicated Playwright suite (the owner-visible arm: a named source's document is suggested when the question uses the name, the alias never breaks a locator), the full gates, the docs pass, the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- **Unit:** `tests/unit/test_source_labels.py` (NEW) — the D2 matching rule (name/label/tag hits, the case-insensitive `_normalize_name` form, the 4-char minimum on both sides, the no-hit baseline), the D1 helpers (upsert, the partial-update shape, the 404 shape at the helper level); `tests/unit/test_retriever.py` — the phase-119 mirror pins for the SECOND bonus (the kill-switch byte-identity, the bounded per-document application, the OR-merge through `fuse`).
|
||||||
|
- **Integration:** `tests/integration/test_source_labels_api.py` (NEW) — the admin gate (401/403 the phase-91 shape), the catalogue GET (git + local + upload union, no dupes), the PUT validation 422s (121-char name, 201-char label, 11 tags, a 33-char tag, a whitespace tag), the partial-update shape (an omitted field is untouched, an explicit null clears), the 404s (no such source; DELETE of an absent row), the round-trip.
|
||||||
|
- **E2E (AGENTS rule 4, task 06):** `tests/e2e/test_source_name_labels.py`, run in isolation — the owner-visible arm through the real UI (set a name + tag on a fixture source → a chat question using the name surfaces that source's document in the suggested tier via the mock/real battery — the phase-119 E2E is the precedent), the alias contract (a read/grep locator for the renamed source still resolves — the derived name is untouched), the 404 arm (a label for a non-existent source is rejected in the UI with the one-line note).
|
||||||
|
- **Battery:** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` — the pre/post run at the default bonus (0.005) and at `0` (byte-identical to pre-phase) is recorded in the phase file (the phase-119 convention).
|
||||||
|
- **Coverage:** **>90%** on `app/`; the frontend change is marker-pinned per the house JS convention.
|
||||||
|
- **Lint/types:** `uv run ruff check . && uv run pyright`.
|
||||||
|
|
||||||
|
## Documentation & Consistency
|
||||||
|
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
|
||||||
|
- `README.md`: the sources section gains the naming feature (the name/label/tags editor, the alias-only contract, the new knob `BOR_SOURCE_NAME_HIT_BONUS` with its `0` = off meaning) (task 06). `AGENTS.md`: the phase-119 name-signal clause gains the parallel source-name clause if it names the bonus (verify, do not add if absent).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] alembic 0030 applied cleanly; `SourceLabel` round-trips (unit); the alias contract holds (a `Document.source` / locator is untouched by a label — the E2E arm).
|
||||||
|
- [ ] `GET /api/source-labels` lists git + local + upload sources with their labels (integration + E2E); the PUT validation 422s and 404s pinned; the DELETE resets.
|
||||||
|
- [ ] the Sources page edits name/label/tags for all three source kinds (E2E), save state per the §7.4 contract.
|
||||||
|
- [ ] `source_name_hit_bonus = 0` → the selection walk byte-identical to pre-phase (unit pin); at 0.005 the battery shows the named source surfacing (battery record); the three application sites pass the knob (unit pins).
|
||||||
|
- [ ] the KB-overview input lines and the suggested-tier doc lines carry `display_name (label)` when set, byte-identical otherwise (unit pins on both builders).
|
||||||
|
- [ ] test suite green, coverage >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed phases (no label row → every surface byte-identical to pre-phase).
|
||||||
|
- [ ] one atomic Conventional Commits commit with `--no-gpg-sign`.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Task 01 — The `source_labels` table and helpers (D1/D2 pure half)
|
||||||
|
|
||||||
|
**Phase:** `191_source_name_labels_tags` · **Source:** `TODO.md:5 — "Sources need a name, a label, tag(s) …"`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The additive data layer: the `SourceLabel` ORM model + alembic 0030 (no backfill) + `app/rag/source_labels.py` with the upsert/get helpers and the D2 `question_name_hits` matching rule — so a source without a row is byte-identical to pre-phase everywhere.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/models.py` — NEW `SourceLabel(Base)` (the `SourcesMeta` style, tabulated docstring — the D1 column spec from the phase file):
|
||||||
|
- `source: Mapped[str] = mapped_column(String(120), primary_key=True)` (the `Document.source` name — the join key);
|
||||||
|
- `display_name: Mapped[str | None] = mapped_column(Text, default=None)` (≤ 120 trimmed — enforced at the API, the column is plain Text, the phase-121 pattern);
|
||||||
|
- `label: Mapped[str | None] = mapped_column(Text, default=None)` (≤ 200);
|
||||||
|
- `tags: Mapped[list] = mapped_column(JSONB, default=list, server_default=text("'[]'"), nullable=False)`;
|
||||||
|
- `updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())`.
|
||||||
|
2. `alembic/versions/0030_source_labels.py` — NEW, revision `0030`, down_revision `0029` (verify the 0029 rev id — it is the current head): `CREATE TABLE source_labels` (the columns above, the PK, the JSONB server default), no backfill (an empty table — every source's pre-phase behavior is "no row"); the downgrade `DROP TABLE` (the house migration style — `0028` is the shape to copy).
|
||||||
|
3. `app/rag/source_labels.py` (NEW module — the docstring names the feature and the D1 alias contract):
|
||||||
|
- `@dataclass(frozen=True) SourceLabelView` — `source, display_name, label, tags` (tags: `tuple[str, …]`); `label_for(db, source) -> SourceLabelView | None` (one PK read; `None` when absent);
|
||||||
|
- `get_label(db, source) -> SourceLabel | None` (one PK read);
|
||||||
|
- `set_label(db, source, *, display_name, label, tags) -> SourceLabel` — upsert (the `SourcesMeta` upsert shape): an existing row is updated in place (only the provided non-`None` fields are overwritten — the partial-update shape; an explicit `None` clears), a new row is created; **flush-only — the caller commits** (the phase-53 session convention);
|
||||||
|
- `all_labels(db) -> dict[str, SourceLabelView]` (one query, keyed by `source`);
|
||||||
|
- `delete_label(db, source) -> bool` (one PK delete; `False` when absent; flush-only);
|
||||||
|
- `normalize_tags(raw: list[str]) -> list[str]` (pure: trim, drop empties, ≤ 32-char guard raises `ValueError`, ≤ 10 guard raises `ValueError`, case-insensitive de-dupe preserving first case — the API's 422 comes from these raises);
|
||||||
|
- `question_name_hits(view: SourceLabelView | None, question: str) -> bool` (the D2 rule, pure): `None` → `False`; else any `name_hit_tokens(question)` candidate (imported from `app.rag.retriever` — the phase-119 list, UNCHANGED) equals, in `_normalize_name` form, a token of `display_name` / `label` / any `tags` entry — the SOURCE side tokenized the same way (whitespace-split, `_normalize_name`, `NAME_TOKEN_MIN_LEN` (4) minimum on BOTH sides);
|
||||||
|
- `question_name_hit_sources(db, question: str) -> set[str]` (one `all_labels` read + the pure rule per row — a handful of rows; the per-turn entry point task 04 uses).
|
||||||
|
4. `tests/unit/test_source_labels.py` (NEW):
|
||||||
|
- the model round-trip (create via `set_label`, the `updated_at` stamp, the partial-update shape — an omitted field untouched, an explicit `None` clears);
|
||||||
|
- the D2 rule: a digitless name hit (`"gitea"` question token vs `display_name "Gitea"` — case-insensitive via `_normalize_name`), a label hit, a tag hit, a 3-char source-side token NEVER matches (the 4-char minimum), a 3-char QUESTION token never matches (the phase-119 `NAME_TOKEN_MIN_LEN` side), `None` view → `False`, a no-overlap question → `False`;
|
||||||
|
- `normalize_tags` (the 11-tag ValueError, the 33-char ValueError, the whitespace drop, the case-insensitive de-dupe keeping first case, `[]` legal);
|
||||||
|
- the migration 0030 up/down (the `tests/unit` or `tests/integration` migration-pin convention — verify where the phase-168 0028 pin lives and mirror it).
|
||||||
|
5. Verify: `uv run alembic upgrade head` clean on the dev DB; `uv run pytest tests/unit/test_source_labels.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the pins above (the D2 rule is the heart of the phase — pin every branch).
|
||||||
|
- Coverage: **>90%** on `app/` (the new module is fully exercised by its unit module).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] alembic 0030 applies and reverses cleanly; the `SourceLabel` model matches the D1 column spec.
|
||||||
|
- [ ] `tests/unit/test_source_labels.py` green — the D2 rule, the partial-update shape, the `normalize_tags` guards.
|
||||||
|
- [ ] full test suite **100% passing** (the empty table changes nothing).
|
||||||
|
- [ ] no behavior change in completed work (no row → no surface reads it yet).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Task 02 — The admin `source-labels` API (D5)
|
||||||
|
|
||||||
|
**Phase:** `191_source_name_labels_tags` · **Source:** `TODO.md:5 — "Sources need a name, a label, tag(s) …"`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The wire surface: `app/api/source_labels.py` — the catalogue `GET` (every source, all three kinds, with its label or nulls), the validated `PUT` upsert (the framework's standard 422; a 404 for a source that exists nowhere), the `DELETE` reset — admin-gated like the phase-91/168 admin surfaces, registered in `app/main.py`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/schemas.py` — NEW shapes (the phase-91 `extra="forbid"` style):
|
||||||
|
- `SourceLabelOut` — `source: str` (≤ 120), `display_name: str | None` (≤ 120), `label: str | None` (≤ 200), `tags: list[str]` (≤ 10 items, each ≤ 32);
|
||||||
|
- `SourceLabelList` — `labels: list[SourceLabelOut]`;
|
||||||
|
- `SourceLabelUpdate` — `display_name: str | None = Field(default=None, max_length=120)`, `label: str | None = Field(default=None, max_length=200)`, `tags: list[str] | None = Field(default=None, max_length=10)` (each item ≤ 32 — an item-level `Field(max_length=32)` or a model validator; verify the house item-cap convention in `SavedChatCreate`'s messages and mirror it) — `extra="forbid"`; the OMITTED-vs-NULL distinction is the partial-update shape (D5): omitted → untouched, `null` → cleared, a value → set (the validator trims; a whitespace-only `display_name`/`label` → `None` at the validator boundary).
|
||||||
|
2. `app/api/source_labels.py` (NEW router, `APIRouter(prefix="/api/source-labels", tags=["source-labels"], dependencies=[Depends(require_admin)])` — the phase-91 gate line):
|
||||||
|
- `GET ""` → `SourceLabelList`: (a) the `git_sources` rows — `effective_sources(db)` (the phase-35/38 helper, L285): each row's derived source name (git: `_url_basename(row.url)`; local: the stored path's basename — the SAME expression the importer uses for `Document.source` — verify the importer's local-name expression and reuse it, do not re-derive it); an env-only deployment (no DB rows, the `env` source state) lists the env source names (the `effective_sources` `source` return is `"db"`/`"env"` — for `env`, parse the env list the phase-35 fallback uses); (b) `select(Document.source).distinct()` (ONE query, no `content` — the phase-126 load discipline) minus the names from (a) (the uploads); de-duplicated, sorted by `source` (stable order — the UI and the E2E rely on it); each entry = its label row via `all_labels(db)` (one query) or nulls;
|
||||||
|
- `PUT "/{source}"` (path param, the router's `max_length` via the path conversion — a >120 path → the framework's 422) → the D5 404 arm FIRST: `source` matches no `git_sources` row name AND is not in the distinct document sources (the SAME two queries the GET runs — no third) → 404 (the standard body, the phase-91 shape); else `normalize_tags` (422 on its raises — a `ValueError` → the framework's 422 via the validator, NOT a 500), `set_label` (flush + `db.commit()` — the router owns the session here, the phase-91 commit convention), 200 `SourceLabelOut`;
|
||||||
|
- `DELETE "/{source}"` → the 404 arm (no label row → 404), else `delete_label` + commit, 200 (the phase-91 DELETE shape — verify its body: an empty object or a message; mirror the sibling admin router).
|
||||||
|
3. `app/main.py` — `app.include_router(source_labels.router)` (the admin routers' block — verify the exact registration list and the order convention).
|
||||||
|
4. `tests/integration/test_source_labels_api.py` (NEW — the phase-91 `test_ui_settings_api.py` fixture pattern):
|
||||||
|
- the gate: an anonymous GET/PUT/DELETE → the phase-91 gate shape (403 / the redirect-to-login — verify the phase-91 gate pin and mirror it EXACTLY);
|
||||||
|
- the catalogue GET: a module app with one git row (a `file://` URL — the phase-35 fixture), one local row, one upload (a document row with a source name no row derives) + a label on the upload → the response lists all three (sorted, no dupes), the upload carries its label, the others nulls;
|
||||||
|
- the PUT: a valid upsert round-trips (set name+label+tags → GET shows them; a second PUT with only `label` → the name/tags untouched; a PUT with explicit `null` `display_name` → cleared, the label kept); the 422s (a 121-char `display_name`, a 201-char `label`, 11 `tags`, a 33-char tag, a whitespace-only tag — each with the framework's standard body and NO row change: a pre-seeded row is byte-identical after the 422); the 404s (a `source` that is neither a row name nor a document source — the row table unchanged);
|
||||||
|
- the DELETE: a labelled source → 200, the GET shows nulls; a second DELETE → 404.
|
||||||
|
5. Verify: `uv run pytest tests/integration/test_source_labels_api.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: the pins above — the gate, the union catalogue, the validation 422s (no partial write), the 404s, the round-trip.
|
||||||
|
- Coverage: **>90%** on `app/` (the router is fully exercised by its integration module).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `GET /api/source-labels` lists git + local + upload sources (integration pin) with their labels; admin-gated.
|
||||||
|
- [ ] `PUT /api/source-labels/{source}` upserts with the D5 partial-update shape; every 422 leaves the row unchanged; a 404 for a non-existent source creates nothing.
|
||||||
|
- [ ] `DELETE` resets; a second DELETE 404s.
|
||||||
|
- [ ] full test suite **100% passing**.
|
||||||
|
- [ ] no behavior change in completed work (the `git_sources` API, the `documents` API, the phase-91/168 admin surfaces all green unedited).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Task 03 — The per-source name/label/tags editor on the Sources page (D6)
|
||||||
|
|
||||||
|
**Phase:** `191_source_name_labels_tags` · **Source:** `TODO.md:5 — "Sources need a name, a label, tag(s) …"`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The owner-visible surface: every source on the Sources page (git, local, upload — the catalogue `GET /api/source-labels` is the single source of truth for the row list, so uploads appear without a second API) gets an inline name/label/tags editor with the §7.4 never-stale save state — the phase-121 token field and the phase-89 ignore-paths token input are the house precedents.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html` — `#view-git-sources` (L646+): the sources table area (the `#git-sources-table-wrap` region, L792) gains the per-row expandable label block markup — ONE shared template (a `<template id="source-label-row-template">`) the JS clones per row:
|
||||||
|
- three fields in one row (the house grid): **Name** (`input`, ≤ 120, `maxlength="120"`), **Label** (`input`, ≤ 200, `maxlength="200"`), **Tags** (the token input — the phase-89 ignore-paths input's exact widget: type + Enter/comma adds a chip, Backspace deletes the last, ≤ 10 chips, each ≤ 32 chars — reuse its CSS classes, no new styles beyond the phase-89 ones);
|
||||||
|
- **Save** / **Reset** buttons (the phase-168 button shapes) + the save-state line (`role="status"` — "Saving…" → "saved" / the one-line error note; Reset visible only when the row has a stored label, the phase-168 per-row reset shape);
|
||||||
|
- the row's IDENTITY cell (the derived source name) is UNCHANGED and read-only (D1/A1 — the editor sits beside it, never replaces it).
|
||||||
|
2. `frontend/assets/git-sources.js`:
|
||||||
|
- the load path: after the existing catalogue load, `GET /api/source-labels` (one extra fetch — the gate is the SAME as the page's: the anonymous gate hides the whole page, the admin sees it; the fetch runs inside the existing admin-gated content reveal); merge the labels into the row data (by `source` name — the GET is the union, so the row list for the label editor comes from the GET, not from the `git-sources` GET — the upload rows have no `git-sources` row);
|
||||||
|
- the editor state per row: the three fields pre-populated from the label (nulls → empty); dirty tracking (a field change enables Save — the phase-168 `.is-dirty` + "unsaved changes" hint shape, scoped per row);
|
||||||
|
- **Save**: `PUT /api/source-labels/<source>` (the trimmed values; the tags array from the chips) → success: the "saved" state, the row's label re-read from the response (no second GET — the response is the new truth); failure: the one-line error note (the framework's detail — the 422/404 arm: a 404 shows "that source no longer exists" — the note text names the 404 arm, the phase-168 error-note shape); the §7.4 guard: one in-flight save per row (a second click while saving is ignored, the phase-168 `saveInFlight` pattern scoped per row);
|
||||||
|
- **Reset**: `DELETE /api/source-labels/<source>` → the "saved" state, the fields cleared, the row's stored label nulled;
|
||||||
|
- the page's existing sync / upload / git-row flows are UNTOUCHED (the editor is additive — the row's existing action buttons keep their behavior).
|
||||||
|
3. `tests/unit/` — the JS marker pins (the module that marker-pins `git-sources.js` — verify the name; the `test_save_chat_ui.py` convention):
|
||||||
|
- the editor's save path references `PUT /api/source-labels/` and the response (not a second GET) as the new truth (source marker);
|
||||||
|
- the per-row `saveInFlight` guard marker; the 404 note text marker;
|
||||||
|
- the token input reuses the phase-89 widget classes (a marker naming the class reuse — no new tag-input CSS).
|
||||||
|
4. Verify: `uv run pytest tests/unit/<js-pin-module> -v` green; `uv run playwright`-free local check: the page renders the editor for a seeded label row (the E2E covers this in task 06 — a manual `uv run uvicorn` smoke is optional, the E2E is the gate).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the marker pins above.
|
||||||
|
- E2E: task 06 (the owner-visible arm).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — the task is frontend; the app-side gate stays green).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] every source row (git, local, upload) renders the name/label/tags editor; the derived name cell is read-only and untouched (D6/A1).
|
||||||
|
- [ ] Save/Reset hit the phase-02 endpoints; the save state follows the §7.4 contract (never a stuck "Saving…", the 404 note names the arm) — marker pins.
|
||||||
|
- [ ] the existing sources-page flows (add git/local source, upload, sync) are green unedited.
|
||||||
|
- [ ] no behavior change in completed work.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Task 04 — The `source_name_hit_bonus` selection boost (D2/D3)
|
||||||
|
|
||||||
|
**Phase:** `191_source_name_labels_tags` · **Source:** `TODO.md:5 — "… so you can focus the model when prompting …"`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The focus mechanism: a NEW bounded, kill-able selection-time bonus that lifts a document when the question names its source (D2 rule) — the phase-119 `name_hit_bonus` pattern end to end, a separate knob, the SAME shared walk, `0` = byte-identical pre-phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` — the knob (the `name_hit_bonus` L431 pattern, directly below it):
|
||||||
|
- `source_name_hit_bonus: float = 0.005` (the docstring: the D3 spec — default = the phase-119 starting point; `0` = off, byte-identical walk; bounded per-document, at most two bonuses on the fused scale);
|
||||||
|
- the `field_validator` (the L964 pattern): `>= 0`, the message `"source_name_hit_bonus must be >= 0 (0 = off)"`.
|
||||||
|
2. `app/core/settings_catalog.py` — the `SettingMeta` entry (the L225 `name_hit_bonus` pattern): `"source_name_hit_bonus"` in the **"Retrieval & honesty"** section, label "Source name hit bonus", the restart-required flag FALSE (the phase-168 runtime knob shape — verify the `name_hit_bonus` entry's flags and mirror them). Extend the catalogue-completeness pin (the unit test asserting every `Settings` field has a `SettingMeta` entry — find the module; it starts failing when the field lands, the entry is the fix).
|
||||||
|
3. `app/rag/retriever.py`:
|
||||||
|
- `RetrievedChunk` (L276+): the additive field `source_name_hit: bool = False` + the docstring line (the `name_hit` line pattern — default `False` keeps every ordinary construction valid; `fuse`'s `replace()` copies it automatically — the frozen dataclass; the double-hit merge at L342–358 gains `source_name_hit=existing.source_name_hit or rc.source_name_hit` beside the existing `name_hit` OR);
|
||||||
|
- `_selection_order` (L754): signature `_selection_order(chunks, bonus, source_bonus: float = 0.0)` — the walk gains `any_source_hit[doc.id]` (the `any_name_hit` pattern, L792/L799/L804); the effective-score dict: `best_score[doc.id] + (bonus if any_name_hit[doc.id] else 0.0) + (source_bonus if any_source_hit[doc.id] else 0.0)`; the re-sort condition: `(bonus > 0.0 and any_name_hit…) or (source_bonus > 0.0 and any_source_hit…)` (the pre-phase condition extended — when neither bonus is active, the walk is byte-identical: no re-sort, the same order list, the same `+ 0.0`); the docstring's D2/LOCKED-A3 paragraph is extended for the second bonus (each bonus per DOCUMENT, applied ONCE, at most two per document — bounded 0.010; the bonus lives in the SELECTION layer only — `score` / `cosine` / `fts_hit` / `query_log.top_score` untouched);
|
||||||
|
- `weak_hit_titles` (L825), `select_suggested` (L952), `select_related` (L1000): a `source_bonus: float | None = None` parameter each; `if source_bonus is None: source_bonus = get_settings().source_name_hit_bonus`; passed to `_selection_order`.
|
||||||
|
4. `app/api/chat.py` — the flag-set at the retrieval boundary (the call sites L537–572 and L875 — the question is in scope, the session is in scope):
|
||||||
|
- ONE call per turn: `hits = question_name_hit_sources(db, question)` (task 01's per-turn entry point — the single `all_labels` read) BEFORE the first selection call;
|
||||||
|
- the flag loop: `for rc in chunks: if rc.document.source in hits: rc.source_name_hit = True` (the few dozen retrieved chunks against the handful of hit sources — never a per-chunk query); the selection calls keep their existing arguments (the functions pull the knob from settings themselves — the call sites gain NO new positional arguments);
|
||||||
|
- the per-turn log line (PLAN §9) is UNCHANGED (the `verbatim_blocks=` / `scaffold_stripped=` fields are untouched — the boost is invisible to the log; no new log field).
|
||||||
|
5. `tests/unit/test_retriever.py` (or the phase-119 pin module — verify the name):
|
||||||
|
- the KILL-SWITCH pin (the phase-119 mirror): `source_name_hit_bonus = 0` (and no flagged chunks) → the selection order is byte-identical to the pre-phase walk (the same fixture, the same expectation list);
|
||||||
|
- the boost pin: a document whose source is a D2 hit (a `source_name_hit=True` chunk) ranks ABOVE an equal-fused-score non-hit when the bonus is on (the 0.005 head start), and a document carrying BOTH flags gets exactly +0.010 (the bounded two-bonus pin — no amplification);
|
||||||
|
- the `fuse` merge pin: a vector row merged with a flagged source-name row keeps `source_name_hit=True`;
|
||||||
|
- the settings pins: the validator (`-0.1` → the ValueError), the env name (`BOR_SOURCE_NAME_HIT_BONUS`), the catalogue-completeness pin extended.
|
||||||
|
6. The battery (the phase-119 convention): `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` — run it TWICE and record both in the phase file: (a) pre-change (the baseline), (b) post-change at `source_name_hit_bonus = 0` (must match (a) line-for-line — the kill switch) and at the default 0.005 (no label rows exist in the battery app → also identical to (a) — the no-row baseline).
|
||||||
|
7. Verify: `uv run pytest tests/unit/test_retriever.py tests/unit/test_settings*.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the kill-switch byte-identity, the bounded two-bonus application, the `fuse` OR-merge, the settings/catalogue pins.
|
||||||
|
- Integration: the E2E task's app fixture exercises the flag-set (no separate integration module — the flag loop is covered by the E2E arm + the unit pins).
|
||||||
|
- Battery: the three-run record above.
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `source_name_hit_bonus = 0` → the selection walk byte-identical to pre-phase (the unit pin + the battery match).
|
||||||
|
- [ ] at the default 0.005, a D2-hit document gets exactly the 0.005 head start (0.010 with both flags) — the bounded pin; no chunk field is modified (the untouched-fields pin).
|
||||||
|
- [ ] the three selection helpers pull the knob from settings by default (the unit pins with a settings override).
|
||||||
|
- [ ] `tests/fixtures/retrieval_battery.txt` battery: the kill-switch run matches the baseline line-for-line (recorded in the phase file).
|
||||||
|
- [ ] full test suite **100% passing**; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed work (the phase-119 bonus, the A8 gate inputs, the per-turn log line all untouched).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 05 — The prompt learns the name: the `<document>` block + the KB overview (D4)
|
||||||
|
|
||||||
|
**Phase:** `191_source_name_labels_tags` · **Source:** `TODO.md:5 — "… so you can focus the model when prompting …"`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The model learns the name→docs mapping from the prompt: a suggested `<document>` block carries the source's `name` / `label` as additive attributes (when set), and the KB overview's INPUT lines carry the display name — both byte-identical for a source without a label row. ONE `all_labels` read per turn feeds the task-04 boost flags AND these surfaces.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/prompts.py` — `build_high_prompt` (L828):
|
||||||
|
- a new optional parameter `names: Mapping[str, "SourceLabelView"] | None = None` (the source-name → view map; `None` = pre-phase, byte-identical — the `show_image`/`show_text` optional-parameter pattern);
|
||||||
|
- the block builder (L934–939): when `names.get(doc.source)` has a `display_name`, the block head gains ` name="{display_name}"` immediately after the `source="…"` attribute; when it has a `label`, it gains ` label="{label}"` after the `name` (the attribute order `source` → `name?` → `label?` → `path` → `title` → `date`; an unset field adds NOTHING — a no-label document's block is byte-identical to pre-phase, and a label-only document gets exactly the one `label` attribute);
|
||||||
|
- the values are XML-escaped with the SAME helper the block already uses for `source`/`path`/`title` (verify the escaping at L934 — a `"` in a display name must not break the attribute; the phase-106 date formatting is untouched).
|
||||||
|
2. `app/rag/overview.py` — `build_overview_prompt` (L148):
|
||||||
|
- a new optional parameter `names: Mapping[str, str] | None = None` (source → display_name); the line builder (L173): `line = f"{names.get(source, source)} — {path} — {title}"` (the display name REPLACES the raw source name in the lite model's input only — the stored overview row and the DB are untouched); no `names` / no entry → the pre-phase line, byte-identical.
|
||||||
|
- `regenerate_overview` (the caller — verify the signature): it already holds a session; it gains the ONE `all_labels(db)` read (task 01's helper) and passes (a) the display-name map to `build_overview_prompt` and (b) the same read is NOT re-queried — the map is derived from the views in the same loop.
|
||||||
|
3. `app/api/chat.py` — the task-04 call site is REFACTORED to the shared read (the "one query per turn" contract):
|
||||||
|
- `labels = all_labels(db)` (the single read, before the first selection call — replacing task 04's `question_name_hit_sources` call, whose internals it inlines);
|
||||||
|
- `hits = {v.source for v in labels.values() if question_name_hits(v, question)}` (the pure D2 rule per view — the flag loop of task 04 is unchanged, `hits` is now local);
|
||||||
|
- the `build_high_prompt` call (the grounded plan — verify the exact call site in the module) passes `names=labels` (the `SourceLabelView` map — `build_high_prompt` accepts the views directly; the overview's `str` map is the overview's own derivation, a different surface, no conversion shared).
|
||||||
|
4. `tests/unit/test_prompts.py` (or the prompt-lock module — verify the name):
|
||||||
|
- the KILL pin: `names=None` and `names={}` and a map with no entry for the doc's source → the built prompt is byte-identical to the pre-phase build (the existing pre-task pins stay green UNEDITED — they are the kill pin);
|
||||||
|
- the attribute pins: a `display_name`-only view → exactly ` name="…"` after `source="…"` (the attribute-order pin); a `label`-only view → exactly the `label` attribute (no `name`); both → both attributes in order; the XML-escaping pin (a `"` in a display name renders as the escaped entity in the attribute);
|
||||||
|
- the overview pins: `names=None` → the pre-phase lines byte-identical; a map entry → the line uses the display name (the `path` / `title` / summary halves untouched); a missing entry → the raw source name.
|
||||||
|
5. Verify: `uv run pytest tests/unit/test_prompts.py tests/unit/test_overview.py -v` green (verify the overview test module name); `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the byte-identical kill pins (both builders), the attribute-order + escaping pins, the overview line-swap pins.
|
||||||
|
- Integration: the E2E task's arm covers the wire (the system prompt of a turn with a named source — the mock LLM's captured prompt, the phase-119 E2E precedent).
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] a named source's suggested document renders `name="…"` / `label="…"` (the unit pins); an unnamed source's block and overview line are byte-identical to pre-phase (the unedited pre-task pins + the kill pins).
|
||||||
|
- [ ] ONE `all_labels` read per turn feeds the boost flags and the prompt surfaces (the marker pin on the chat.py call site — no second label query in the turn).
|
||||||
|
- [ ] `regenerate_overview` uses the same single read (the marker pin — no second query).
|
||||||
|
- [ ] full test suite **100% passing**; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed work (the `<document>` block for label-less sources, the KB overview text for label-less sources, the phase-106 date attribute all byte-identical).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 06 — The phase E2E suite, the full gates, the docs pass, the commit
|
||||||
|
|
||||||
|
**Phase:** `191_source_name_labels_tags` · **Source:** `TODO.md:5`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The dedicated Playwright suite proves the owner-visible arm through the real UI — a named source's document surfaces when the question uses the name, the alias never breaks a locator, the 404 arm is visible — run in isolation per AGENTS rule 4; the full gates pass; the docs are consistent; one atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_source_name_labels.py` (NEW — the module docstring names the TODO item and the D1 alias contract) — the module-app fixture pattern (the phase-119/168 E2E convention — verify in `tests/e2e/conftest.py` / the sibling suites; the mock LLM from `tests/e2e/mock_llm.py` for the prompt-capture arm):
|
||||||
|
- the fixture: the standard small KB (the phase-119 fixture convention — a handful of documents in a couple of sources) + one uploaded document (the phase-49/90 upload flow or a direct document-row seed — verify the sibling suites' seeding helper).
|
||||||
|
- **(a) the editor, all three kinds:** as the admin, open the Sources page; set a Name ("the git server"), a Label ("self-hosted forge") and a tag ("gitea") on a git source row, a Name on a local source row, a Name + tag on the upload row → each save shows "saved" (the §7.4 state) and a reload pre-populates the saved values (the phase-91 pre-population convention); the derived-name cells are unchanged (the A1 pin — the row identity cell text is the pre-save text).
|
||||||
|
- **(b) the focus, owner-visible:** a chat question that uses the NAME ("what does the git server run?") with the mock LLM → the suggested tier (the `done` frame's `sources` / the UI's source cluster) includes the named source's document (the D3 boost's owner-visible arm — the phase-119 E2E is the precedent; the mock's captured system prompt asserts the `<document name="the git server" label="self-hosted forge" …>` attributes — the D4 arm at the wire); the same question phrased WITHOUT the name (the pre-baseline question from the battery) shows the pre-phase suggested set (the no-signal control — the boost is the only difference).
|
||||||
|
- **(c) the alias contract:** after the rename, a verbatim-fenced answer (or a direct `page.request` read of the document API) for `source/path` (the DERIVED name) still resolves — the locator is untouched (the A1 pin through the wire); the KB tree / RAG page still lists the derived name (the D1 surface pin).
|
||||||
|
- **(d) the 404 arm:** the editor's PUT for a source that no longer exists (delete the underlying git row via the API mid-test, then save a label through the UI) → the one-line error note names the 404 arm (the task-03 pin, owner-visible).
|
||||||
|
- **(e) the reset:** the editor's Reset on the named source → "saved", the fields cleared, a reload shows empty, and the chat's suggested set for the named question reverts to the control (the label's absence is the pre-phase state end to end).
|
||||||
|
2. Regression, run in isolation: `tests/e2e/test_name_signal.py` (the phase-119 suite — verify the exact name in `tests/e2e/`), `tests/e2e/test_settings_panel.py`, `tests/e2e/test_git_sources.py` (the phase-76 suite — verify the name) — green unedited.
|
||||||
|
3. The full gates, in order:
|
||||||
|
- `uv run pytest` (unit + integration) — 100% passing;
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`;
|
||||||
|
- `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` — green in isolation;
|
||||||
|
- `uv run ruff check . && uv run pyright` — clean.
|
||||||
|
4. Documentation pass (the phase file's Documentation section): `README.md` + `AGENTS.md` reviewed in their entirety against the post-phase app; the sources section gains the naming feature (the editor, the alias-only contract, `BOR_SOURCE_NAME_HIT_BONUS` with its `0` = off meaning); `AGENTS.md` corrected only where it names the phase-119 bonus as the ONLY name signal.
|
||||||
|
5. Commit: one atomic, professional commit (Conventional Commits, `--no-gpg-sign`), e.g.
|
||||||
|
`feat(sources): per-source name, label and tags focus retrieval and the prompt (phase 191)` — the body names the D1 alias contract, the D2 rule, the D3 knob, the D4 surfaces, the new table/migration 0030, and the battery record location. `.agents/phases/todo/191_source_name_labels_tags/` moves to `.agents/phases/complete/` in the SAME commit (AGENTS rule 8).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: the suite above, isolated; the regression suites green.
|
||||||
|
- Battery record: task 04's three-run record is in the phase file (the kill-switch run matches the baseline).
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
- Lint/types: clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `tests/e2e/test_source_name_labels.py` green in isolation — (a) the editor for git/local/upload with the saved-state contract, (b) the named question surfaces the named source's document + the prompt attributes, (c) the derived-name locator is untouched by a rename, (d) the 404 note, (e) the reset reverts end to end.
|
||||||
|
- [ ] `uv run pytest` 100% passing; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] `README.md` / `AGENTS.md` consistent (the naming feature + the knob).
|
||||||
|
- [ ] the phase directory is in `.agents/phases/complete/`; one atomic commit with `--no-gpg-sign`.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Phase 192 — Verbatim panels land while the answer is still streaming
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L5 — "The verbatim tag appear at the end and only when the llm is done answering which makes the chat read strangely."
|
||||||
|
**Story:** n/a (owner report)
|
||||||
|
**Depends on:** none functionally (builds on the completed phase-182 verbatim display, phase-95 tool_result frames, phase-158/164 saved-chat restore).
|
||||||
|
|
||||||
|
## Evidence (diagnosed from the code, read this not the chat)
|
||||||
|
|
||||||
|
1. **Panels materialize only on the `done` frame.** `app/api/chat.py` resolves the turn's verbatim blocks at done-time (L1319–1347: `resolve_answer_blocks` over `answer_text` → the done frame's additive `answer_text` list); `app.js` hydrates at done (L4506–4511: `hydrateVerbatim(bubble, ev.answer_text, …)` + `appendUnlocatedVerbatim(wrap, ev.answer_text, …)`) and at restore (L3044–3046, the record's `m.answer_text`).
|
||||||
|
2. **The `show_text` result already exists mid-turn — it just isn't sent.** The agent's show_text branch (app/rag/agent.py L2238–2340) appends the resolved `VerbatimBlock` to `holder.shown_texts` (call order, the `show_text_max_blocks` budget) the instant the call succeeds, and the result text rides the round's `tool` message — but the block itself reaches the client ONLY in the done frame.
|
||||||
|
3. **So the reading order inverts.** The prose streams first; every verbatim panel (fenced or not) pops in at the END, after the model has already finished talking about the bytes: "…here is the doc: [model finishes] [panel appears]". The unfenced blocks are the worst case — `appendUnlocatedVerbatim` appends them at the answer's tail (the `answer_image` position), detached from the sentence that introduced them.
|
||||||
|
4. **The fix is additive.** The block is already in hand at the success point (the `shown_texts` append, L2322 area); the round loop already has the snapshot-then-yield pattern for phase-95 truncations (`trunc_before`, L2640 → the `ToolResultPiece` yield at L2690). One new piece + one new SSE event + one client branch; the `done` frame, the record key, the restore and the shared page keep their exact contract.
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
|
||||||
|
- **A1 — the mid-turn frame (task 01).** A new additive SSE event `verbatim_block`, emitted as soon as a `show_text` call succeeds:
|
||||||
|
- `app/rag/agent.py`: a new `@dataclass VerbatimBlockPiece` (the `ToolResultPiece` pattern, beside it; the `block: VerbatimBlock` field — the class is already imported at L343). In the round loop: `shown_before = len(holder.shown_texts)` BEFORE `_execute_tool` (the `trunc_before` pattern, L2640); after the round's `ToolResultPiece` yield (L2690), `for block in holder.shown_texts[shown_before:]: yield VerbatimBlockPiece(block=block)` — the frame lands AFTER the round's `tool_result` frame and BEFORE the next model round. SUCCESS-ONLY by construction: every refusal path returns before the `shown_texts.append` (L2322), and the already-shown dedupe is a refusal — a refused call yields no piece. The generator union types (L928/L992) gain the piece.
|
||||||
|
- `app/schemas.py`: `ChatVerbatimBlockEvent` (the `ChatToolResultEvent` pattern, L321) — `type: Literal["verbatim_block"]` + the EXACT `VerbatimBlockRef` field set (L185: `source` ≤ 120 / `path` ≤ 1000 / `title` ≤ 500 / `start_line` / `end_line` / `total_lines` / `text` ≤ 600 000 / `truncated` / `locator` ≤ 2100) — the client's `isVerbatimBlock` check (verbatim.js L70) accepts the event as-is; the framework's `extra` convention of the sibling events is mirrored.
|
||||||
|
- `app/api/chat.py`: the new dispatch branch after the `ToolResultPiece` branch (L1104): `if isinstance(piece, VerbatimBlockPiece): yield sse_event(ChatVerbatimBlockEvent(**asdict-style fields).model_dump()); continue`. The done-frame `answer_text` resolution (L1319–1347) and the per-turn log line (`verbatim_blocks=N`, done-frame count) are UNTOUCHED — the mid-turn frames are not counted there (the pin stays green).
|
||||||
|
- KILL-SWITCH: `BOR_ANSWER_TEXT=0` → the tool is not offered (the phase-182 D8 posture) → no success → NO piece, NO frame: the frame sequence is byte-identical to pre-phase (a wire pin, task 01 + the E2E arm).
|
||||||
|
- **A2 — the client lands the panel mid-stream (task 02).**
|
||||||
|
- `frontend/assets/verbatim.js`: a NEW `hydrateVerbatimNow(root, block, opts)` — walks the PENDING placeholders (`.verbatim-pending[data-bor-locator]`); for each, `matchVerbatimBlock([block], key)` (the SAME matching semantics as the done path — the exact locator, or the identity fallback when the list holds exactly one block of that identity); REPLACES ONLY the matched nodes with `buildVerbatimPanel(block, opts)`; the UNMATCHED pending nodes STAY PENDING (settling them is the done/failure path's job — a mid-turn pass must never burn a placeholder for a not-yet-shown fence); returns the number matched (0 → the caller appends). Never throws (the house contract — a missing `root` → 0).
|
||||||
|
- `frontend/assets/app.js`: the new `ev.type === "verbatim_block"` branch beside the `tool_result` branch (L4612): `const n = hydrateVerbatimNow(bubble, ev, VERBATIM_LINK_OPTS); if (n === 0) appendUnlocatedVerbatim(wrap, [ev], VERBATIM_LINK_OPTS);` — a fenced block lands IN PLACE at its fence (the model said "here it is" → the panel is there); an unfenced block lands at the `answer_image` position (the `appendUnlocatedVerbatim` contract — below the current text, above the sources) AT THE MOMENT the call succeeds, not at the end of the answer. `appendUnlocatedVerbatim`'s state-free idempotence (same canonical locator already placed → skip, L463–472) makes the done pass's re-run a no-op for the same block. No client-side kill-switch gate: the server's gate is the only one (a `False` turn sends no frame — the branch never fires).
|
||||||
|
- **A3 — the done/restore dedupe (task 03).** The one remaining duplicate: a mid-turn APPENDED panel (locator A) + the model later fences the SAME locator A → the fence placeholder hydrates at done into a second panel. `hydrateVerbatim` (the done AND restore path — L423) gains the placed-scan: before building a matched panel for a `key`, if a PLACED resolved panel (``.verbatim[data-bor-locator]`, NOT `.verbatim-unresolved` — the `appendUnlocatedVerbatim` placed-scan convention) carrying the same canonical `locator` exists in the message wrap (`root.closest(".msg")`), that placed panel is REMOVED first — the in-text panel wins (the fence is where the model wanted it; the appended panel has served its mid-turn purpose). TWO DIFFERENT ranges of the same file are LEGITIMATE (the `shown_texts` dedupe is per canonical locator, not per identity) and stay — the scan is per-locator, never per-identity. The restore path (L3044–3046) and the shared page ride the SAME functions — no separate change; the parity pin is the test.
|
||||||
|
- **A4 — the unchanged contract (pinned across tasks).** The `done` frame's `answer_text` list, the saved-record key, the restore path and the shared page are byte-identical to phase 182 for a turn that never calls `show_text`; a `BOR_ANSWER_TEXT=0` turn is byte-identical end to end (no frame, no placeholder, no panel); the `verbatim_blocks=N` per-turn log line counts the done-frame blocks as before.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- `app/rag/agent.py` — `VerbatimBlock` (imported L343), `holder.shown_texts` (L1848), the show_text branch (L2238–2340), the round loop (L2640–2697), the piece union types (L928/L992); `app/api/chat.py` — the piece dispatch (L1096–1130), the done-time resolution (L1319–1347); `app/schemas.py` — `VerbatimBlockRef` (L185), `ChatToolResultEvent` (L321), the `extra` convention; `app/rag/verbatim.py` — `VerbatimBlock` (L94), `block_for`, `canonical_locator` (D7).
|
||||||
|
- `frontend/assets/verbatim.js` — `isVerbatimBlock` (L70), `matchVerbatimBlock` (L404), `hydrateVerbatim` (L423), `appendUnlocatedVerbatim` (L447), `settleVerbatimPlaceholders` (L480); `frontend/assets/app.js` — the `tool_result` branch (L4612), the done branch's hydrate/append (L4506–4511), the restore hydrate/append (L3044–3046).
|
||||||
|
- `tests/e2e/mock_llm.py` (the mock's show_text flow — the phase-182 E2E precedent; verify the exact mock arm), the phase-182 E2E suites (verify the names in `tests/e2e/`), the JS marker-pin unit module (verify the name — the `test_save_chat_ui.py` convention), `tests/integration/` the chat SSE contract module (verify the name).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_verbatim_block_sse.md` — the `VerbatimBlockPiece`, the `ChatVerbatimBlockEvent`, the chat dispatch branch; the agent mock pins, the SSE contract pins, the kill-switch wire pin.
|
||||||
|
2. `02_midturn_hydration_and_append.md` — `hydrateVerbatimNow` + the app.js `verbatim_block` branch (in-place fence hydration at mid-turn; unfenced blocks land at the call moment; unmatched placeholders stay pending).
|
||||||
|
3. `03_done_and_restore_dedupe.md` — the `hydrateVerbatim` placed-scan dedupe (per-locator; two ranges stay; the restore/shared parity pin; the kill-switch and the `verbatim_blocks=N` pins stay green).
|
||||||
|
4. `04_e2e_and_gates.md` — the dedicated Playwright suite (the mid-turn arm, the fence arm, the no-double arm, the kill-switch wire arm, the restore arm), the full gates, the docs pass, the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- **Unit:** the agent mock pins (a successful `show_text` → exactly one `VerbatimBlockPiece` after the round's `tool_result` and before the next round; the five refusal paths → zero; the `answer_text=False` posture → zero); the `VerbatimBlockPiece`/`ChatVerbatimBlockEvent` shape pins; the JS marker pins (the `hydrateVerbatimNow` matched-only contract, the app.js branch).
|
||||||
|
- **Integration:** the chat SSE contract — a mock turn with `show_text` → the stream carries the `verbatim_block` frame (the exact `VerbatimBlockRef` fields) before `done`; a `BOR_ANSWER_TEXT=0` app → the captured frame sequence is byte-identical to the pre-phase sequence (the kill-switch wire pin); the done frame's `answer_text` is unchanged (the phase-182 pins stay green).
|
||||||
|
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_verbatim_inline_midturn.py`, run in isolation — the owner-visible arm: the panel is on screen WHILE the answer is still streaming (before `done`), the fence hydrates in place mid-turn, no double panel, the kill-switch wire arm, the restore parity.
|
||||||
|
- **Regression:** the phase-182 E2E suites green unedited; `tests/e2e/test_chat_history.py` (the restore contract) green.
|
||||||
|
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`); the frontend change is marker-pinned per the house JS convention.
|
||||||
|
- **Lint/types:** `uv run ruff check . && uv run pyright`.
|
||||||
|
|
||||||
|
## Documentation & Consistency
|
||||||
|
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
|
||||||
|
- `README.md`: the verbatim section notes the mid-turn landing (a `show_text` panel appears the moment the call succeeds — in place when fenced, at the answer position when not; the done/restore contract is unchanged) (task 04). `AGENTS.md`: the phase-182 clause gains the mid-turn frame clause if it names the done-frame resolution (verify, do not add if absent).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] a successful `show_text` call yields exactly one `verbatim_block` frame (the unit + integration pins); every refusal yields none.
|
||||||
|
- [ ] a `BOR_ANSWER_TEXT=0` turn's frame sequence is byte-identical to pre-phase (the wire pin) — no frame, no placeholder, no panel.
|
||||||
|
- [ ] a pending fence placeholder for a shown block hydrates IN PLACE at mid-turn (before `done`); an unfenced block is on screen at the call moment (the E2E arms).
|
||||||
|
- [ ] a mid-turn appended panel + a same-locator fence → exactly ONE panel after `done` (the dedupe pin); two different ranges of one file → two panels (the pin).
|
||||||
|
- [ ] the restore path shows exactly one panel per locator after a reload (the E2E arm); the `done` frame's `answer_text`, the record key and the `verbatim_blocks=N` log line are unchanged (the phase-182 pins green unedited).
|
||||||
|
- [ ] test suite green, coverage >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed phases (a turn without `show_text` is byte-identical end to end).
|
||||||
|
- [ ] one atomic Conventional Commits commit with `--no-gpg-sign`.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Task 01 — The additive `verbatim_block` SSE frame (A1)
|
||||||
|
|
||||||
|
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6 — "The verbatim tag appear at the end and only when the llm is done answering …"`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The server half: a `show_text` success yields a new `VerbatimBlockPiece` → a new additive `verbatim_block` SSE frame lands AFTER the round's `tool_result` frame and BEFORE the next model round — the block the agent already holds, sent at the moment it exists. Success-only by construction; `BOR_ANSWER_TEXT=0` → zero frames (byte-identical sequence).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/agent.py`:
|
||||||
|
- a new `@dataclass VerbatimBlockPiece` beside `ToolResultPiece` (the same style — a field docstring naming the phase-95/182 lineage: `block: VerbatimBlock` — the class is already imported at L343);
|
||||||
|
- the round loop: `shown_before = len(holder.shown_texts)` immediately BEFORE `_execute_tool` (L2640, the `trunc_before` pattern); after the round's `ToolResultPiece` yield (L2690–2697), `for block in holder.shown_texts[shown_before:]: yield VerbatimBlockPiece(block=block)` — one line, the same snapshot-then-yield shape (a round can add at most one block — the already-shown dedupe is a refusal — so the loop is a one-iteration shape, but the snapshot keeps a future multi-add correct);
|
||||||
|
- the generator union types (L928/L992) gain `VerbatimBlockPiece`; the module docstring's piece paragraph (the L215–225 display-tools region) gains the one-line clause (the mid-turn frame, the done-frame count UNCHANGED — `verbatim_blocks=N` still counts the done frame's blocks, never the mid-turn frames);
|
||||||
|
- the `run()` docstring: the emission-order contract (the frame is after the round's `tool_result`, before the next round — a refused call emits nothing, the `shown_texts.append` at L2322 is the single success gate).
|
||||||
|
2. `app/schemas.py` — `ChatVerbatimBlockEvent(BaseModel)` (the `ChatToolResultEvent` L321 pattern, directly after it):
|
||||||
|
- `type: Literal["verbatim_block"] = "verbatim_block"` + the EXACT `VerbatimBlockRef` field set (L185 — `source` ≤ 120, `path` ≤ 1000, `title` ≤ 500, `start_line`/`end_line`/`total_lines` `ge=0`, `text` ≤ 600 000, `truncated: bool = False`, `locator` ≤ 2100) — the docstring: the mid-turn twin of the done frame's `answer_text` item; the client's `isVerbatimBlock` check (verbatim.js L70) accepts the event as-is (the key set matches — a pin); the sibling events' `model_config`/`extra` convention is mirrored EXACTLY (verify `ChatToolResultEvent`'s config and copy it).
|
||||||
|
3. `app/api/chat.py` — the dispatch branch after the `ToolResultPiece` branch (L1104–1119):
|
||||||
|
```python
|
||||||
|
if isinstance(piece, VerbatimBlockPiece):
|
||||||
|
# Phase 192 (task 01, A1): the mid-turn verbatim frame — the block
|
||||||
|
# the show_text call just displayed, sent at the moment it exists.
|
||||||
|
# Additive: a refused call yields no piece (no frame); a
|
||||||
|
# answer_text=False turn offers no tool (no frame) — the frame
|
||||||
|
# sequence is byte-identical to pre-phase in both cases.
|
||||||
|
yield sse_event(
|
||||||
|
ChatVerbatimBlockEvent(
|
||||||
|
source=piece.block.source,
|
||||||
|
path=piece.block.path,
|
||||||
|
title=piece.block.title,
|
||||||
|
start_line=piece.block.start_line,
|
||||||
|
end_line=piece.block.end_line,
|
||||||
|
total_lines=piece.block.total_lines,
|
||||||
|
text=piece.block.text,
|
||||||
|
truncated=piece.block.truncated,
|
||||||
|
locator=piece.block.locator,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
(import `VerbatimBlockPiece` beside `ToolResultPiece` at L290; import `ChatVerbatimBlockEvent` beside the event imports at L310–322).
|
||||||
|
4. `tests/unit/test_agent.py` (verify the module name — the agent mock suite):
|
||||||
|
- `test_show_text_success_yields_one_verbatim_block_piece` — a mock turn whose answer calls `show_text` (the phase-182 mock precedent — verify the helper in the module) → exactly ONE `VerbatimBlockPiece`, positioned AFTER the round's `ToolResultPiece` (if a truncation piece exists) and BEFORE the next model round's first `StreamPiece`; the piece's `block` is the `holder.shown_texts` entry (same object);
|
||||||
|
- `test_show_text_refusals_yield_no_verbatim_block_piece` — the five refusal paths (the missing-arg `MISSING_SHOW_TEXT_ARGS`, the `answer_text=False` `UNKNOWN_TOOL`, the no-document refusal, the `is_image` refusal, the block-cap refusal, the already-shown dedupe — six pins, the module's refusal fixtures) → zero `VerbatimBlockPiece`s in the stream;
|
||||||
|
- `test_kill_switch_turn_yields_no_verbatim_block_piece` — the `answer_text=False` app posture → the tool is absent from the offered tools (the phase-182 D8 pin stays green) AND zero `VerbatimBlockPiece`s.
|
||||||
|
5. `tests/integration/test_chat_api.py` (verify the module name — the SSE contract suite):
|
||||||
|
- `test_verbatim_block_frame_lands_before_done` — a mock-LLM turn with a `show_text` call → the captured SSE stream carries a `type="verbatim_block"` frame with the EXACT `VerbatimBlockRef` field set (the pin asserts every key of `model_dump()`) positioned after the matching `tool_result`/`tool` frames and before `done`; the `done` frame's `answer_text` list is the same list the pre-phase resolution produced (the phase-182 pin, extended — the done-time resolution is untouched);
|
||||||
|
- `test_kill_switch_stream_is_byte_identical` — the `BOR_ANSWER_TEXT=0` module app: the captured frame sequence (types + payloads) equals the pre-phase expected sequence (the documented baseline in the test — a turn with the mock's `show_text` attempt gets the phase-182 refusal path; NO `verbatim_block` frame anywhere).
|
||||||
|
6. Verify: `uv run pytest tests/unit/test_agent.py tests/integration/test_chat_api.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the success-position pin, the six refusal pins, the kill-switch pin.
|
||||||
|
- Integration: the frame contract pin, the kill-switch wire pin, the done-frame parity pin.
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] a successful `show_text` yields exactly one `verbatim_block` frame (after `tool_result`, before the next round) — unit + integration pins.
|
||||||
|
- [ ] every refusal path yields zero frames; the `answer_text=False` stream is byte-identical to pre-phase (the wire pin).
|
||||||
|
- [ ] the done frame's `answer_text` and the `verbatim_blocks=N` log line are unchanged (the phase-182 pins green unedited).
|
||||||
|
- [ ] full test suite **100% passing**.
|
||||||
|
- [ ] no behavior change in completed work (a turn without `show_text` is byte-identical — the pre-existing SSE pins green).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Task 02 — Mid-turn landing: the fence hydrates in place, the unfenced block lands at the call moment (A2)
|
||||||
|
|
||||||
|
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6 — "… which makes the chat read strangely."`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The client half: a `verbatim_block` frame settles the pending fence placeholder IN PLACE (the matching semantics of the done path) or, when nothing matches, lands the panel at the answer position AT THE MOMENT the call succeeds — so the reading order is the model's order: "…here it is:" → panel → "…as you can see…". Unmatched placeholders STAY pending (settling is the done/failure path's job).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/verbatim.js` — a NEW `hydrateVerbatimNow(root, block, opts)` (after `hydrateVerbatim`, L446):
|
||||||
|
```js
|
||||||
|
/* hydrateVerbatimNow(root, block, opts) — the MID-TURN pass (phase 192,
|
||||||
|
* A2): settle the PENDING placeholders THIS ONE block matches, leave
|
||||||
|
* every other placeholder pending.
|
||||||
|
*
|
||||||
|
* The matching is the done path's semantics (matchVerbatimBlock with a
|
||||||
|
* ONE-block list): the exact canonical locator, or the identity
|
||||||
|
* fallback when the block is the only one of that identity — a fence
|
||||||
|
* for range B of a shown file A hydrates with A's server extent (the
|
||||||
|
* caption shows the SERVER's range, the phase-182 contract), so the
|
||||||
|
* mid-turn pass and the done pass can never disagree.
|
||||||
|
*
|
||||||
|
* ONLY matched nodes are replaced (buildVerbatimPanel); the unmatched
|
||||||
|
* pending nodes STAY .verbatim-pending — settling them is the
|
||||||
|
* done/failure path's job (settleVerbatimPlaceholders), and a mid-turn
|
||||||
|
* pass must never burn a placeholder for a not-yet-shown fence.
|
||||||
|
*
|
||||||
|
* Returns the number of placeholders settled (the caller appends the
|
||||||
|
* block — appendUnlocatedVerbatim — only when it is 0). Never throws:
|
||||||
|
* a missing root → 0. */
|
||||||
|
```
|
||||||
|
- implementation: `const list = [block]` guarded by `isVerbatimBlock(block)` (a malformed frame → 0, the house never-throw contract); the same `querySelectorAll(".verbatim-pending[data-bor-locator]")` walk as `hydrateVerbatim`; `matchVerbatimBlock(list, key)`; replace only when matched; count the replacements.
|
||||||
|
2. `frontend/assets/app.js` — the new branch beside the `tool_result` branch (L4612):
|
||||||
|
```js
|
||||||
|
} else if (ev.type === "verbatim_block") {
|
||||||
|
// Phase 192 (task 02, A2): the show_text panel lands at the moment
|
||||||
|
// the call succeeds — the fence it names hydrates IN PLACE; an
|
||||||
|
// unfenced block lands at the answer position now, not at done.
|
||||||
|
// The done pass re-runs the same functions: hydrateVerbatim is
|
||||||
|
// idempotent (only .verbatim-pending is touched) and
|
||||||
|
// appendUnlocatedVerbatim skips an already-placed locator, so the
|
||||||
|
// done frame and this frame can never double a panel.
|
||||||
|
const n = hydrateVerbatimNow(
|
||||||
|
wrap.querySelector(".bubble"), ev, VERBATIM_LINK_OPTS
|
||||||
|
);
|
||||||
|
if (n === 0) appendUnlocatedVerbatim(wrap, [ev], VERBATIM_LINK_OPTS);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- the import line: `hydrateVerbatimNow` joins `hydrateVerbatim` / `appendUnlocatedVerbatim` in the existing verbatim.js import (verify the import site);
|
||||||
|
- the `answer_text=False` gate is SERVER-side only (the frame never arrives) — NO client-side settings check (a client gate would be a second, drift-prone gate — the docstring says so).
|
||||||
|
3. `tests/unit/` — the JS marker pins (the verbatim JS pin module — verify the name; the `test_save_chat_ui.py` convention):
|
||||||
|
- `hydrateVerbatimNow` exists with the matched-only contract (the marker names the `matchVerbatimBlock` reuse + the "unmatched stay pending" clause + the never-throw guard);
|
||||||
|
- the app.js handler references `verbatim_block` + `hydrateVerbatimNow` + `appendUnlocatedVerbatim` in the `n === 0` shape (the source marker — the append only on zero matches);
|
||||||
|
- the `isVerbatimBlock` guard marker (a malformed frame settles nothing).
|
||||||
|
4. Verify: `uv run pytest tests/unit/<js-pin-module> -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the marker pins above (the matched-only contract is the task's heart — the E2E proves the behavior, the markers prove the shape).
|
||||||
|
- E2E: task 04 (the owner-visible arm).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — the task is frontend; the app-side gate stays green).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `hydrateVerbatimNow` settles ONLY the pending placeholders the single block matches; the rest stay `.verbatim-pending`; returns the match count; never throws (marker pins).
|
||||||
|
- [ ] the app.js `verbatim_block` branch hydrates in place and appends ONLY on zero matches (marker pins); no client-side kill-switch gate (the marker asserts its absence).
|
||||||
|
- [ ] the existing `hydrateVerbatim` / `appendUnlocatedVerbatim` / `settleVerbatimPlaceholders` functions are UNTOUCHED (the phase-182 pins green unedited).
|
||||||
|
- [ ] no behavior change in completed work (a turn without the frame type never enters the branch — the pre-existing E2E suites green).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Task 03 — The done/restore dedupe and the unchanged-contract pins (A3/A4)
|
||||||
|
|
||||||
|
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6 — "… makes the chat read strangely."`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Close the one remaining duplicate: a mid-turn APPENDED panel and a later fence for the SAME canonical locator must end as ONE panel (the in-text one). The dedupe lives in `hydrateVerbatim` (the done AND restore path — one function, so the shared page rides it for free); per-locator, never per-identity — two DIFFERENT ranges of one file are legitimate and stay.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/verbatim.js` — `hydrateVerbatim` (L423–445) gains the placed-scan, inside the per-node loop:
|
||||||
|
- before `node.replaceWith(buildVerbatimPanel(block, opts))` for a MATCHED key: `const wrap = root.closest(".msg") || root;` (the message wrap — the same host `appendUnlocatedVerbatim` scans); remove every PLACED resolved panel (`.verbatim[data-bor-locator]`, NOT `.verbatim-unresolved` — the `appendUnlocatedVerbatim` placed-scan convention, L463–467) whose `data-bor-locator` equals the key (the canonical-locator comparison — the exact `===`, the phase-182 D7 key), then build the in-text panel in the node's place;
|
||||||
|
- UNMATCHED nodes: UNCHANGED (still settle to `buildVerbatimUnresolved`);
|
||||||
|
- the docstring gains the A3 paragraph: the mid-turn appended panel is superseded by the in-text fence panel (the fence is where the model wanted it); the scan is PER LOCATOR — two different ranges of one file (the `shown_texts` per-locator dedupe admits both) are both legitimate placements and BOTH survive (the scan never compares identities);
|
||||||
|
- `appendUnlocatedVerbatim` is UNTOUCHED (its state-free idempotence — the `placed` set skip, L468–472 — already makes the done pass a no-op for an appended block).
|
||||||
|
2. `tests/unit/` — the JS marker pins (the verbatim JS pin module — the task-02 module):
|
||||||
|
- the `hydrateVerbatim` placed-scan marker: the scan targets `.verbatim[data-bor-locator]` (the class + attribute pair), EXCLUDES `.verbatim-unresolved`, removes on the exact `data-bor-locator` key (the three markers — the per-locator contract), and the "two ranges stay" clause is named in the docstring (the marker asserts the docstring contains the per-locator-not-per-identity sentence — the house docstring-pinning convention);
|
||||||
|
- the `root.closest(".msg")` wrap marker.
|
||||||
|
3. The unchanged-contract pins (A4) — the existing suites stay green UNEDITED, and three NEW integration/E2E-preparation pins:
|
||||||
|
- `tests/integration/test_chat_api.py` — `test_done_frame_and_log_line_unchanged_by_midturn_frames` — a mock turn WITH a `show_text` call: the `done` frame's `answer_text` list equals the phase-182 resolution (the task-01 pin, now extended with the per-turn log line assertion: `verbatim_blocks=N` where `N` = the done-frame block count, the mid-turn frames NOT counted — the captured log line pin, the house log-pin convention).
|
||||||
|
- `tests/unit/test_schemas.py` (or the phase-182 pin module — verify): `ChatVerbatimBlockEvent`'s `model_dump()` key set == `VerbatimBlockRef`'s key set + `type` (the `isVerbatimBlock` compatibility pin — the client check's required keys are present).
|
||||||
|
- the restore-parity marker (the app.js restore path L3044–3046 UNTOUCHED — a marker asserting the restore path still calls `hydrateVerbatim` + `appendUnlocatedVerbatim` with `m.answer_text` — the shared page's hydration call site likewise untouched; the BEHAVIOR pin is the E2E arm).
|
||||||
|
4. Verify: `uv run pytest tests/unit/<js-pin-module> tests/integration/test_chat_api.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the placed-scan markers (per-locator, unresolved-excluded, wrap derivation, the two-ranges-stay clause).
|
||||||
|
- Integration: the done-frame + log-line parity pin; the schema compatibility pin.
|
||||||
|
- E2E: task 04 (the no-double arm, the two-ranges arm, the restore arm).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] a mid-turn appended panel + a same-locator fence → exactly ONE panel after `done` (the marker pins now; the E2E proves it) — the in-text panel wins, the placed panel removed.
|
||||||
|
- [ ] two different ranges of one file → two panels after `done` (the marker clause + the E2E arm).
|
||||||
|
- [ ] the restore path and the shared page's call sites are untouched (marker pins); the `done` frame's `answer_text` and the `verbatim_blocks=N` log line are unchanged (integration pins).
|
||||||
|
- [ ] `appendUnlocatedVerbatim` / `settleVerbatimPlaceholders` / the task-02 functions are untouched (the phase-182 pins green unedited).
|
||||||
|
- [ ] full test suite **100% passing**.
|
||||||
|
- [ ] no behavior change in completed work.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Task 04 — The phase E2E suite, the full gates, the docs pass, the commit
|
||||||
|
|
||||||
|
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The dedicated Playwright suite proves the owner-visible contract — the panel is on screen WHILE the answer is still streaming, the fence lands in place, nothing doubles, the kill switch is a byte-identical wire — run in isolation per AGENTS rule 4; the full gates pass; the docs are consistent; one atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_verbatim_inline_midturn.py` (NEW — the module docstring names the TODO item and the A4 unchanged-contract clause) — the module-app fixture pattern (the phase-182 E2E convention — verify in `tests/e2e/conftest.py` / the sibling verbatim suites; the mock LLM from `tests/e2e/mock_llm.py` — verify the phase-182 mock's `show_text` arm; if the mock drives `show_text` via a fence-only turn, extend the mock with a direct `show_text` tool call arm in `tests/e2e/mock_llm.py` — the mock is a test fixture, the extension is additive and the phase-182 pins must stay green):
|
||||||
|
- the fixture: a small KB with one document whose content the mock's `show_text` call targets (the phase-182 fixture convention).
|
||||||
|
- **(a) the mid-turn arm (the TODO regression):** a mock turn: an intro delta ("…here is the exact text:") → the `show_text` call (the mock arm) → the continuation deltas → `done`. Assert: the panel (`.verbatim[data-bor-locator]` with the call's locator — caption + gutter + body) is in the DOM BEFORE the `done` frame is consumed (the Playwright shape: `page.wait_for_selector` on the panel resolves while the stream is still running — a sentinel delta after the call proves the stream is live; the panel's DOM position is BELOW the intro text and ABOVE the final sentinel delta — the inline position assertion, the `answer_image`-position contract of `appendUnlocatedVerbatim`); the `done` pass adds nothing (the final panel count == 1).
|
||||||
|
- **(b) the fence-in-place arm:** a mock turn: an intro delta → the `show_text` call for `source/path#12-20` → the final answer containing the ` ```bor ` fence for the SAME locator → `done`. Assert: the fence placeholder is hydrated IN PLACE (the panel is at the fence position — between the intro and the final sentinel delta, NOT at the answer tail) before `done` (the mid-turn `hydrateVerbatimNow` pass); after `done`, the total panel count for that locator == 1 (the dedupe — the task-03 arm).
|
||||||
|
- **(c) the two-ranges arm:** a mock turn with TWO `show_text` calls — range 1–10 and range 11–20 of the same document (the `shown_texts` per-locator dedupe admits both) — no fences. Assert: exactly TWO panels after `done`, both at the answer position in call order, both with their own captions (the per-locator-not-per-identity pin, owner-visible).
|
||||||
|
- **(d) the kill-switch wire arm:** the `BOR_ANSWER_TEXT=0` module app — the same mock turn (the mock's `show_text` attempt hits the phase-182 refusal) → the captured SSE frame sequence (types + payloads via the house SSE-capture pattern) equals the pre-phase baseline (documented in the test); NO `verbatim_block` frame, NO panel, NO placeholder in the DOM.
|
||||||
|
- **(e) the restore arm:** run arm (a) to completion (a saved conversation — the phase-158 auto-save), reload the page, restore → exactly ONE panel for the locator (the record's `answer_text` through the same dedupe functions — the A4 parity pin, owner-visible).
|
||||||
|
2. Regression, run in isolation: the phase-182 E2E suites (verify the exact names in `tests/e2e/` — the verbatim display suite, the fence suite), `tests/e2e/test_chat_history.py` (the restore contract) — green unedited.
|
||||||
|
3. The full gates, in order:
|
||||||
|
- `uv run pytest` (unit + integration) — 100% passing;
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`;
|
||||||
|
- `uv run pytest tests/e2e/test_verbatim_inline_midturn.py -v --no-cov` — green in isolation;
|
||||||
|
- `uv run ruff check . && uv run pyright` — clean.
|
||||||
|
4. Documentation pass (the phase file's Documentation section): `README.md` + `AGENTS.md` reviewed in their entirety against the post-phase app; the verbatim section notes the mid-turn landing (a `show_text` panel appears the moment the call succeeds — in place when fenced, at the answer position when not; the `done`/restore/shared contract is unchanged, the kill switch byte-identical); `AGENTS.md` corrected only where it names the done-frame resolution as the ONLY landing point.
|
||||||
|
5. Commit: one atomic, professional commit (Conventional Commits, `--no-gpg-sign`), e.g.
|
||||||
|
`feat(verbatim): show_text panels land mid-turn, at the moment the call succeeds (phase 192)` — the body names the additive `verbatim_block` frame, the `hydrateVerbatimNow` matched-only pass, the per-locator dedupe, and the A4 unchanged contract. `.agents/phases/todo/192_verbatim_inline_midturn/` moves to `.agents/phases/complete/` in the SAME commit (AGENTS rule 8).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: the suite above, isolated; the regression suites green.
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
- Lint/types: clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `tests/e2e/test_verbatim_inline_midturn.py` green in isolation — (a) the panel is on screen before `done`, at the inline position; (b) the same-locator fence hydrates in place mid-turn and never doubles; (c) two ranges of one file → two panels; (d) the `answer_text=False` wire is byte-identical with no panel/placeholder; (e) the restore shows one panel per locator.
|
||||||
|
- [ ] `uv run pytest` 100% passing; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] `README.md` / `AGENTS.md` consistent (the mid-turn landing clause).
|
||||||
|
- [ ] the phase directory is in `.agents/phases/complete/`; one atomic commit with `--no-gpg-sign`.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Phase 193 — The sync label counts DOWN: "N remaining"
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L6 — "The sync status should show how many files are left to sync, not how many files out of the total."
|
||||||
|
**Story:** n/a (owner directive)
|
||||||
|
**Depends on:** none functionally (builds on the completed phase-64/98/128/129/152 sync-label lineage).
|
||||||
|
|
||||||
|
## Evidence (diagnosed from the code, read this not the chat)
|
||||||
|
|
||||||
|
1. **The running label counts UP.** `frontend/assets/sources.js` `fmtSyncLabel` (L1053–1058): the import phase and the upload job render `count = total > 0 ? `(${done}/${total})` : ""` — `Syncing… notes/a.md (1234/27000)`. The owner wants the distance to the end, not the position in the whole: `25766 remaining`.
|
||||||
|
2. **The data already exists.** `SyncStatus.files_done` / `files_total` (the phase-64 payload — the running label's only inputs) carry everything: `N = total − done` is a subtraction, no server change, no new field, no new poll. The `#sync-progress` line (the phase-152 A4: the untruncated full label rides the button title + `#sync-progress` via `setSyncLabel`) gets the new part through the SAME builder — nothing to change there.
|
||||||
|
3. **The other count parts are different animals — they stay.** `· chunk n/m` (phase 129 — one file's embed batch), `· image n/m` (phase 152 — one PDF's vision span), and the summaries phase's `(n/m)` (phase 98 — the FOLDER position, always emitted, `fmtSyncPhaseLabel` L1089+) are per-file / per-folder position parts, not "files of the total". The terminal result line (`counts_line` — "N added · M updated …") is a different surface and is untouched.
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
|
||||||
|
- **D1 — the part (task 01).** `fmtSyncLabel`'s `count` becomes `total > 0 && total - done > 0 ? `${total - done} remaining` : ""` — the owner-confirmed shape (ASSUMPTION A1, confirmed 2026-10-18):
|
||||||
|
- `N remaining` shows while `N ≥ 1`; at the final file (`N == 0`) the part is HIDDEN — the main part names the file in flight, and a "0 remaining" next to the last file's name is the stale-looking edge the owner's complaint is about;
|
||||||
|
- plain integer (no thousands separator — the existing count part is plain; the house label style);
|
||||||
|
- the POSITION is unchanged (main → count → chunk → image — the phase-128 parts rework order);
|
||||||
|
- the UPLOAD job shares the builder and the format (owner-confirmed A2 — `Importing <file> N remaining`; the upload's `files_total` is the pre-walk count, the phase-98 contract — the same subtraction semantics);
|
||||||
|
- `total === 0` (the pre-walk null total, the phase-64 contract) → no count part (unchanged);
|
||||||
|
- the chunk / image parts and the summaries-phase folder position are UNTOUCHED (byte-identical);
|
||||||
|
- the docstring (L1044–1058 region) is updated to the new part (the phase-64/98 lineage paragraph keeps its history, the `(done/total)` description becomes the `N remaining` one).
|
||||||
|
- **D2 — the pinned suites move (task 02).** The running-label count pins retarget to the new part; the terminal-line pins stay unedited. The definitive list is found by `grep -rn "Syncing…\|Importing" tests/e2e/*.py | grep -E "\\\\d|/\{N|[0-9]+\)"` at execution time (the known pins: `test_sync_label_mobile.py` `IMPORT_FULL_RE` L226 + the docstring L93/L213; `test_sync_upload_progress.py` L653/L667/L850).
|
||||||
|
|
||||||
|
## ASSUMPTIONS (owner-confirmed 2026-10-18)
|
||||||
|
- **A1:** `N remaining` while `N ≥ 1`, hidden at `N == 0` (the final file — the main part names it).
|
||||||
|
- **A2:** the upload job shares the format (one builder, one shape).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- `frontend/assets/sources.js` — `fmtSyncLabel` (L1053–1058), `fmtSyncPhaseLabel` (L1089+, the phase-aware wrapper: overview / summaries / import), `setSyncLabel` (the A4 full-label placement — the button title + `#sync-progress`), the `counts_line` helper (the terminal result line — UNTOUCHED); `app/schemas.py` `SyncStatus` (`files_done` / `files_total` — the payload, UNTOUCHED); `app/api/sync.py` (the status endpoint — UNTOUCHED).
|
||||||
|
- `tests/e2e/test_sync_label_mobile.py`, `tests/e2e/test_sync_upload_progress.py`, `tests/e2e/test_sync_chunk_progress.py`, `tests/e2e/test_sync_summary_visibility.py` (the phase-64/98/128/129/152 label suites — the count-part pins; the chunk/image/summaries pins stay), `tests/e2e/test_sync_git_freeze.py` + `test_sync_session_reset.py` + `test_fast_path_deferred_content.py` (the `counts_line` terminal pins — unedited).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_remaining_label.md` — the `fmtSyncLabel` count part becomes `N remaining` (the D1 spec), the docstring update, the JS marker pin.
|
||||||
|
2. `02_update_pinned_e2e.md` — the running-label count pins retarget to the new part (the D2 list); the full suite green.
|
||||||
|
3. `03_e2e_and_gates.md` — the phase's own suite (the count-down through the real UI), the full gates, the docs pass, the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- **Unit (JS marker pin):** the new count expression (`total - done > 0` guard, the plain-integer shape, the `total > 0` guard kept, the upload kind sharing the builder — the marker names the expression).
|
||||||
|
- **E2E (regression, task 02):** the retargeted count pins + the chunk / image / summaries pins green; the terminal `counts_line` pins green UNEDITED.
|
||||||
|
- **E2E (phase, AGENTS rule 4, task 03):** `tests/e2e/test_sync_remaining_count.py`, run in isolation — the count-down arm (the part decreases across polls; hidden at the final file; the chunk part coexists), the upload arm (the shared format).
|
||||||
|
- **Coverage:** **>90%** on `app/` (unchanged — the task is frontend; the app-side gate stays green).
|
||||||
|
- **Lint/types:** `uv run ruff check . && uv run pyright`.
|
||||||
|
|
||||||
|
## Documentation & Consistency
|
||||||
|
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
|
||||||
|
- `README.md`: the sync section's running-label description (the `(done/total)` example, if present) becomes the `N remaining` one; `AGENTS.md` — the phase-64 sync-label clause (if it names the count part) gains the new shape (task 03 — verify, do not add if absent).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] the running file-count part is `N remaining` (`N = total − done`, plain, shown while `N ≥ 1`, hidden at `N == 0`, `total === 0` → no part) — the marker pin + the E2E count-down arm.
|
||||||
|
- [ ] the upload label uses the same shape (the E2E upload arm); the chunk / image / summaries parts are byte-identical (the unedited pins green).
|
||||||
|
- [ ] the terminal result line is byte-identical (the `counts_line` pins green unedited); the `#sync-progress` line carries the new part through the same builder (the A4 pin).
|
||||||
|
- [ ] test suite green, coverage >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed phases (the server, the payload, the phases of the run all untouched — the change is the one label part).
|
||||||
|
- [ ] one atomic Conventional Commits commit with `--no-gpg-sign`.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Task 01 — The `N remaining` count part (D1)
|
||||||
|
|
||||||
|
**Phase:** `193_sync_remaining_count` · **Source:** `TODO.md:7 — "The sync status should show how many files are left to sync, not how many files out of the total."`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The one-line change the owner asked for: `fmtSyncLabel`'s running file-count part renders the distance to the end (`N remaining`, `N = total − done`) instead of the position in the whole (`(done/total)`) — sync and upload alike (one builder), hidden at the final file, the pre-walk `total === 0` behavior unchanged, the chunk / image / summaries parts untouched.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/sources.js` — `fmtSyncLabel` (L1053–1058):
|
||||||
|
- the `count` expression: `const count = total > 0 && total - done > 0 ? `${total - done} remaining` : "";` (the `total > 0` guard kept — the phase-64 pre-walk contract: no count part before the walk knows the total; the plain integer — no thousands separator, the existing part's style; the POSITION in the returned object is unchanged — the phase-128 parts order main → count → chunk → image is preserved by `fmtSyncPhaseLabel` which consumes `count` unchanged);
|
||||||
|
- the docstring block above the function (L1044–1052) + the `fmtSyncPhaseLabel` docstring (L1060–1088): the `(done/total)` description becomes the `N remaining` one — the phase-64/98 lineage KEPT (the "count part" history), the new clause: `N = total − done`, shown while `N ≥ 1`, hidden at `N == 0` (the final file — the main part names it; a "0 remaining" next to the last file's name is the stale-looking edge), the upload job shares the builder and the shape (the phase-98 D2 "upload keeps the bare fmtSyncLabel parts" clause updated: still bare, now with the `N remaining` part);
|
||||||
|
- `setSyncLabel` / `#sync-progress` placement: UNTOUCHED (the A4 full label rides the same builder — the new part flows through automatically);
|
||||||
|
- the terminal `counts_line` helper: UNTOUCHED.
|
||||||
|
2. `tests/unit/` — the JS marker pin (the sources-JS pin module — verify the name; the `test_save_chat_ui.py` convention):
|
||||||
|
- the `fmtSyncLabel` count expression marker (the `total > 0 && total - done > 0` guard + the `` `${total - done} remaining` `` shape — the source marker asserts the exact expression);
|
||||||
|
- the `total === 0` guard marker (no count part before the walk);
|
||||||
|
- the "hidden at N == 0" clause marker (the docstring names the final-file edge);
|
||||||
|
- the chunk / image / `fmtSyncPhaseLabel` parts UNTOUCHED marker (the three part builders' source is unchanged — the `· chunk n/m` / `· image n/m` / summaries `(n/m)` strings appear only in their own builders, not in `fmtSyncLabel`).
|
||||||
|
3. Verify: `uv run pytest tests/unit/<sources-pin-module> -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the marker pins above (the expression is the whole change — the markers pin the shape).
|
||||||
|
- E2E: task 02 (the retargeted pins) + task 03 (the phase arm).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — the task is frontend).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `fmtSyncLabel`'s count part is `` `${total - done} remaining` `` with the two guards (the marker pins).
|
||||||
|
- [ ] the docstrings name the new part + the hidden-at-final-file edge + the upload sharing (the marker pins).
|
||||||
|
- [ ] the chunk / image / summaries / terminal-line code is byte-identical (the unedited pins green).
|
||||||
|
- [ ] no behavior change in completed work (the server and the payload are untouched).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Task 02 — The pinned running-label count pins move to `N remaining` (D2)
|
||||||
|
|
||||||
|
**Phase:** `193_sync_remaining_count` · **Source:** `TODO.md:7`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The full suite stays green: every E2E pin that asserts the running file-count part retargets to the new shape; the chunk / image / summaries pins and the terminal `counts_line` pins stay UNEDITED.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. The definitive pin list — run at execution time: `grep -rn "Syncing…\|Importing" tests/e2e/*.py | grep -E "\\\\d|/\{N|[0-9]+\)"` — the known pins (verify each against the list):
|
||||||
|
- `tests/e2e/test_sync_label_mobile.py` — `IMPORT_FULL_RE` (L226): `^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$` → `^Syncing… {FILE_RE} [1-4] remaining(?: · chunk \d+/\d+)?$` (a 4-file fixture: while file `i` is in flight `done = i − 1`, so `N = 4 − done ∈ {1, 2, 3, 4}` — `N == 0` never renders, the part is `[1-4] remaining`; the docstring lines L93/L213 updated to the new shape — the `IMPORT_MAIN_RE` (L217, the no-count mobile clip) is UNTOUCHED);
|
||||||
|
- `tests/e2e/test_sync_upload_progress.py` — L653, L667, L850: `\(\d+/{N_FILES}\)` → `[0-9]+ remaining` (the same module's `N_FILES` fixture; the L667 arm keeps the ` · chunk \d+/\d+` tail; the L653/L850 arms are the bare label).
|
||||||
|
2. Any ADDITIONAL pin the grep surfaces (a label regex with a count part in another suite — the phase-128/129/152 lineage: `test_sync_chunk_progress.py`, `test_sync_summary_visibility.py`, `test_sync_git_freeze.py`, `test_sync_session_reset.py` are the candidates — verify each: a pin on the RUNNING label's count part → retargeted; a pin on the CHUNK/IMAGE part, the summaries folder position, or the `counts_line` terminal line → UNTOUCHED).
|
||||||
|
3. The module docstrings of the retargeted suites: the shape description updated (the `(n/m)` running-count clause → the `N remaining` clause, the phase-64/98 lineage KEPT, one sentence: phase 193 moved the part to the distance-to-end per the owner's directive).
|
||||||
|
4. Verify: `uv run pytest tests/e2e/test_sync_label_mobile.py tests/e2e/test_sync_upload_progress.py -v --no-cov` green (plus every additional retargeted suite); `uv run pytest tests/e2e/test_sync_chunk_progress.py tests/e2e/test_sync_summary_visibility.py tests/e2e/test_sync_git_freeze.py tests/e2e/test_sync_session_reset.py -v --no-cov` green (the unedited chunk/image/terminal pins); `uv run pytest` (unit + integration) green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E (regression): the retargeted count pins green; the chunk / image / summaries / terminal pins green unedited.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] every running-label count pin in `tests/e2e/` asserts the `N remaining` shape (the grep list is closed — every hit accounted for: retargeted or documented-untouched).
|
||||||
|
- [ ] the terminal `counts_line` pins and the chunk / image / summaries pins are green UNEDITED.
|
||||||
|
- [ ] `uv run pytest` 100% passing (the full suite — no pin left asserting the old shape).
|
||||||
|
- [ ] no behavior change in completed work (test-only + the task-01 label part).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 03 — The phase E2E suite, the full gates, the docs pass, the commit
|
||||||
|
|
||||||
|
**Phase:** `193_sync_remaining_count` · **Source:** `TODO.md:7`
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The dedicated Playwright suite proves the owner-visible contract — the label counts DOWN as the run progresses and goes quiet at the final file — run in isolation per AGENTS rule 4; the full gates pass; the docs are consistent; one atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_sync_remaining_count.py` (NEW — the module docstring names the TODO item, the A1/A2 assumptions and the "server untouched" clause) — the module-app fixture pattern (the phase-64/98 E2E convention — verify in `tests/e2e/conftest.py` / the sibling sync suites):
|
||||||
|
- the fixture: a small git source with N files (N ≥ 4, the phase-98 fixture convention — the `N_FILES` shape of `test_sync_upload_progress.py`).
|
||||||
|
- **(a) the count-down arm:** start the sync through the RAG page's sync flow; capture `#sync-progress` (and the button title — the A4 full-label placement) across the run: while files are in flight the label matches `^Syncing… <file> [1-N] remaining` and the displayed `N` is NON-INCREASING across polls (the `total − done` shape — a strict count-down; the pin: the sequence of captured `N`s, when a new value appears, is smaller); the `· chunk n/m` part coexists when a batch is in flight (the phase-129 part, unbroken); when the walk reaches the final file the part is ABSENT (the main part names the file — `^Syncing… <last-file>$`, no count part — the A1 hidden edge); the run's terminal result line is the phase-64 shape (the `counts_line` helper — byte-identical, the terminal-line pin).
|
||||||
|
- **(b) the upload arm (A2):** the upload job (the same fixture as an archive, the `test_sync_upload_progress.py` convention) → the label shows `Importing <file> [1-N] remaining` (the shared builder, the shared shape — the pin) and the chunk part coexists (the pin).
|
||||||
|
- **(c) the pre-walk edge:** the first poll before the walk knows the total → NO count part (`^Syncing…$`, the phase-64 contract — the `total === 0` guard, owner-visible).
|
||||||
|
2. Regression, run in isolation: `tests/e2e/test_sync_label_mobile.py`, `tests/e2e/test_sync_upload_progress.py` (the task-02 retargets), `tests/e2e/test_sync_chunk_progress.py`, `tests/e2e/test_sync_summary_visibility.py` (the chunk/image/summaries pins) — green.
|
||||||
|
3. The full gates, in order:
|
||||||
|
- `uv run pytest` (unit + integration) — 100% passing;
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`;
|
||||||
|
- `uv run pytest tests/e2e/test_sync_remaining_count.py -v --no-cov` — green in isolation;
|
||||||
|
- `uv run ruff check . && uv run pyright` — clean.
|
||||||
|
4. Documentation pass (the phase file's Documentation section): `README.md` + `AGENTS.md` reviewed in their entirety against the post-phase app; the sync section's running-label example (the `(done/total)` shape, if present in `README.md`) becomes the `N remaining` one; `AGENTS.md` corrected only where a phase-64/98 clause names the running count part.
|
||||||
|
5. Commit: one atomic, professional commit (Conventional Commits, `--no-gpg-sign`), e.g.
|
||||||
|
`feat(sync): the running label counts down — "N remaining" (phase 193)` — the body names the one-line `fmtSyncLabel` change (sync + upload through the shared builder), the hidden-at-final-file edge, and the untouched chunk/image/summaries/terminal parts. `.agents/phases/todo/193_sync_remaining_count/` moves to `.agents/phases/complete/` in the SAME commit (AGENTS rule 8).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: the suite above, isolated; the regression suites green.
|
||||||
|
- Coverage: **>90%** on `app/`.
|
||||||
|
- Lint/types: clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `tests/e2e/test_sync_remaining_count.py` green in isolation — (a) the count-down (non-increasing `N` across polls, the chunk part coexisting, the part absent at the final file, the terminal line byte-identical); (b) the upload arm (`Importing <file> N remaining`); (c) the pre-walk edge (no count part before the total is known).
|
||||||
|
- [ ] `uv run pytest` 100% passing; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] `README.md` / `AGENTS.md` consistent (the running-label shape).
|
||||||
|
- [ ] the phase directory is in `.agents/phases/complete/`; one atomic commit with `--no-gpg-sign`.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [ ] Changing the App name in settings broke, it doesn't update the name now.
|
|
||||||
+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