phase: 99_kb_tree_table_and_back_nav
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification is complete and green. Final report:

**Phase 99 — final verification pass (all 3 tasks already in `complete/`)**

- Verified the one-line Description clamp: `.kb-desc-cell` flex wrapper + `.kb-desc-text` ellipsis triad + `nowrap` column + in-cell Edit button + hover `title` + untouched level block (source diffs match D1; unit pins present)
- Verified the back-button breadcrumb nav: `applyTarget(target, push)` state-only `pushState`, no-duplicate gate, popstate adopt/reset, `bor:view-refresh` alignment before `loadTree()`, anonymous gate listener-free, router.js untouched (D2)
- `uv run pytest --cov=app`: **2180 passed**, coverage **99%** (>90% gate)
- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings**
- `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` (isolated, DB up): **4/4 passed** (back-walk, jump-then-back, fresh-nav/active-reclick, measured clamp incl. row-height parity ±4px, computed styles, full text in DOM/title/level block)
- Regression suites isolated: `test_kb_tree.py` 8✓, `test_ls_tree_drilldown.py` 3✓, `test_navbar_refresh.py` 7✓, plus `test_sync_summary_visibility.py` 3✓ (marker pin updated to `kb-desc-text kb-summary-pending` — the sanctioned class-pair change) and `test_edit_summaries.py` 4✓
- Criteria 1–4: **met** (each as above). Criterion 5 (atomic commit): left to the harness per executor rules — no `git add`/`commit` run; all changes left in the working tree
- No defects found in prior phases; no deviations. Stray `__pycache__/test_zz_smoke_tree_ui.*.pyc` is a leftover (no `.py` on disk) — inert, not touched
- Next pending phase: **none** — `todo/` will be empty once the harness moves this phase
This commit is contained in:
2026-09-13 01:57:40 -04:00
parent f665a83b1a
commit 8476dc1e07
22 changed files with 1755 additions and 53 deletions
@@ -0,0 +1,13 @@
All verification is complete and green. Final report:
**Phase 99 — final verification pass (all 3 tasks already in `complete/`)**
- Verified the one-line Description clamp: `.kb-desc-cell` flex wrapper + `.kb-desc-text` ellipsis triad + `nowrap` column + in-cell Edit button + hover `title` + untouched level block (source diffs match D1; unit pins present)
- Verified the back-button breadcrumb nav: `applyTarget(target, push)` state-only `pushState`, no-duplicate gate, popstate adopt/reset, `bor:view-refresh` alignment before `loadTree()`, anonymous gate listener-free, router.js untouched (D2)
- `uv run pytest --cov=app`: **2180 passed**, coverage **99%** (>90% gate)
- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings**
- `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` (isolated, DB up): **4/4 passed** (back-walk, jump-then-back, fresh-nav/active-reclick, measured clamp incl. row-height parity ±4px, computed styles, full text in DOM/title/level block)
- Regression suites isolated: `test_kb_tree.py` 8✓, `test_ls_tree_drilldown.py` 3✓, `test_navbar_refresh.py` 7✓, plus `test_sync_summary_visibility.py` 3✓ (marker pin updated to `kb-desc-text kb-summary-pending` — the sanctioned class-pair change) and `test_edit_summaries.py` 4✓
- Criteria 1–4: **met** (each as above). Criterion 5 (atomic commit): left to the harness per executor rules — no `git add`/`commit` run; all changes left in the working tree
- No defects found in prior phases; no deviations. Stray `__pycache__/test_zz_smoke_tree_ui.*.pyc` is a leftover (no `.py` on disk) — inert, not touched
- Next pending phase: **none** — `todo/` will be empty once the harness moves this phase
@@ -0,0 +1,98 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 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/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 232 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 137 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 126 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 200 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 313 0 100%
--------------------------------------------------
TOTAL 3903 15 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,17 @@
Task 01 complete — all gates green.
**Implemented**
- `sources.js` `makeDescCell`: the Description `<td>` now holds ONE `div.kb-desc-cell` flex wrapper — text `<span class="kb-desc-text">` (flexes + ellipsizes) + the always-present Edit button; stored text always carries `title` = full text (D1 hover escape hatch); the phase-98 pending marker keeps its own D4 title (`classList.add` — base class survives); the editor's row `container` is now the wrapper (level block's `.kb-level-body` untouched)
- `closeEditor`: toggles only `kb-summary-pending` (base classes survive the swap) and re-derives the clamped span's `title` from the stored text — a stale pre-edit title cannot survive a save; the unclamped level `<p>` keeps its no-title behavior
- `styles.css`: `td:nth-child(3)` → `white-space: nowrap` (budget/widths kept), new `.kb-desc-cell` (flex + 0.4rem gap) and `.kb-desc-text` (ellipsis triad + `min-width: 0`) rules, button `margin-left` removed (gap replaces it; level rule kept), `.kb-desc-cell .kb-summary-editor` flex-fill for the open editor; `.kb-level p` and no-new-hue invariant untouched; module docstring records the D1 contract
- Unit pins: updated the restructured-cell pins + 4 new tests (wrapper structure, hover-title rules, closeEditor title re-derivation, CSS declarations)
- Fixed two exact `to_have_class` pins in `test_sync_summary_visibility.py` broken by the additive base class (the phase-03 "fix-only-broken-pins" case, surfaced early)
**Results**
- `uv run pytest --cov=app --cov-report=term` → 2176 passed, `app/` TOTAL **99%** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- E2E in isolation (`--no-cov`): `test_kb_tree.py` 8 passed, `test_sync_summary_visibility.py` 3 passed, `test_ls_tree_drilldown.py` 3 passed, `test_navbar_refresh.py` 7 passed
**Decisions**: hover title is re-derived in `closeEditor` (not just `makeDescCell`) to prevent stale titles after save/cancel; no E2E file added here (task 03's mandate); no commit made (harness commits per phase).
**Next pending task**: `.agents/phases/todo/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md`
@@ -0,0 +1,98 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 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/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 232 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 137 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 126 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 200 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 313 0 100%
--------------------------------------------------
TOTAL 3903 15 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,20 @@
All green. Final report:
## Task 02 complete — back button walks the breadcrumb (drill state = history state)
**Implemented** (per D2/D3 in `00_phase.md`):
- `frontend/assets/sources.js`: `goTo` → `applyTarget(target, push)` — every drill (source row, folder link, breadcrumb segment) first pushes a **state-only** entry `history.pushState({ view: "rag", kb: target }, "")` (URL stays put, no third arg), skipping the push when the target equals `current` (no duplicate entries)
- `window popstate` listener (armed once, admin branch only): adopts `event.state.kb` via the no-push variant; a `kb`-less entry (boot/router/foreign) resets the drill to the top level
- `bor:view-refresh` listener: aligns `current` with `history.state` (adopt kb / reset to top) **before** `loadTree()` — fresh nav visit → top, active-link re-click → drill survives
- No pushState on boot; `resetVanishedLocation` and `router.js` untouched; module docstring gained the D2 contract section
- `tests/unit/test_kb_tree_ui.py`: replaced the stale client-side-only pin with the state-only push/duplicate-skip pin; 4 new tests (popstate adopt/reset, refresh alignment, anonymous installs no listeners, docstring)
- `tests/unit/test_frontend_router.py`: fixed the shared `_pin_refresh_listener` helper (slices to the listener's `});` close — the RAG listener became a block arrow) and the RAG pin (broken only by this additive change; asserted behavior unchanged)
**Gates (all green, final state):**
- `uv run pytest --cov=app --cov-report=term` → 2180 passed, TOTAL 99% (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- Regression E2E in isolation (`--no-cov`): `test_navbar_refresh.py` 7 passed, `test_kb_tree.py` 8 passed, `test_ls_tree_drilldown.py` 3 passed
**Notes:** no deviations from D2; no E2E file added (task 03 owns the browser proof + atomic commit). No defects found in prior work — the one broken pin was this task's own additive change.
**Next pending task:** `03_e2e_tree_nav.md` (`tests/e2e/test_kb_tree_nav.py` + regression sweep + atomic commit).
@@ -0,0 +1,98 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 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/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 232 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 137 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 126 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 200 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 313 0 100%
--------------------------------------------------
TOTAL 3903 15 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,21 @@
Task 03 complete. All gates green.
**Implemented**
- `tests/e2e/test_kb_tree_nav.py` — phase-99's dedicated A16 E2E suite (4 tests, mock-only, per-module app on port 8144, nested `alpha/one/two` + `alpha/three` fixture KB seeded via API + real sync):
- `test_back_button_walks_the_breadcrumb` — full drill chain → Back pops one level/press (two→one→root→top→CHAT view), Forward replays the whole chain; URL stays `/sources.html` on all state-only entries
- `test_breadcrumb_jump_then_back` — `Knowledge base` crumb jump pushes an entry; Back returns to the jump's origin
- `test_fresh_nav_visit_starts_at_the_top` — fresh RAG nav visit resets to top (D2 alignment); active-link re-click (no pushState, `history.length` pinned) keeps the drill
- `test_description_cell_clamps_to_one_line` — 470-char manual description via `PATCH /api/folders/summary`: row `td` height = short row's (±4 px), computed `ellipsis`+`nowrap`, full text in `title`/DOM, Edit button measured in-cell, `#kb-level-summary` shows full unclamped text
- Module-scoped autouse cleanup removes the temp source's documents/chunks/folder_summaries/git_sources rows (verified: shared DB empty after the run)
**Results (exact commands)**
- `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` → 4 passed (twice, incl. after the sweep)
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → 8 passed · `test_ls_tree_drilldown.py` → 3 passed · `test_navbar_refresh.py` → 7 passed (all in isolation; no pin fixes needed)
- `uv run pytest --cov=app --cov-report=term-missing` → 2180 passed, `app/` coverage 99% (>90%)
- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings
**Decisions/deviations**
- No commit and no phase-dir move made — per harness rules the harness makes the one atomic `--no-gpg-sign` commit and moves task files (overrides the task file's commit step)
- No defects found in tasks 01/02 (baseline was fully green)
**Next pending task:** none — 03 was the final task of phase 99.
@@ -0,0 +1,98 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 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/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 232 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 137 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 126 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 200 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 313 0 100%
--------------------------------------------------
TOTAL 3903 15 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
+154 -19
View File
@@ -203,6 +203,65 @@
* surface passes its pending copy via pendingText — the row's * surface passes its pending copy via pendingText — the row's
* "Summary pending" marker, the level's D4 note — and its * "Summary pending" marker, the level's D4 note — and its
* tooltip via pendingTitle, the row only). * tooltip via pendingTitle, the row only).
*
* Phase 99 (task 01, D1) — the ONE-LINE Description clamp: the
* owner's long stored descriptions no longer stretch the catalog
* rows. A row's Description cell is now ONE flex wrapper
* (`.kb-desc-cell` — inside the <td>, which gains no new class) holding
* the text span (`.kb-desc-text` — flex: 1 1 auto + min-width: 0 (the
* flex item may shrink), overflow: hidden + text-overflow: ellipsis +
* white-space: nowrap: the first line that fits, ellipsized — the row
* height is ONE line, independent of the description's length) and the
* ALWAYS-present Edit button (`.kb-summary-edit`, flex: 0 0 auto — it
* never wraps to a second line; its spacing comes from the wrapper's
* gap, its old margin-left is gone). The clamp is VISUAL only: the
* full text stays in the DOM (the accessible name is unchanged) and
* rides the span's title (the hover escape hatch, the house pattern) —
* set for real description text whenever non-empty; the phase-98
* marker keeps its OWN D4 title (never overridden), the empty cell
* carries none. The editor's open/close swaps happen INSIDE the
* wrapper (wireDescriptionEdit's row container IS the wrapper — the
* <td> always keeps it; the level block's container stays
* .kb-level-body), and closeEditor toggles ONLY the
* kb-summary-pending class (the base classes survive) while re-deriving
* the clamped span's title from the stored text (a stale pre-edit
* title cannot survive a save). The level block (`.kb-level p`) keeps
* the FULL unclamped description — the escape hatch at the top when
* you click in.
*
* Phase 99 (task 02, D2) — the browser back/forward walks the
* breadcrumb: the drill state IS the history state. Every user-
* initiated drill (source-row click, folder-link click, breadcrumb-
* segment click — all through goTo) goes through applyTarget(target,
* true): when the target DIFFERS from the current level, it FIRST
* pushes a STATE-ONLY entry — history.pushState({ view: "rag", kb:
* target }, "") — then sets current + re-renders. The push is the
* STATE only: the second arg is "" and there is no third — the URL
* STAYS PUT (no new route, the phase-76 deep-link surface is
* untouched; the shell's pathname is the only route contract). A
* re-click of the current level pushes NOTHING (no duplicate history
* entries). Back/forward: the window popstate (armed ONCE at mount,
* admin branch only — the anonymous gate installs no listeners) ADOPTS
* the popped entry's state.kb via the no-push variant (applyTarget(kb,
* false) — the browser owns its own entries, so adopt/reset never
* push); an entry WITHOUT a kb (the boot entry, the router's view
* entries, any foreign state) RESETS the drill to the TOP level
* (applyTarget({ source: null, folder: null }, false)). Back at the
* top level pops the router's view entry — the view leaves, exactly
* as today (the router's pathname-based switch, UNCHANGED). The
* re-show (bor:view-refresh) aligns the same way BEFORE the re-fetch:
* a kb-carrying top entry (the active-link re-click pushed NOTHING —
* the drilled entry is still on top) KEEPS the drill; a kb-less entry
* (a fresh nav visit) starts at the top level. Boot pushes NOTHING
* (the browser's own entry is the base — the phase-76 boot contract),
* and the never-stale resetVanishedLocation is UNCHANGED (it resets
* current WITHOUT pushing when a re-fetch finds the level gone — it
* follows whatever the history state says; no new entry). The router
* module is UNTOUCHED: its popstate reads the pathname only, and a
* state-only push never changes it (so rag-internal entries never
* trigger a view switch; for a foreign entry the router has already
* switched views, and this listener's render of the now-hidden view
* is harmless).
*/ */
import { fetchIsAdmin } from "./header.js"; import { fetchIsAdmin } from "./header.js";
@@ -235,7 +294,8 @@ function mkBtn(cls, label) {
} }
/* The shared description editor. `container` holds ONLY the /* The shared description editor. `container` holds ONLY the
* description UI (the row's <td>, or the level block's .kb-level-body) * description UI (the row cell's .kb-desc-cell wrapper — Phase 99
* (task 01) — or the level block's .kb-level-body)
* — the swap is a bare replaceChildren on it, so any persistent * — the swap is a bare replaceChildren on it, so any persistent
* sibling (the level's <h2>) is untouched. `getTarget()` returns the * sibling (the level's <h2>) is untouched. `getTarget()` returns the
* current { node, source, folder } at open/save time: a row's is a * current { node, source, folder } at open/save time: a row's is a
@@ -282,16 +342,25 @@ function wireDescriptionEdit({
* the pre-edit state, including the marker); neither → the empty * the pre-edit state, including the marker); neither → the empty
* text. The Edit button is available again. A cleared node empties * text. The Edit button is available again. A cleared node empties
* the text and, for the level block, hides the whole block via * the text and, for the level block, hides the whole block via
* onCleared. */ * onCleared.
* Phase 99 (task 01): the class line toggles ONLY kb-summary-pending
* (the base classes survive — the row's span keeps .kb-desc-text, the
* clamp; the level's <p> keeps none), and the CLAMPED row span's
* title is re-derived from the stored text (the D1 hover escape hatch
* — a stale pre-edit title cannot survive a save; the level's <p> is
* unclamped — it shows the full text and keeps its no-title
* behavior). */
function closeEditor(message) { function closeEditor(message) {
const target = getTarget(); const target = getTarget();
const node = target ? target.node : null; const node = target ? target.node : null;
const stored = node && typeof node.summary === "string" ? node.summary : ""; const stored = node && typeof node.summary === "string" ? node.summary : "";
const pending = node !== null && stored === "" && node.summary_pending; const pending = node !== null && stored === "" && node.summary_pending;
const value = pending && pendingText ? pendingText : stored; const value = pending && pendingText ? pendingText : stored;
textEl.className = pending ? "kb-summary-pending" : ""; textEl.classList.toggle("kb-summary-pending", pending);
if (pending) { if (pending) {
if (pendingTitle) textEl.title = pendingTitle; if (pendingTitle) textEl.title = pendingTitle;
} else if (stored && textEl.classList.contains("kb-desc-text")) {
textEl.title = stored; // D1: full text on hover — the clamp is visual only
} else { } else {
textEl.removeAttribute("title"); textEl.removeAttribute("title");
} }
@@ -387,30 +456,48 @@ function wireDescriptionEdit({
* the "Summary pending" text + the D4 title; text + color, never color * the "Summary pending" text + the D4 title; text + color, never color
* alone), and the empty cell (neither — the ls rule, unchanged). The * alone), and the empty cell (neither — the ls rule, unchanged). The
* Edit button is UNCHANGED in all three (a manual save creates the row * Edit button is UNCHANGED in all three (a manual save creates the row
* and clears the marker in place — the editor's success path). */ * and clears the marker in place — the editor's success path).
* Phase 99 (task 01, D1): the cell is ONE flex row — the <td> holds a
* single `div.kb-desc-cell` wrapper with the text span (`.kb-desc-text`
* — flexes + ellipsizes, so the row height is ONE line, independent of
* the description's length) and the Edit button (`.kb-summary-edit` —
* flex: 0 0 auto, it never wraps). The clamp is VISUAL only: the full
* text stays in the DOM and rides the span's title (the hover escape
* hatch, set for real description text whenever non-empty — the marker
* keeps its OWN D4 title, the empty cell carries none). The editor's
* open/close swaps happen INSIDE the wrapper (the container is the
* wrapper — the <td> always keeps it). */
function makeDescCell(node, source, folder, label) { function makeDescCell(node, source, folder, label) {
const td = document.createElement("td"); const td = document.createElement("td");
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
* ellipsizes, the button stays fixed (see the docstring above). */
const wrap = document.createElement("div");
wrap.className = "kb-desc-cell";
const text = document.createElement("span"); const text = document.createElement("span");
text.className = "kb-desc-text";
if (node && node.summary) { if (node && node.summary) {
text.textContent = node.summary; // stored description — text node, never innerHTML text.textContent = node.summary; // stored description — text node, never innerHTML
text.title = node.summary; // full text on hover — the clamp is visual only (D1)
} else if (node && node.summary_pending) { } else if (node && node.summary_pending) {
// Phase 98 (D4): the summary is due but not stored yet — the marker. // Phase 98 (D4): the summary is due but not stored yet — the marker
text.className = "kb-summary-pending"; // keeps its OWN title (the hover rule never overrides it).
text.classList.add("kb-summary-pending");
text.textContent = "Summary pending"; text.textContent = "Summary pending";
text.title = "No stored description yet — the next sync will generate one."; text.title = "No stored description yet — the next sync will generate one.";
} else { } else {
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule) text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule, no title)
} }
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "kb-summary-edit"; btn.className = "kb-summary-edit";
btn.textContent = "Edit"; btn.textContent = "Edit";
btn.setAttribute("aria-label", `Edit description: ${label}`); btn.setAttribute("aria-label", `Edit description: ${label}`);
td.append(text, btn); wrap.append(text, btn);
td.append(wrap);
wireDescriptionEdit({ wireDescriptionEdit({
editBtn: btn, editBtn: btn,
textEl: text, textEl: text,
container: td, container: wrap, // Phase 99 (task 01): the swaps fill the WRAPPER (the <td> keeps it)
getTarget: () => ({ node, source, folder }), getTarget: () => ({ node, source, folder }),
// Phase 98 (D4): the row's pending display (closeEditor restores // Phase 98 (D4): the row's pending display (closeEditor restores
// it on a cancel; a save clears the marker in place). // it on a cancel; a save clears the marker in place).
@@ -1097,12 +1184,32 @@ export async function mount(root) {
return span; return span;
} }
/* The drill navigation (client-side, no fetch, no URL change). */ /* The drill navigation (phase 99, task 02, D2 — client-side, no
function goTo(target) { * fetch; the drill state IS the history state). `applyTarget(target,
* push)`: when `push` is true the target DIFFERS from the current
* level (a re-click of the current row/segment pushes NO duplicate
* entry), it FIRST records a STATE-ONLY entry — history.pushState(
* { view: "rag", kb: target }, "") — the second arg "" + no third:
* the URL stays the shell's pathname (no new route, the phase-76
* deep-link surface is untouched) — then sets `current` +
* re-renders. Back/forward (popstate) and the re-show alignment
* (bor:view-refresh) adopt with push=false: they never push (the
* browser owns those entries). */
function applyTarget(target, push) {
if (push && (target.source !== current.source || target.folder !== current.folder)) {
history.pushState({ view: "rag", kb: target }, "");
}
current = { source: target.source, folder: target.folder }; current = { source: target.source, folder: target.folder };
renderLevel(); renderLevel();
} }
/* Every user-initiated drill (source row, folder link, breadcrumb
* segment) — push a history entry, then drill (D2, phase 99 task
* 02). */
function goTo(target) {
applyTarget(target, true);
}
/* ONE table for every level (phase 97): at the TOP level the rows /* ONE table for every level (phase 97): at the TOP level the rows
* are the SOURCES themselves (the ls() equivalence — name, recursive * are the SOURCES themselves (the ls() equivalence — name, recursive
* count, the stored (source, "") description). textContent only. */ * count, the stored (source, "") description). textContent only. */
@@ -1358,13 +1465,41 @@ export async function mount(root) {
return; return;
} }
if (gateEl) gateEl.hidden = true; if (gateEl) gateEl.hidden = true;
/* Phase 77 (task 02) + phase 97 (task 04): a user-initiated /* Phase 77 (task 02) + phase 97 (task 04) + phase 99 (task 02,
re-show of this already-mounted view makes the router dispatch D2): a user-initiated re-show of this already-mounted view
bor:view-refresh on the section — re-load the catalog tree then makes the router dispatch bor:view-refresh on the section —
(loadTree is race-tokened: only the newest load touches the FIRST align the drill state with the history state (a
DOM). Armed ONLY here, after the whoami gate passed: anonymous kb-carrying entry KEEPS the drill — the active-link re-click
shows the gate and must never fetch /api/docs/tree (the pushed nothing, the drilled entry is still on top; a kb-less
phase-16 soft rule the story E2E pins). */ entry — a fresh nav visit — starts at the TOP level), THEN
root.addEventListener("bor:view-refresh", () => loadTree()); re-load the catalog tree (loadTree is race-tokened: only the
newest load touches the DOM). Armed ONLY here, after the whoami
gate passed: anonymous shows the gate and must never fetch
/api/docs/tree (the phase-16 soft rule the story E2E pins). */
root.addEventListener("bor:view-refresh", () => {
const kb = history.state && history.state.kb;
if (kb) applyTarget(kb, false);
else applyTarget({ source: null, folder: null }, false);
loadTree();
});
/* Phase 99 (task 02, D2): the browser back/forward walks the
breadcrumb — a popstate ADOPTS the popped entry's kb via the
no-push variant (the browser owns its entries — adopt/reset
never push) or, for a kb-LESS entry (the boot entry, the
router's view entries, any foreign state), RESETS the drill to
the TOP level. The router's own popstate (registered earlier,
at shell boot) reads the pathname only: rag-internal entries
never change it, so its listener stays a no-op and THIS
listener's render is the visible one; for a foreign entry it
has already switched the visible view, so this render is a
harmless hidden one. Back at the top level pops the router's
view entry — the view leaves, exactly as today. Armed ONCE
here, admin branch only: the anonymous gate installs no
listeners. */
window.addEventListener("popstate", (event) => {
const kb = event.state && event.state.kb;
if (kb) applyTarget(kb, false);
else applyTarget({ source: null, folder: null }, false);
});
loadTree(); loadTree();
} }
+29 -4
View File
@@ -1982,11 +1982,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.kb-folders-table { min-width: 480px; } .kb-folders-table { min-width: 480px; }
.kb-folders-table td:nth-child(2) { font-family: var(--font); font-size: inherit; max-width: none; } .kb-folders-table td:nth-child(2) { font-family: var(--font); font-size: inherit; max-width: none; }
.kb-folders-table td:nth-child(3) { .kb-folders-table td:nth-child(3) {
white-space: normal; white-space: nowrap; /* phase 99 (D1): one line — the clamp's budget is the column width */
min-width: 18rem; min-width: 18rem;
max-width: 44rem; max-width: 44rem;
font-size: 0.88rem; font-size: 0.88rem;
} }
/* Phase 99 (task 01, D1): the Description cell is ONE flex row — the
text span flexes + ellipsizes (the row height is one line, independent
of the description's length), the Edit button stays fixed (flex: 0 0
auto — it never wraps to a second line). The gap replaces the button's
old margin-left (the context-spacing rule below keeps only the level
block's margin-top). The clamp is visual only: the full text stays in
the DOM + on the span's title (hover). */
.kb-desc-cell {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.kb-desc-text {
flex: 1 1 auto;
min-width: 0; /* lets the flex item shrink — what engages the ellipsis (the .sync-label pattern) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-link { .folder-link {
color: var(--brand-ink); /* 9.0:1 on --surface (the .doc-link pair, row variant) */ color: var(--brand-ink); /* 9.0:1 on --surface (the .doc-link pair, row variant) */
text-decoration: none; text-decoration: none;
@@ -2024,9 +2044,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
.kb-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); } .kb-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.kb-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */ .kb-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
/* Context spacing: the row cell's button sits after the description /* Context spacing: the row cell's button spacing comes from the
text; the level block's sits below the <p>. */ .kb-desc-cell gap (phase 99, task 01 — the margin-left is gone);
.kb-folders-table .kb-summary-edit { margin-left: 0.4rem; } the level block's sits below the <p>. */
.kb-level-body .kb-summary-edit { margin-top: 0.5rem; } .kb-level-body .kb-summary-edit { margin-top: 0.5rem; }
.kb-summary-editor { .kb-summary-editor {
display: block; display: block;
@@ -2041,6 +2061,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
line-height: 1.5; line-height: 1.5;
resize: vertical; resize: vertical;
} }
/* Phase 99 (task 01): the open editor fills the .kb-desc-cell flex
wrapper — as a flex item it needs the explicit grow + min-width: 0
(the width: 100% base above still serves the level block's non-flex
.kb-level-body). */
.kb-desc-cell .kb-summary-editor { flex: 1 1 auto; min-width: 0; }
.kb-summary-actions { .kb-summary-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+759
View File
@@ -0,0 +1,759 @@
"""Phase 99 task 03 E2E (Playwright, mock-only): the browser Back/Forward
buttons walk the directory breadcrumb — and a long description renders
exactly one catalog row-line.
The dedicated story suite for ``99_kb_tree_table_and_back_nav`` (owner
request, 2026-09-12): both owner-visible contracts, pinned in a browser
against the real app + the deterministic mock:
* **the back button walks the breadcrumb** (D2) — every drill
(source-row click, folder-link click, breadcrumb-segment click) is a
STATE-ONLY history entry (``history.pushState({ view: "rag",
kb: target }, "")`` — the URL stays the shell's pathname, the
phase-76 deep-link surface untouched), so ``page.go_back()`` pops
exactly one level at a time (``one/two`` → ``one`` → the source root
→ the top level), ``page.go_forward()`` re-descends the whole chain,
and Back at the top level pops the router's own view entry — the
CHAT view becomes visible (the phase-76 router contract, unchanged).
A breadcrumb jump (clicking ``Knowledge base`` from ``one/two``)
pushes a top-level entry, and Back returns to the jump's ORIGIN
(standard history semantics). A fresh nav visit to RAG (a router
entry carrying no ``kb``) starts at the top level (the D2 refresh
alignment), while a re-click of the ACTIVE RAG nav link (no
pushState — the drilled entry is still on top) KEEPS the drill.
* **the one-line clamp** (D1) — a long stored description renders ONE
line in the catalog row: the Description ``<td>``'s bounding-box
height equals a short-description row's height (± 4 px), the
``.kb-desc-text`` span's computed style is ``text-overflow:
ellipsis`` + ``white-space: nowrap``, the span's ``title`` carries
the FULL text (hover escape hatch) and the full text stays in the
DOM, the Edit button is visible IN the same cell (its bounding box
shares the cell's row — not wrapped below), and clicking the folder
shows the FULL unclamped text in the level block (``#kb-level-
summary`` — the owner's escape hatch at the top).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the clamp test's
level-block assertion keys on a MANUAL description (LLM-free), but the
seeded KB's stored descriptions at the other levels are the mock's
canned ``FOLDER_SUMMARY_MODE`` one-liner ``Fixture folder summary for
<folder>.`` (the phase-94 convention — byte-exact only against the
mock).
KB fixture — a host temp dir (``tmp_path_factory``; the app runs on the
same host) with ONE registered local source (the
``test_ls_tree_drilldown.py`` / phase-94 API-registration + real
``POST /api/sync`` pattern; no git anywhere): ``alpha/`` with the
NESTED folder chain ``one/`` → ``two/`` (2 docs each, so the mock
stores a description at every level) plus the sibling folder
``three/`` (2 docs — the short-description row the clamp height is
measured against). Total: 6 documents; the mock stores exactly four
rows (the recursive-subtree ≥ 2 rule): the source root ``""``,
``one``, the nested ``one/two``, and ``three``.
The long manual description (test 4) is set through
``PATCH /api/folders/summary`` with the admin cookie (the phase-97
endpoint — pure DB write, no LLM call) BEFORE the browser visit, so
the view's first tree fetch already carries it.
Autouse cleanup (the phase-96 pattern, module-scoped — the KB is
module-scoped: all four tests drill the same tree): after the module's
tests, the suite removes its OWN rows — this temp source's KB rows
(documents + their chunks), its ``folder_summaries`` rows, and its
``git_sources`` registration — leaving the shared E2E Postgres clean
(the deterministic start is the fresh TRUNCATE in ``seeded_kb``).
Test → observable mapping (Playwright Mapping Rule):
1. ``test_back_button_walks_the_breadcrumb`` — the full chain: chat
(``/``) → RAG nav link (the router's pushState entry) → source row
(push #1) → ``one`` (push #2) → ``two`` (push #3); the breadcrumb
renders ``Knowledge base / alpha / one / two`` (last segment
``aria-current="page"``). Then one level per press: Back → ``one``
(title ``alpha/one``, ``two`` listed as a subfolder, current
segment ``one``), Back → the source root (title ``alpha``, current
segment ``alpha``), Back → the TOP level (the source row,
breadcrumb hidden — the URL is STILL ``/sources.html``: state-only
entries never change it), Back → the CHAT view visible + the RAG
view hidden (Back at the top leaves the view — the router
contract). Forward replays the whole chain: top → ``alpha`` →
``one`` → ``two``, the current segment correct at each step.
2. ``test_breadcrumb_jump_then_back`` — at ``one/two``: clicking the
``Knowledge base`` breadcrumb segment is a JUMP (it pushes a
top-level entry) → the top level renders; Back → ``one/two`` again
(the jump's origin — standard history semantics, pinned).
3. ``test_fresh_nav_visit_starts_at_the_top`` — drill to ``one/two``;
leave via the Tuning nav link; click the RAG nav link again (a
FRESH router entry, no ``kb`` state) → the RAG view shows the TOP
level after the refresh re-render (the D2 alignment). Then drill to
``one`` and re-click the ACTIVE RAG link (no pushState —
``history.length`` unchanged) → the refresh re-render KEEPS the
drill (still at ``one`` — the active re-click contract).
4. ``test_description_cell_clamps_to_one_line`` — a 400+ char manual
description on ``one`` (via the admin API): at the source level the
``one`` row's Description ``<td>`` bounding-box height equals the
``three`` row's (± 4 px — both one line, the row height is
independent of the description's length), the ``.kb-desc-text``
span computes to ``text-overflow: ellipsis`` + ``white-space:
nowrap``, its ``title`` attribute carries the FULL text, the full
text is in the DOM, and the Edit button is visible IN the same cell
(its box shares the cell's row — vertically centered with the text,
not wrapped below). Clicking ``one`` → the level block's
``#kb-level-summary`` textContent is the FULL long text (unclamped
at the top — the owner's escape hatch).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.config import Settings as _Settings
from app.db import SessionLocal
from app.models import FolderSummary
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_KBTRENAV", "8144"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# --------------------------------------------------------------------------
# Fixture constants (deterministic, token-controlled)
# --------------------------------------------------------------------------
ALPHA = "alpha"
ONE_A = "one/one-a.md"
ONE_B = "one/one-b.md"
TWO_A = "one/two/two-a.md"
TWO_B = "one/two/two-b.md"
THREE_A = "three/three-a.md"
THREE_B = "three/three-b.md"
TOTAL_DOCS = 6 # 2 one/ + 2 one/two/ + 2 three/ (no root-level files)
#: The sync-time folder descriptions the mock's canned
#: ``FOLDER_SUMMARY_MODE`` branch stores (the phase-94 byte-stable
#: template — the one-liner names the folder), in ``(source,
#: folder_path)`` order: one row per ≥ 2-doc folder (the
#: recursive-subtree rule — ``one/two`` is a distinct nested folder) —
#: the ``""`` row is the source root.
SUMMARY_FOR = "Fixture folder summary for {}."
ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA)
ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one")
TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/one/two")
THREE_SUM = SUMMARY_FOR.format(f"{ALPHA}/three")
EXPECTED_SUMMARIES: list[tuple[str, str, str]] = [
(ALPHA, "", ALPHA_ROOT_SUM),
(ALPHA, "one", ONE_SUM),
(ALPHA, "one/two", TWO_SUM),
(ALPHA, "three", THREE_SUM),
]
assert [
(source, folder) for source, folder, _s in EXPECTED_SUMMARIES
] == sorted((source, folder) for source, folder, _s in EXPECTED_SUMMARIES)
#: The LONG manual description test 4 sets on ``one`` (via
#: ``PATCH /api/folders/summary``): ≥ 300 chars — at the column's
#: clamped width (0.88rem text, ≤ 44rem) that is comfortably ≥ 4
#: WRAPPED lines' worth without the clamp (the owner's "rows grow way
#: too much in height" — the pre-fix failure mode) — single line (no
#: newlines: the API stores it as one text node) and distinctive
#: (no part of it occurs in the fixture or the canned template).
LONG_DESC = (
"Alpha one is the deep fixture folder of this navigation suite: it "
"holds the one-a and one-b fixture notes plus the nested two folder "
"with its two-a and two-b notes, and every one of those documents "
"exists to prove the point of this very cell, namely that no matter "
"how many words the owner writes into a catalog description, the "
"row stays a single clamped line with an ellipsis instead of "
"growing to four wrapped lines and stretching the whole table. "
"(RESE-KBTRENAV-01)"
)
assert len(LONG_DESC) >= 300 and "\n" not in LONG_DESC
def _md(title: str, body: str) -> str:
return f"# {title}\n\n{body}\n"
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
@pytest.fixture(scope="module")
def nav_dirs(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The ONE-source temp tree (see the module docstring): the app
server runs on the same host, so the path is visible to it. The
directory NAME is the source name (``kind=local`` → the
directory's basename, phase 38)."""
root = tmp_path_factory.mktemp("bor_kb_tree_nav")
alpha = root / ALPHA
(alpha / "one" / "two").mkdir(parents=True)
(alpha / "three").mkdir(parents=True)
(alpha / ONE_A).write_text(
_md("Alpha One A", "Alpha one fixture note A: covers topic A of "
"the alpha source tree."),
encoding="utf-8",
)
(alpha / ONE_B).write_text(
_md("Alpha One B", "Alpha one fixture note B: covers topic B of "
"the alpha source tree."),
encoding="utf-8",
)
(alpha / TWO_A).write_text(
_md("Alpha Two A", "Alpha one/two fixture note A: covers topic A "
"of the nested folder."),
encoding="utf-8",
)
(alpha / TWO_B).write_text(
_md("Alpha Two B", "Alpha one/two fixture note B: covers topic B "
"of the nested folder."),
encoding="utf-8",
)
(alpha / THREE_A).write_text(
_md("Alpha Three A", "Alpha three fixture note A: covers topic A "
"of the alpha source tree."),
encoding="utf-8",
)
(alpha / THREE_B).write_text(
_md("Alpha Three B", "Alpha three fixture note B: covers topic B "
"of the alpha source tree."),
encoding="utf-8",
)
assert (alpha / TWO_B).is_file() and (alpha / THREE_B).is_file()
return alpha
@pytest.fixture(scope="module")
def app_server(mock_llm: int, nav_dirs: Path) -> Iterator[str]:
"""The real app under test — per-module app (the conftest pattern,
cf. ``test_ls_tree_drilldown.py`` / ``test_kb_tree.py``): NO
``BOR_GIT_SOURCES`` (the env fallback is git-only — the source here
is a DB-registered local directory), the mock LLM, the
mock-calibrated threshold, and the leak-guarded code defaults. The
session app is never started in this isolated run, so no port
clash. ``nav_dirs`` is a dependency only for the fixture ordering
(the temp tree exists before the app boots — the sync reads it)."""
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"
)
# Mock-calibrated threshold (conftest pattern): this suite never
# asks the chat model anything — the gate is never on a path.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
# Phase 67: instant retry waits + the code-default budget (the
# conftest leak-guard pattern).
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
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
# reads it from cwd) — override it with an EMPTY value (the env var
# beats the .env file): the registry must hold EXACTLY the one
# local directory this suite registers (a leftover env git list
# would pollute the top-level rows the whole story asserts on).
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under test.
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
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
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
share one Postgres, so a leftover git_sources row would pollute the
top-level rows and a leftover document would show up in the level
file tables and the stat cards the suite asserts on exactly."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the run
reaches a terminal state (the test_local_directory_sources pattern,
over plain httpx — this fixture has no browser page yet)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
return body
time.sleep(0.5)
raise AssertionError(f"sync did not reach a terminal state: {body}")
@pytest.fixture(scope="module")
def seeded_kb(app_server: str, nav_dirs: Path) -> None:
"""The story's precondition: the nested-folder KB synced under the
deterministic mock.
Registers the temp directory through the authenticated API (the
``test_local_directory_sources.py`` / phase-94 pattern), runs the
REAL in-process sync (``POST /api/sync`` — walk → chunk → embed →
overview → folder summaries → version bump), and pins the stored
folder descriptions: the mock's canned ``FOLDER_SUMMARY_MODE``
branch (phase 94) makes the sync store one deterministic row per
≥ 2-doc folder — the level-block assertions below key on that
exact text at every level of the chain.
"""
alpha = nav_dirs
_truncate_all()
with httpx.Client(base_url=app_server, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.post(
"/api/git-sources", json={"kind": "local", "path": str(alpha)}
)
assert r.status_code == 201, r.text
r = client.post("/api/sync")
assert r.status_code == 202, r.text
body = _wait_sync_done_http(client)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == TOTAL_DOCS, detail
assert detail["pruned"] == 0, detail
assert detail["overview"] is True, detail
# The change-gated folder descriptions (phase 94) landed: one row
# per ≥ 2-doc folder (the nested ``one/two`` included), the mock's
# byte-stable text — and every row AI-written (the phase-97
# ``manually_edited`` flag starts false).
with SessionLocal() as db:
rows = db.execute(
select(
FolderSummary.source,
FolderSummary.folder_path,
FolderSummary.summary,
FolderSummary.manually_edited,
).order_by(FolderSummary.source, FolderSummary.folder_path)
).all()
assert [
(source, folder, summary, False)
for source, folder, summary in EXPECTED_SUMMARIES
] == [(s, f, t, m) for s, f, t, m in rows], rows
@pytest.fixture(autouse=True, scope="module")
def _clean(nav_dirs: Path, seeded_kb: None) -> Iterator[None]:
"""Autouse cleanup (the phase-96 pattern, module-scoped — the KB is
module-scoped: all four tests drill the same tree): AFTER the
module's tests, remove this suite's OWN rows — the temp source's
KB rows (documents + their chunks), its ``folder_summaries`` rows
(test 4's manual description included), and its ``git_sources``
registration (the local row's ``url`` column holds the expanded
path) — leaving the shared E2E Postgres the way the house
isolation pattern found it. The deterministic START is the fresh
TRUNCATE in ``seeded_kb``."""
yield
with SessionLocal() as db:
db.execute(
text(
"DELETE FROM chunks WHERE document_id IN "
"(SELECT id FROM documents WHERE source = :s)"
),
{"s": ALPHA},
)
db.execute(text("DELETE FROM documents WHERE source = :s"), {"s": ALPHA})
db.execute(text("DELETE FROM folder_summaries WHERE source = :s"), {"s": ALPHA})
db.execute(text("DELETE FROM git_sources WHERE url = :p"), {"p": str(nav_dirs)})
db.execute(text("TRUNCATE query_log"))
db.commit()
# --------------------------------------------------------------------------
# Page helpers
# --------------------------------------------------------------------------
def _wait_top_level(page: Page) -> None:
"""The TOP level has rendered: the source rows (this suite: exactly
one — ``alpha``), the breadcrumb + the level block hidden, the file
table empty (files are per-source — always hidden at the top), and
the stat cards' document total (the tree walk)."""
expect(page.locator("#folders-tbody tr")).to_have_count(1, timeout=30_000)
expect(page.locator("#kb-crumb")).to_be_hidden()
expect(page.locator("#kb-level")).to_be_hidden()
expect(page.locator("#docs-tbody tr")).to_have_count(0)
expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS))
def _drill(page: Page, *names: str) -> None:
"""Drill one level at a time (client-side — no fetch, NO URL
change): each name is the EXACT text of the source/folder link at
the current level (the row builders' link text: the source name, or
the folder's last path segment). Phase 99 (D2): every drill click
is a STATE-ONLY history entry (``goTo`` → ``applyTarget(target,
true)``)."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _assert_crumb(page: Page, links: list[str], current: str) -> None:
"""The breadcrumb (``#kb-crumb``) renders the drilled chain: one
``.kb-crumb-link`` per ancestor (in order — the first is always the
``Knowledge base`` jump-to-top segment), then the last segment as
the ``.kb-crumb-current`` span with ``aria-current="page"``."""
expect(page.locator("#kb-crumb")).to_be_visible()
texts = page.eval_on_selector_all(
"#kb-crumb :is(.kb-crumb-link, .kb-crumb-current)",
"(els) => els.map((el) => el.textContent)",
)
assert texts == [*links, current], texts
cur = page.locator("#kb-crumb .kb-crumb-current")
expect(cur).to_have_count(1)
expect(cur).to_have_attribute("aria-current", "page")
def _assert_level(page: Page, title: str, summary: str) -> None:
"""The level block (``#kb-level``) shows the current directory's
stored description: the full source-relative path as the title, the
description as the text (UNCLAMPED at the top — the D1 escape
hatch)."""
expect(page.locator("#kb-level")).to_be_visible()
expect(page.locator("#kb-level-title")).to_have_text(title)
expect(page.locator("#kb-level-summary")).to_have_text(summary)
def _open_rag_from_chat(page: Page, app_url: str) -> None:
"""The phase-76 router entry: from the chat view (``/``) click the
RAG nav link — a client-side view switch (pushState ``{ view:
"rag" }`` — NO ``kb``) that mounts the RAG view on its first show
and lands on the top level."""
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible()
expect(page).to_have_url(app_url + "/sources.html")
_wait_top_level(page)
def _drill_to_two(page: Page) -> None:
"""The full drill chain, asserted at every level (the chain every
test in this suite starts with): source row → ``one`` → ``two`` —
the level block + the breadcrumb's exact segment chain at each
step (the last segment ``aria-current``)."""
page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")')
_assert_level(page, ALPHA, ALPHA_ROOT_SUM)
_assert_crumb(page, ["Knowledge base"], ALPHA)
expect(page.locator("#folders-tbody a.folder-link")).to_have_count(2)
page.click('#folders-tbody a.folder-link:text-is("one")')
_assert_level(page, f"{ALPHA}/one", ONE_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA], "one")
expect(page.locator('#folders-tbody a.folder-link:text-is("two")')).to_be_visible()
page.click('#folders-tbody a.folder-link:text-is("two")')
_assert_level(page, f"{ALPHA}/one/two", TWO_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two")
expect(page.locator("#folders-wrap")).to_be_hidden() # a leaf folder: no subfolders
# --------------------------------------------------------------------------
# 1. The back button walks the breadcrumb — one level per press, the
# router contract at the top, the whole chain replays on Forward
# --------------------------------------------------------------------------
def test_back_button_walks_the_breadcrumb(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_open_rag_from_chat(page, app_url)
# The full drill chain (three STATE-ONLY pushes — the URL is
# /sources.html at every level; the phase-76 deep-link surface is
# untouched).
_drill_to_two(page)
expect(page).to_have_url(app_url + "/sources.html")
# --- Back: one level per press --------------------------------------
# popstate adopts the popped entry's kb (no push) — the level is
# `one`: the title, `two` listed as a subfolder, the breadcrumb's
# current segment = `one`.
page.go_back()
expect(page.locator("#view-rag")).to_be_visible()
expect(page).to_have_url(app_url + "/sources.html") # state-only: no URL change
_assert_level(page, f"{ALPHA}/one", ONE_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA], "one")
expect(page.locator('#folders-tbody a.folder-link:text-is("two")')).to_be_visible()
# Back: the source root — title `alpha`, current segment the source.
page.go_back()
_assert_level(page, ALPHA, ALPHA_ROOT_SUM)
_assert_crumb(page, ["Knowledge base"], ALPHA)
expect(page.locator("#folders-tbody a.folder-link")).to_have_count(2)
# Back: the TOP level — the source row, the breadcrumb + level
# block hidden. The URL is STILL the RAG path (the entry popped is
# the router's view entry — same-document, state-only pushes never
# changed the pathname).
page.go_back()
expect(page).to_have_url(app_url + "/sources.html")
_wait_top_level(page)
# Back at the top level: the router's OWN popstate (pathname `/`)
# switches the view — the CHAT view is visible, the RAG view hidden
# (Back at the top leaves the view, exactly as pre-phase-99).
page.go_back()
expect(page.locator("#view-chat")).to_be_visible()
expect(page.locator("#view-rag")).to_be_hidden()
expect(page).to_have_url(app_url + "/")
# --- Forward: the whole chain replays -------------------------------
# Forward: the router's view entry — the RAG view re-shows on the
# top level (the kb-less entry — the D2 alignment, via the
# refresh re-render).
page.go_forward()
expect(page.locator("#view-rag")).to_be_visible()
_wait_top_level(page)
# Forward: the source root.
page.go_forward()
_assert_level(page, ALPHA, ALPHA_ROOT_SUM)
_assert_crumb(page, ["Knowledge base"], ALPHA)
# Forward: `one`.
page.go_forward()
_assert_level(page, f"{ALPHA}/one", ONE_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA], "one")
# Forward: `two` — the chain's end again, current segment correct.
page.go_forward()
_assert_level(page, f"{ALPHA}/one/two", TWO_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two")
# --------------------------------------------------------------------------
# 2. A breadcrumb jump pushes an entry — Back returns to the jump's
# origin (standard history semantics)
# --------------------------------------------------------------------------
def test_breadcrumb_jump_then_back(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_open_rag_from_chat(page, app_url)
_drill_to_two(page)
# The jump: the `Knowledge base` breadcrumb segment (a real drill
# target — goTo pushes a top-level entry).
page.click('#kb-crumb a.kb-crumb-link:text-is("Knowledge base")')
expect(page).to_have_url(app_url + "/sources.html") # state-only: no URL change
_wait_top_level(page)
# Back: the jump's ORIGIN — `one/two` again (standard history
# semantics: the jump was an entry, not a reset).
page.go_back()
_assert_level(page, f"{ALPHA}/one/two", TWO_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two")
# --------------------------------------------------------------------------
# 3. The D2 alignment: a fresh nav visit starts at the top; an
# active-link re-click keeps the drill
# --------------------------------------------------------------------------
def test_fresh_nav_visit_starts_at_the_top(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_open_rag_from_chat(page, app_url)
_drill_to_two(page)
# Leave via the Tuning nav link (the router's view entry).
page.click("#nav-tuning")
expect(page.locator("#view-tuning")).to_be_visible()
expect(page.locator("#view-rag")).to_be_hidden()
expect(page).to_have_url(app_url + "/tuning.html")
# A FRESH nav visit to RAG: the router's entry carries no `kb`
# state — the D2 alignment resets the drill to the TOP level (after
# the refresh re-render).
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible()
expect(page).to_have_url(app_url + "/sources.html")
_wait_top_level(page)
# Drill to `one`…
page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")')
page.click('#folders-tbody a.folder-link:text-is("one")')
_assert_level(page, f"{ALPHA}/one", ONE_SUM)
# …and re-click the ACTIVE RAG link: NO pushState (the router's
# re-click contract — `history.length` unchanged), the drilled
# entry is still on top, and the refresh alignment KEEPS the drill.
length_before = page.evaluate("() => history.length")
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible()
assert page.evaluate("() => history.length") == length_before # no pushState
_assert_level(page, f"{ALPHA}/one", ONE_SUM)
_assert_crumb(page, ["Knowledge base", ALPHA], "one")
expect(page).to_have_url(app_url + "/sources.html")
# --------------------------------------------------------------------------
# 4. The measured one-line clamp: row height independent of the
# description's length, ellipsis + hover title, Edit in-cell, the
# full text at the top
# --------------------------------------------------------------------------
def test_description_cell_clamps_to_one_line(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# The long manual description on `one` (the phase-97 endpoint — a
# pure DB write, the admin cookie via the real /api/login), set
# BEFORE the browser visit: the view's first tree fetch already
# carries it (manually_edited — the sync's generator would have
# skipped it).
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.patch(
"/api/folders/summary",
json={"source": ALPHA, "folder_path": "one", "summary": LONG_DESC},
)
assert r.status_code == 200, r.text
assert r.json()["summary"] == LONG_DESC, r.json()
login(page, app_url, next="/sources.html")
_wait_top_level(page)
# The source level: the folder rows `one` (the long manual
# description) + `three` (the short canned one).
page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")')
_assert_level(page, ALPHA, ALPHA_ROOT_SUM)
one_row = page.locator(
"#folders-tbody tr", has=page.locator('a.folder-link:text-is("one")')
)
three_row = page.locator(
"#folders-tbody tr", has=page.locator('a.folder-link:text-is("three")')
)
expect(one_row).to_have_count(1)
expect(three_row).to_have_count(1)
# The measured clamp: the long-description row's Description <td>
# is ONE line — its bounding-box height equals the short row's (±
# 4 px). Without the clamp, ≥ 4 wrapped lines would stretch it.
one_desc = one_row.locator("td:nth-child(3)")
three_desc = three_row.locator("td:nth-child(3)")
one_box = one_desc.bounding_box()
three_box = three_desc.bounding_box()
assert one_box is not None and three_box is not None
assert abs(one_box["height"] - three_box["height"]) <= 4, (one_box, three_box)
# The span: the full text stays in the DOM (the accessible name is
# unchanged — the ellipsis is CSS-only) and rides the `title` (the
# hover escape hatch, D1); the computed style is the ellipsis
# triad's visible half.
span = one_row.locator("span.kb-desc-text")
expect(span).to_have_text(LONG_DESC)
expect(span).to_have_attribute("title", LONG_DESC)
style = span.evaluate(
"""(el) => {
const s = getComputedStyle(el);
return { textOverflow: s.textOverflow, whiteSpace: s.whiteSpace };
}"""
)
assert style["textOverflow"] == "ellipsis", style
assert style["whiteSpace"] == "nowrap", style
# The Edit button stays IN the same cell (D1 — the owner's "nice
# touch"): visible, its bounding box inside the cell's, sharing the
# cell's row (vertically centered with the text — NOT wrapped
# below).
btn = one_row.locator("button.kb-summary-edit")
expect(btn).to_be_visible()
btn_box = btn.bounding_box()
span_box = span.bounding_box()
assert btn_box is not None and span_box is not None
assert btn_box["y"] >= one_box["y"] - 1, (btn_box, one_box)
assert btn_box["y"] + btn_box["height"] <= one_box["y"] + one_box["height"] + 1
assert btn_box["x"] >= one_box["x"] - 1, (btn_box, one_box)
assert btn_box["x"] + btn_box["width"] <= one_box["x"] + one_box["width"] + 1
assert (
abs(
(span_box["y"] + span_box["height"] / 2)
- (btn_box["y"] + btn_box["height"] / 2)
)
<= 4
), (span_box, btn_box)
# The escape hatch at the top: clicking `one` shows the FULL long
# text in the level block (unclamped — `.kb-level p` is untouched).
one_row.locator("a.folder-link").click()
_assert_level(page, f"{ALPHA}/one", LONG_DESC)
+6 -3
View File
@@ -865,7 +865,9 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
src_row = page.locator("#folders-tbody tr") src_row = page.locator("#folders-tbody tr")
s_span = src_row.locator("td:nth-child(3) span") s_span = src_row.locator("td:nth-child(3) span")
expect(s_span).to_have_text(PENDING_COPY) expect(s_span).to_have_text(PENDING_COPY)
expect(s_span).to_have_class("kb-summary-pending") # Phase 99 (task 01): the marker toggles onto the cell's base
# .kb-desc-text span (the one-line clamp) — the class pair, exact.
expect(s_span).to_have_class("kb-desc-text kb-summary-pending")
expect(s_span).to_have_attribute("title", PENDING_TITLE) expect(s_span).to_have_attribute("title", PENDING_TITLE)
# The Edit button is KEPT with the marker (a manual save creates # The Edit button is KEPT with the marker (a manual save creates
# the row and clears the marker in place). # the row and clears the marker in place).
@@ -888,10 +890,11 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
expect(alpha_row.locator("td:nth-child(3) span")).not_to_have_class( expect(alpha_row.locator("td:nth-child(3) span")).not_to_have_class(
"kb-summary-pending" "kb-summary-pending"
) )
# The affected folder: the marker (copy + class + title). # The affected folder: the marker (copy + class + title) — the
# class pair with the phase-99 .kb-desc-text base span, exact.
b_span = bravo_row.locator("td:nth-child(3) span") b_span = bravo_row.locator("td:nth-child(3) span")
expect(b_span).to_have_text(PENDING_COPY) expect(b_span).to_have_text(PENDING_COPY)
expect(b_span).to_have_class("kb-summary-pending") expect(b_span).to_have_class("kb-desc-text kb-summary-pending")
expect(b_span).to_have_attribute("title", PENDING_TITLE) expect(b_span).to_have_attribute("title", PENDING_TITLE)
# Drill into the affected folder: ITS level block shows the D4 # Drill into the affected folder: ITS level block shows the D4
+18 -7
View File
@@ -548,23 +548,34 @@ def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) ->
"""Shared shape of the task-02 pin: the view module listens for """Shared shape of the task-02 pin: the view module listens for
``bor:view-refresh`` on its own root, the listener re-runs the ``bor:view-refresh`` on its own root, the listener re-runs the
view's existing load, and the listener is armed ONLY in the ADMIN view's existing load, and the listener is armed ONLY in the ADMIN
branch — after the whoami gate (anonymous never fetches).""" branch — after the whoami gate (anonymous never fetches). The
body is sliced to the listener's close: the block-arrow forms
(phase 99 task 02: the rag view's history-alignment step runs
before the load) close with ``});``, the inline-arrow forms close
with ``());`` (the slice then runs to the NEXT ``});`` in the file
— or to its end — harmless, the inline call sits at the slice's
start)."""
listener = js.find('addEventListener("bor:view-refresh"') listener = js.find('addEventListener("bor:view-refresh"')
assert listener != -1, f"{name} must listen for the refresh event on the view root" assert listener != -1, f"{name} must listen for the refresh event on the view root"
gate_i = js.find(gate) gate_i = js.find(gate)
assert 0 <= gate_i < listener, ( assert 0 <= gate_i < listener, (
f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})" f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})"
) )
assert listener_call in js[listener : listener + 120], ( end = js.find("});", listener)
body = js[listener:] if end == -1 else js[listener : end + 3]
assert listener_call in body, (
f"{name}: the listener must re-run the view's load ({listener_call!r})" f"{name}: the listener must re-run the view's load ({listener_call!r})"
) )
def test_rag_view_refetches_on_reshow() -> None: def test_rag_view_refetches_on_reshow() -> None:
"""Phase 77 task 02 (+ phase 97 task 04): the RAG (knowledge base) """Phase 77 task 02 (+ phase 97 task 04, + phase 99 task 02):
view re-fetches on a user-initiated re-show — sources.js listens the RAG (knowledge base) view re-fetches on a user-initiated
and re-runs ``loadTree()`` (the phase-97 catalog load: ONE fetch of re-show — sources.js listens and re-runs ``loadTree()`` (the
``GET /api/docs/tree``). The load is race-tokened (phase 79) and phase-97 catalog load: ONE fetch of ``GET /api/docs/tree``), now
AFTER the phase-99 history alignment (adopt the entry's kb / reset
to the top — a block-arrow listener, hence the ``});`` close in
the pin helper below). The load is race-tokened (phase 79) and
the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers
at the TOP before filling them (the History pattern from task 01, at the TOP before filling them (the History pattern from task 01,
extended to the folders table) — so a refresh from a populated extended to the folders table) — so a refresh from a populated
@@ -572,7 +583,7 @@ def test_rag_view_refetches_on_reshow() -> None:
of leaving ghost rows.""" of leaving ghost rows."""
js = _asset("sources.js") js = _asset("sources.js")
_pin_refresh_listener( _pin_refresh_listener(
js, "const admin = await fetchIsAdmin();", "() => loadTree()", "sources.js" js, "const admin = await fetchIsAdmin();", "loadTree()", "sources.js"
) )
load = js.find("async function loadTree()") load = js.find("async function loadTree()")
assert load != -1, "loadTree must exist" assert load != -1, "loadTree must exist"
+326 -20
View File
@@ -357,16 +357,40 @@ def test_breadcrumb_links_go_up_and_the_top_reset() -> None:
assert "span.textContent = label" in cur assert "span.textContent = label" in cur
def test_navigation_is_client_side_only() -> None: def test_drill_navigation_pushes_state_only_entries() -> None:
"""goTo() is the ONLY navigation: set `current`, re-render. No """Phase 99 (task 02, D2): every user-initiated drill is a history
fetch (the tree is already in memory), no URL change (history is entry — ``goTo`` is the push variant of ``applyTarget``: when the
untouched — the deep-link contract of the shell is unchanged).""" target DIFFERS from the current level (a re-click of the current
row/segment pushes NO duplicate entry), it FIRST records a
STATE-ONLY entry — ``history.pushState({ view: "rag", kb: target },
"")`` — the second arg is ``""`` and there is no third (the URL
stays the shell's pathname: no new route, the phase-76 deep-link
surface is untouched) — then sets ``current`` + re-renders. The
drill still never fetches (the tree is in memory) and the module
never ``replaceState``s."""
js = _js() js = _js()
body = js[js.find("function goTo(") : js.find("function makeSourceRow(")] start = js.find("function applyTarget(")
assert "current = { source: target.source, folder: target.folder };" in body assert start != -1, "applyTarget must exist (the two-step drill)"
assert "renderLevel();" in body body = js[start : js.find("function makeSourceRow(")]
gate_i = body.find(
"if (push && (target.source !== current.source || target.folder !== current.folder)) {"
)
push_i = body.find('history.pushState({ view: "rag", kb: target }, "")')
set_i = body.find("current = { source: target.source, folder: target.folder };")
render_i = body.find("renderLevel();")
assert -1 < gate_i < push_i < set_i < render_i, (
"push gate (target differs from current) → pushState → set current → render"
)
# STATE-ONLY: exactly ONE pushState call site — the "" second arg,
# no third (no URL change).
assert _code(body).count("pushState") == 1, (
"ONE pushState call site — the state-only drill entry"
)
assert "replaceState" not in _code(js), "no replaceState anywhere in the module"
# goTo is the push variant — every drill call site keeps calling it.
go = body[body.find("function goTo(") :]
assert "applyTarget(target, true)" in go, "goTo pushes (the user-initiated drill)"
assert "fetch(" not in body, "the drill never fetches (client-side only)" assert "fetch(" not in body, "the drill never fetches (client-side only)"
assert "pushState" not in body and "replaceState" not in body, "no URL change"
# ---------- the level rendering ---------- # ---------- the level rendering ----------
@@ -577,16 +601,30 @@ def test_load_tree_wired_at_exactly_the_three_refresh_points_plus_boot() -> None
assert _code(js).count("loadTree()") == 5, ( assert _code(js).count("loadTree()") == 5, (
"the definition + the 4 call sites, no others (code, not comments)" "the definition + the 4 call sites, no others (code, not comments)"
) )
# 1. the view-refresh listener, armed in the admin branch. # 1. the view-refresh listener, armed in the admin branch — since
# Phase 99 (task 02, D2) its body is the ALIGNMENT step (a
# kb-carrying history.state entry keeps the drill — the
# active-link re-click; a kb-less entry starts at the top — a
# fresh nav visit) BEFORE the re-fetch.
listener = js.find('addEventListener("bor:view-refresh"') listener = js.find('addEventListener("bor:view-refresh"')
assert listener != -1 assert listener != -1
gate = js.find("const admin = await fetchIsAdmin();") gate = js.find("const admin = await fetchIsAdmin();")
assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)" assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)"
assert "() => loadTree()" in js[listener : listener + 120] listener_body = js[listener : js.find('window.addEventListener("popstate"')]
state_i = listener_body.find("history.state && history.state.kb")
adopt_i = listener_body.find("applyTarget(kb, false)")
reset_i = listener_body.find("applyTarget({ source: null, folder: null }, false)")
load_i = listener_body.find("loadTree();")
assert -1 < state_i < adopt_i < reset_i < load_i, (
"align with history.state (adopt kb / reset to top) BEFORE the re-fetch"
)
assert "pushState" not in _code(listener_body), ("the alignment adopts only — no push")
# 2. the boot load — the last statement of mount, right after the # 2. the boot load — the last statement of mount, right after the
# listener is armed (the mount's own load is the first fetch). # listeners are armed (the mount's own load is the first fetch).
assert 'root.addEventListener("bor:view-refresh", () => loadTree());\n loadTree();' in js, ( pop = js.find('window.addEventListener("popstate"')
"the boot load follows the listener" boot = js[js.find("});", pop) + 3 :] # past the popstate listener's close
assert boot.strip().startswith("loadTree();"), (
"the boot load follows the listeners (no pushState on boot)"
) )
# 3. applySyncSuccess (the sync's terminal — the KB just changed). # 3. applySyncSuccess (the sync's terminal — the KB just changed).
success = js[js.find("function applySyncSuccess(") : js.find("function applySyncFailure(")] success = js[js.find("function applySyncSuccess(") : js.find("function applySyncFailure(")]
@@ -746,13 +784,16 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None:
the text gates on the node state, the button never does). The the text gates on the node state, the button never does). The
button is a real type=button with a human aria-label, and the button is a real type=button with a human aria-label, and the
shared editor is wired with a CONSTANT target { node, source, shared editor is wired with a CONSTANT target { node, source,
folder }.""" folder }. Phase 99 (task 01, D1): text + button live in ONE
`div.kb-desc-cell` flex wrapper (the <td> holds only the wrapper),
and the editor's container IS the wrapper (the open/close swaps
fill it — the cell layout survives the editor swap)."""
body = _fn(_js(), "makeDescCell") body = _fn(_js(), "makeDescCell")
# The Edit button is built unconditionally — the whole button # The Edit button is built unconditionally — the whole button
# block (from its creation to the append) carries no `if` gate on # block (from its creation to the append) carries no `if` gate on
# the stored description or the pending flag. # the stored description or the pending flag.
btn_block = body[ btn_block = body[
body.find("const btn = document.createElement") : body.find("td.append(text, btn)") body.find("const btn = document.createElement") : body.find("wrap.append(text, btn)")
] ]
assert "if (" not in btn_block, ( assert "if (" not in btn_block, (
"always present: no gate on the stored description" "always present: no gate on the stored description"
@@ -761,10 +802,20 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None:
assert 'btn.className = "kb-summary-edit"' in body assert 'btn.className = "kb-summary-edit"' in body
assert 'btn.textContent = "Edit"' in body assert 'btn.textContent = "Edit"' in body
assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body
assert "td.append(text, btn)" in body assert 'wrap.className = "kb-desc-cell"' in body, "the ONE flex wrapper"
assert "wrap.append(text, btn)" in body, "text + button, in order, in the wrapper"
assert "td.append(wrap)" in body, "the <td> holds the wrapper (no new td class)"
assert "container: wrap" in body, (
"the editor's container is the WRAPPER (Phase 99 — the swap fills it)"
)
assert "getTarget: () => ({ node, source, folder })" in body, ( assert "getTarget: () => ({ node, source, folder })" in body, (
"a row's target is a constant (its own node)" "a row's target is a constant (its own node)"
) )
# The level block's editor is UNTOUCHED — its container is still
# .kb-level-body (the D1 escape hatch keeps its full text).
js = _js()
wire = js[js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")]
assert "container: levelBody" in wire
def test_desc_cell_pending_marker_branch() -> None: def test_desc_cell_pending_marker_branch() -> None:
@@ -786,7 +837,12 @@ def test_desc_cell_pending_marker_branch() -> None:
assert "kb-summary-pending" not in stored, "a stored summary is NEVER the marker" assert "kb-summary-pending" not in stored, "a stored summary is NEVER the marker"
assert "Summary pending" not in stored assert "Summary pending" not in stored
pending = body[pending_i:empty_i] pending = body[pending_i:empty_i]
assert 'text.className = "kb-summary-pending"' in pending assert 'text.classList.add("kb-summary-pending")' in pending, (
"Phase 99: the marker TOGGLES onto the .kb-desc-text base class"
)
assert 'text.className = "kb-summary-pending"' not in body, (
"a bare className re-assignment would drop the clamp's base class"
)
assert 'text.textContent = "Summary pending"' in pending, "the D4 marker copy" assert 'text.textContent = "Summary pending"' in pending, "the D4 marker copy"
assert ( assert (
'text.title = "No stored description yet — the next sync will generate one."' 'text.title = "No stored description yet — the next sync will generate one."'
@@ -841,13 +897,23 @@ def test_close_editor_rederives_the_pending_display() -> None:
close = body[body.find("function closeEditor(") : body.find("function openEditor()")] close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
flag_i = close.find('const pending = node !== null && stored === "" && node.summary_pending;') flag_i = close.find('const pending = node !== null && stored === "" && node.summary_pending;')
value_i = close.find("const value = pending && pendingText ? pendingText : stored;") value_i = close.find("const value = pending && pendingText ? pendingText : stored;")
class_i = close.find('textEl.className = pending ? "kb-summary-pending" : "";') class_i = close.find('textEl.classList.toggle("kb-summary-pending", pending);')
title_i = close.find("if (pendingTitle) textEl.title = pendingTitle;") title_i = close.find("if (pendingTitle) textEl.title = pendingTitle;")
hover_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {')
full_i = close.find("textEl.title = stored;")
clear_title_i = close.find('textEl.removeAttribute("title")') clear_title_i = close.find('textEl.removeAttribute("title")')
render_i = close.find("textEl.textContent = value") render_i = close.find("textEl.textContent = value")
assert -1 < flag_i < value_i < class_i < title_i < clear_title_i < render_i, ( assert -1 < flag_i < value_i < class_i < title_i < hover_i < full_i, (
"flag → value → class → title (set/clear) → text, in order" "flag → value → class toggle → title (pending / hover-full), in order"
) )
assert full_i < clear_title_i < render_i, "title-set → title-clear → text, in order"
# Phase 99 (task 01): the class line is a TOGGLE (the base classes
# survive — the row's span keeps .kb-desc-text, the clamp; the
# level's <p> keeps none), and the CLAMPED row span's title is
# re-derived from the stored text (the D1 hover escape hatch — a
# stale pre-edit title cannot survive a save; the unclamped
# level's <p> is gated out by the kb-desc-text check).
assert 'textEl.className' not in close, "no bare className re-assignment"
# The row passes its marker copy + the EXACT D4 tooltip… # The row passes its marker copy + the EXACT D4 tooltip…
cell = _fn(_js(), "makeDescCell") cell = _fn(_js(), "makeDescCell")
assert 'pendingText: "Summary pending"' in cell assert 'pendingText: "Summary pending"' in cell
@@ -1212,3 +1278,243 @@ def test_module_docstring_documents_the_pending_markers() -> None:
in header in header
), "the level block's pending note" ), "the level block's pending note"
assert "node.summary_pending = false" in header, "the in-place clear" assert "node.summary_pending = false" in header, "the in-place clear"
# ---------- the one-line Description clamp (phase 99, task 01) ----------
# D1: the clamp is VISUAL only — the row's Description cell is ONE flex
# row (text flexes + ellipsizes, the Edit button stays fixed), the full
# text stays in the DOM + on the span's hover title, and the level
# block keeps the full unclamped description. The measured-height proof
# lands in the phase's E2E (task 03); this module pins the structure
# and the CSS.
def _css_rule(css_text: str, selector: str) -> str:
"""The declarations of the rule with exactly this selector (the
task-named classes own their base rule — no compound matches)."""
m = re.search(r"(?<![\w-])" + re.escape(selector) + r"\s*\{([^}]*)\}", css_text)
assert m, f"styles.css must define {selector}"
return m.group(1)
def test_desc_cell_hover_title_covers_real_description_text_only() -> None:
"""Phase 99 (task 01, D1): the text span (`.kb-desc-text` — the
base class, set UNCONDITIONALLY before the three-state branch) AL-
WAYS carries `title` = the FULL description text for real
(non-empty) stored text — the hover escape hatch of the visual
clamp; the phase-98 marker keeps its OWN D4 title (never over-
ridden by the hover rule); the empty cell carries no title at all.
textContent/title only — the house rule (no innerHTML)."""
body = _fn(_js(), "makeDescCell")
base_i = body.find('text.className = "kb-desc-text"')
stored_i = body.find("if (node && node.summary) {")
pending_i = body.find("else if (node && node.summary_pending) {")
empty_i = body.find("} else {")
assert -1 < base_i < stored_i, "the base clamp class is set before the state branch"
stored = body[stored_i:pending_i]
assert "text.title = node.summary;" in stored, "stored → title = the full text (hover)"
assert "Summary pending" not in stored, "the stored branch never touches the marker"
pending = body[pending_i:empty_i]
assert (
'text.title = "No stored description yet — the next sync will generate one."'
in pending
), "the marker keeps its OWN D4 title"
empty = body[empty_i : empty_i + 200]
assert "text.title" not in empty, "the empty cell carries no title"
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
assert "innerHTML" not in code, "the house rule: no innerHTML in the cell builder"
def test_close_editor_rederives_the_hover_title_from_the_stored_text() -> None:
"""Phase 99 (task 01, D1): the hover title is the escape hatch for
the CLAMPED text, so a Save/Cancel must re-derive it — a stale
pre-edit title cannot survive a save (the span is re-inserted
into the wrapper by the swap, attributes included). The re-
derivation is gated on the kb-desc-text class: only the clamped
row span gets the full-text title; the unclamped level's <p>
(full text visible) keeps its no-title behavior, and an emptied
(cleared) cell loses its title."""
body = _fn(_js(), "wireDescriptionEdit")
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
gate_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {')
set_i = close.find("textEl.title = stored;")
clear_i = close.find('textEl.removeAttribute("title")')
assert -1 < gate_i < set_i < clear_i, "gated set (stored + clamped span) → clear, in order"
assert close.count("textEl.title =") == 2, (
"exactly: pendingTitle (the marker's own) + stored (the hover full text) — nothing else"
)
def test_styles_carry_the_one_line_clamp() -> None:
"""Phase 99 (task 01, D1): the Description cell is one flex row —
.kb-desc-cell (flex + center + the 0.4rem gap that replaces the
button's old margin-left + min-width: 0) and .kb-desc-text (flex:
1 1 auto + min-width: 0 — the flex item may shrink — + the
ellipsis triad). The column itself is white-space: nowrap (was
normal — the column width is the clamp's budget; the min/max width
+ font-size stay). The button's margin-left rule is GONE, the
level block's margin-top rule is untouched, and the open editor
fills the flex wrapper (flex: 1 1 auto + min-width: 0). .kb-level
p is UNTOUCHED (the full unclamped description — D1), and the
phase-97 block carries no new hue (the phase-92 invariant)."""
css = _text(STYLES_CSS)
cell = _css_rule(css, ".kb-desc-cell")
for prop in ("display: flex", "align-items: center", "gap: 0.4rem", "min-width: 0"):
assert prop in cell, f".kb-desc-cell must carry {prop!r}"
text = _css_rule(css, ".kb-desc-text")
for prop in (
"flex: 1 1 auto",
"min-width: 0",
"overflow: hidden",
"text-overflow: ellipsis",
"white-space: nowrap",
):
assert prop in text, f".kb-desc-text must carry {prop!r}"
col_i = css.find(".kb-folders-table td:nth-child(3) {")
assert col_i != -1
col = css[col_i : col_i + 400]
col = col[: col.find("\n}")]
assert "white-space: nowrap" in col, "the column is one line (was: normal)"
assert "white-space: normal" not in col
for prop in ("min-width: 18rem", "max-width: 44rem", "font-size: 0.88rem"):
assert prop in col, f"the column keeps {prop!r} (the clamp's budget)"
assert ".kb-folders-table .kb-summary-edit" not in css, (
"the button's margin-left is gone — the wrapper's gap replaces it"
)
lvl_i = css.find(".kb-level-body .kb-summary-edit")
assert lvl_i != -1
assert "margin-top: 0.5rem" in css[lvl_i : lvl_i + 80], (
"the level block's spacing rule is untouched"
)
fill_i = css.find(".kb-desc-cell .kb-summary-editor")
assert fill_i != -1
fill = css[fill_i : fill_i + 80]
assert "flex: 1 1 auto" in fill and "min-width: 0" in fill, (
"the open editor fills the flex wrapper"
)
# The level block keeps the FULL unclamped description (D1) — its
# rule is unchanged (no white-space/ellipsis additions).
m = re.search(r"(?<![\w-])\.kb-level p\s*\{([^}]*)\}", css)
assert m, "the level block's <p> rule must exist"
assert "white-space" not in m.group(1) and "text-overflow" not in m.group(1)
block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)")
assert ".kb-desc-cell" in block and ".kb-desc-text" in block, (
"the clamp lives in the phase-97 tree region"
)
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no new hue (phase-92 invariant)"
assert not re.search(r"rgba?\(", block), "no new hue (phase-92 invariant)"
def test_module_docstring_documents_the_one_line_clamp() -> None:
"""The house per-phase module-note convention: the phase-99
task-01 section records the flex wrapper structure (text flexes +
ellipsizes, button fixed), the VISUAL-only clamp (full text in the
DOM + on the hover title), the marker's own title, and the wrapper
as the editor's container (the level block's container stays
.kb-level-body)."""
js = _js()
header = js[: js.find("import { fetchIsAdmin }")]
assert "Phase 99 (task 01, D1)" in header
assert "kb-desc-cell" in header and "kb-desc-text" in header
assert "VISUAL only" in header, "the clamp is documented as visual only"
assert "title" in header, "the hover escape hatch is documented"
assert "kb-level-body" in header, "the level block's container is documented as kept"
# ---------- the history integration (phase 99, task 02, D2) ----------
# The drill state IS the history state: every drill pushes a state-
# only entry (no URL change), Back/Forward adopt the entry's kb (or
# reset to the top), the re-show aligns before the re-fetch, and the
# anonymous gate installs no listeners. The browser proof lands in
# the phase's E2E (task 03); this module pins the wiring.
def test_popstate_adopts_the_entry_kb_or_resets_to_top() -> None:
"""Phase 99 (task 02, D2): the window popstate — armed exactly
ONCE, in the ADMIN branch (after the whoami gate, next to the
refresh listener) — ADOPTS the popped entry's ``event.state.kb``
via the no-push variant (the browser owns its entries —
adopt/reset never push), and an entry WITHOUT a kb (the boot
entry, the router's view entries, any foreign state) RESETS the
drill to the TOP level. Rendering a currently-hidden view is
harmless: the router's own popstate (registered earlier, at shell
boot) owns the view switch for foreign entries; rag-internal
entries never change the pathname, so this listener's render is
the visible one."""
js = _js()
assert js.count('window.addEventListener("popstate"') == 1, (
"armed exactly once (mount-once — the router mounts a view ONCE)"
)
pop = js.find('window.addEventListener("popstate"')
gate = js.find("const admin = await fetchIsAdmin();")
refresh = js.find('root.addEventListener("bor:view-refresh"')
assert -1 < gate < refresh < pop, (
"armed in the admin branch, after the gate, next to the refresh listener"
)
body = js[pop : js.find("});", pop)]
kb_i = body.find("event.state && event.state.kb")
adopt_i = body.find("applyTarget(kb, false)")
reset_i = body.find("applyTarget({ source: null, folder: null }, false)")
assert -1 < kb_i < adopt_i < reset_i, (
"read event.state.kb → adopt (no push) / reset to the top"
)
assert "pushState" not in _code(body), ("adopt/reset never push (the browser owns its entries)")
def test_refresh_alignment_adopts_history_state_before_the_refetch() -> None:
"""Phase 99 (task 02, D2): the bor:view-refresh listener (the
phase-77 re-show) aligns ``current`` with ``history.state``
BEFORE ``loadTree()`` — a kb-carrying top entry (the active-link
re-click pushed NOTHING — the drilled entry is still on top)
KEEPS the drill (``applyTarget(kb, false)``); a kb-less entry (a
fresh nav visit) RESETS to the top level. The alignment adopts —
it never pushes."""
js = _js()
listener = js.find('root.addEventListener("bor:view-refresh"')
assert listener != -1
body = js[listener : js.find("});", listener)]
state_i = body.find("history.state && history.state.kb")
adopt_i = body.find("applyTarget(kb, false)")
reset_i = body.find("applyTarget({ source: null, folder: null }, false)")
load_i = body.find("loadTree();")
assert -1 < state_i < adopt_i < reset_i < load_i, (
"align (adopt kb / reset to top) BEFORE the re-fetch"
)
assert "pushState" not in _code(body), "the alignment never pushes"
def test_anonymous_branch_installs_no_navigation_listeners() -> None:
"""Phase 99 (task 02): the anonymous gate installs NO listeners at
all — no popstate, no refresh (the phase-16 rule: anonymous shows
the gate, fetches nothing, and owns no drill state — both
listeners are armed only in the admin branch)."""
js = _js()
start = js.find("if (!admin) {")
end = js.find("if (gateEl) gateEl.hidden = true;")
assert -1 < start < end, "the anonymous branch must exist"
branch = js[start:end]
assert "addEventListener" not in branch, (
"the anonymous branch arms no listeners (no popstate, no refresh)"
)
def test_module_docstring_documents_the_history_integration() -> None:
"""The house per-phase module-note convention: the phase-99
task-02 section records the push/adopt/reset rules — the
STATE-ONLY push (the URL STAYS PUT, no new route — the phase-76
deep-link surface untouched), the duplicate-push skip, the
popstate adopt/reset, the refresh alignment before the re-fetch,
the no-boot-push contract, the UNCHANGED (no-push)
resetVanishedLocation, and the UNTOUCHED router module."""
js = _js()
header = js[: js.find("import { fetchIsAdmin }")]
assert "Phase 99 (task 02, D2)" in header
assert "STATE-ONLY" in header, "the state-only push is documented"
assert "STAYS PUT" in header, "the URL-stays-put note"
assert "no duplicate history" in header, "the duplicate-push skip"
assert "ADOPTS" in header and "popstate" in header, "the popstate adopt"
assert "RESETS the drill to the TOP level" in header, "the reset branch"
assert "BEFORE the re-fetch" in header, "the refresh alignment"
assert "Boot pushes NOTHING" in header, "no pushState on boot"
assert "resetVanishedLocation is UNCHANGED" in header
assert "module is UNTOUCHED" in header, "the router module is untouched"