Files
brain-of-reese/tests/unit/test_kb_tree_ui.py
T
ducoterra ad7585d474
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report:

## Phase 97 final verification pass — ALL GREEN

**Verified (no code changes needed):**
- `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css`
- `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate
- Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved

**Gates (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation
- 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview`

**Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run)

**Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
2026-09-11 22:48:02 -04:00

1015 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: the RAG view's drill-down catalog tree (phase 97, task 04).
The RAG (Knowledge base) view lists the catalog the way the agent's
``ls`` sees it (the phase-94 concept, ONE end to end): sources at the
top, then per level the subfolders (recursive count + the STORED
description) and the level's files (the existing 5-column
``#docs-table`` — unchanged). The view's catalog load is now
``loadTree()``: ONE fetch of ``GET /api/docs/tree`` (the full recursive
tree in a single payload — task 02), then client-side drill navigation
(no per-level fetch, no URL change).
This module pins the source-level contract (the house pattern of
``tests/unit/test_sync_button.py`` — read the frontend files as text,
no browser; the behavior is E2E-covered by the phase's dedicated
suite, task 06):
* the shell's RAG view ships the static tree skeleton (``#kb-crumb``,
``#kb-level`` + ``#kb-level-title``/``#kb-level-summary``,
``#folders-wrap`` + ``#folders-table`` — ONE table for every level,
the Folder | Documents | Description head) in order after
``#stat-cards`` and BEFORE the unchanged file table;
* the exact ``/api/docs/tree`` fetch (and the flat ``/api/docs`` fetch
is gone from the view module);
* the drill state (``current`` / ``kbTree``), the state transitions
(source row click, folder row click, breadcrumb up, top reset), and
the aria-current last segment;
* the textContent contract (no ``innerHTML`` with document-derived
data — the module's standing rule);
* the top-level semantics (the rows ARE the sources — the ``ls()``
equivalence — and the file table is hidden at the top);
* the level block's ls rule (hidden when no description is stored);
* the stat cards' whole-tree walk;
* the empty-state semantic (``#sources-empty`` ONLY on zero sources —
the deliberate phase-97 change: a registered 0-document source
renders its row instead);
* the never-stale reset-to-top fallback (PLAN §7.4 — a vanished
location resets ``current`` to the top BEFORE rendering);
* ``loadTree`` wired at exactly the three refresh points (view-refresh,
sync success, upload success) plus the boot load — and the
anonymous branch still never fetches the catalog.
Phase 97 (task 05) adds the FOLDER-DESCRIPTION EDITOR (the phase-57
affordance, mirrored) — the source pins this module gains for it:
* the static ``#kb-level-edit`` button in the level block (type=button,
the .kb-summary-edit class, the "Edit" label, after the description
``<p>`` — in the .kb-level-body the editor swaps inside);
* the Description cell of EVERY source/folder row (``makeDescCell``,
called from both row builders) — the stored description as a text
node + the ALWAYS-present Edit button (a description can be CREATED
where none is stored: no gate on the stored value);
* the shared editor (``wireDescriptionEdit``): the swap builds a
textarea prefilled via ``.value`` (never innerHTML — the XSS
contract), Save / Cancel, and the ``role="status"``
``aria-live="polite"`` live region;
* the exact PATCH — ``/api/folders/summary``, method PATCH, body
``{ source, folder_path, summary }`` with ``folder_path ""`` for the
source root (exactly one call site in the module);
* the outcomes — success updates the in-memory ``kbTree`` node IN
PLACE (no re-fetch) + re-renders the text via textContent + "
Description updated."; a cleared echo (summary null) empties the
text (row cell) / hides the level block (the phase-57 announcement
beat, guarded) + "Description cleared."; a failure (non-ok OR
network) keeps the editor open with neutral retry copy (phase-55);
Cancel restores the text node without a fetch;
* the double-click guard (disabled before the fetch, re-enabled in the
``finally``); the level editor's ``getTarget()`` getter + the
``reset()`` handle called on every re-render (PLAN §7.4);
* the five ``.kb-summary-*`` classes in styles.css (house palette, no
new hue in the phase-97 block).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
SOURCES_JS = ASSETS / "sources.js"
STYLES_CSS = ASSETS / "styles.css"
SHELL_HTML = FRONTEND / "index.html"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(SOURCES_JS)
def _rag_view(html: str) -> str:
"""The RAG view section of the shell (view-scoped scope — the shell
carries many views, so whole-file matches hit the wrong view)."""
i = html.find('<section class="view" id="view-rag"')
assert i != -1, "the RAG view section must be in the shell"
j = html.find('<section class="view" id="view-git-sources"', i)
assert j != -1, "the Sources view section must follow the RAG view"
return html[i:j]
def _css_block(css_text: str, start_marker: str, end_marker: str) -> str:
"""The CSS between two phase markers (comments included — the no-new
hue check wants to see that none was added in prose either)."""
i = css_text.find(start_marker)
assert i != -1, f"missing CSS marker: {start_marker!r}"
j = css_text.find(end_marker, i)
assert j != -1, f"missing CSS end marker: {end_marker!r}"
return css_text[i:j]
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async, possibly nested) function via
balanced-brace counting (module-level and mount-scoped alike). The
brace count starts AFTER the parameter list — a destructured
parameter (wireDescriptionEdit's target object) may carry braces of
its own."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
# Skip the parameter list (balanced parens).
depth = 0
i = js.find("(", start)
close = i
while i < len(js):
if js[i] == "(":
depth += 1
elif js[i] == ")":
depth -= 1
if depth == 0:
close = i
break
i += 1
# Then brace-count the body.
brace = js.find("{", close)
depth = 0
for j in range(brace, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in sources.js")
# ---------- the shell's RAG view: the static tree skeleton ----------
def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
"""The house no-JS-safe skeleton convention: the tree surfaces ship
in the static HTML (after the stat cards, before the file table)
and ship HIDDEN — assets/sources.js fills them with createElement.
``#kb-crumb`` is the location nav; ``#kb-level`` is the level block
(title + description); ``#folders-wrap`` hosts the ONE
folders/sources table (``.table-wrap`` card, Folder | Documents |
Description, visually-hidden caption, the ``.docs-table``
language + ``.kb-folders-table``)."""
view = _rag_view(_text(SHELL_HTML))
for fragment in (
'<nav id="kb-crumb" class="kb-crumb" aria-label="Catalog location" hidden></nav>',
'<section id="kb-level" class="kb-level" aria-labelledby="kb-level-title" hidden>',
'<h2 id="kb-level-title"></h2>',
'<p id="kb-level-summary"></p>',
'<div id="folders-wrap" class="table-wrap" role="region" '
'aria-label="Folders" tabindex="0" hidden>',
'<table class="docs-table kb-folders-table" id="folders-table">',
'<caption class="visually-hidden">Catalog sources and folders</caption>',
'<tbody id="folders-tbody"></tbody>',
):
assert fragment in view, f"the RAG view must ship {fragment!r}"
head = re.search(r"<thead>(.*?)</thead>", view[view.find('id="folders-table"'):], re.S)
assert head, "#folders-table must keep a static thead"
for column in (
"<th scope=\"col\">Folder</th>",
"<th scope=\"col\">Documents</th>",
"<th scope=\"col\">Description</th>",
):
assert column in head.group(1), f"#folders-table head must carry {column!r}"
# The deliberate placement: after the stat cards, before the file
# table (the file table keeps its own wrap + head, unchanged).
assert (
view.find('id="stat-cards"')
< view.find('id="kb-crumb"')
< view.find('id="kb-level"')
< view.find('id="folders-wrap"')
< view.find('id="docs-table"')
), "the skeleton must sit between the stat cards and the file table"
# The file table is UNCHANGED (the 5-column contract, makeRow's home).
doc_head = re.search(
r'<table class="docs-table" id="docs-table">.*?<thead>(.*?)</thead>', view, re.S
)
assert doc_head, "the file table must keep its static thead"
for column in ("Source", "Path", "Title", "Chunks", "Indexed"):
assert f">{column}</th>" in doc_head.group(1), f"#docs-table head must keep {column!r}"
# ---------- the tree load: the ONE fetch + the race token ----------
def test_sources_js_fetches_the_tree_endpoint_only() -> None:
"""The catalog load is `loadTree()`: ONE fetch of the tree endpoint.
The flat `GET /api/docs` fetch is GONE from the view module (the
endpoint itself stays — the API surface is untouched, task 02)."""
js = _js()
assert 'fetch("/api/docs/tree")' in js, "the load must fetch /api/docs/tree"
assert js.count('fetch("/api/docs/tree")') == 1, (
"the tree is the view's SINGLE catalog fetch (zero per-level fetches)"
)
assert 'fetch("/api/docs")' not in js, "the flat /api/docs fetch must be gone"
def test_load_tree_is_race_tokened_and_resets_before_rendering() -> None:
"""Phase 79 carries over: the monotonic loadSeq token — only the
newest load may touch the DOM after its await. On success the load
stores the tree in the module-scoped `kbTree`, runs the never-stale
reset, and renders (in that order — the reset must see the NEW
tree and happen BEFORE the render)."""
js = _js()
assert "async function loadTree()" in js, "loadTree must exist"
body = js[js.find("async function loadTree()") :]
body = body[: body.find("\n }")]
assert "const my = ++loadSeq;" in body, "the race token stays (phase 79)"
assert "if (my !== loadSeq) return;" in body, "stale loads must not touch the DOM"
store_i = body.find("kbTree = tree && Array.isArray(tree.sources) ? tree : { sources: [] };")
reset_i = body.find("resetVanishedLocation();")
render_i = body.find("renderLevel();")
assert -1 < store_i < reset_i < render_i, (
"store the tree → reset a vanished location → render (in order)"
)
# ---------- the drill state + the navigation transitions ----------
def test_drill_state_and_module_tree_are_scoped_to_the_mount() -> None:
"""`current` ({ source, folder } — null/null = the top level,
folder "" = the source root) and `kbTree` are mount-scoped module
state (the router mounts a view ONCE — the state survives every
switch, as the sync state machine does)."""
js = _js()
assert "let kbTree = { sources: [] };" in js
assert "let current = { source: null, folder: null };" in js, (
"the drill state starts at the top level"
)
def test_source_row_click_drills_into_the_source_root() -> None:
"""Top-level row = a SOURCE (the ls() equivalence): the name cell
is a `.folder-link` whose click sets `{ source, "" }` (the source
ROOT) and re-renders — client-side only (no fetch, no URL change).
The row carries the recursive count and the stored (source, "")
description — textContent only."""
js = _js()
body = js[js.find("function makeSourceRow(") : js.find("function makeSourceRow(") + 1200]
assert 'link.className = "folder-link"' in body, "the source row uses the row drill link"
assert "link.textContent = s.name" in body
assert "goTo({ source: s.name, folder: \"\" })" in body, (
"a source row drills to the source ROOT (folder \"\")"
)
assert "countTd.textContent = String(s.documents)" in body, "the recursive count"
assert 'makeDescCell(s, s.name, "", s.name)' in body, (
"the Description cell (task 05): the stored (source, \"\") "
"description + the ALWAYS-present Edit button — folder_path \"\" "
"for the source root"
)
assert "link.href = \"#\"" in body, "client-side drill — no URL change"
def test_folder_row_click_drills_into_the_folder() -> None:
"""A level's subfolder row: the folder's LAST path segment as the
label (the full source-relative path rides the title), the
recursive count, the stored description — and the click sets
`{ source, folder: f.path }` (the source-relative folder path)."""
js = _js()
start = js.find("function makeFolderRow(")
body = js[start : start + 1400]
assert 'link.className = "folder-link"' in body
assert "link.textContent = f.path.split(\"/\").pop()" in body, (
"the label is the last segment (the breadcrumb language)"
)
assert "link.title = current.source + \"/\" + f.path" in body, "full path on hover"
assert "goTo({ source: current.source, folder: f.path })" in body, (
"a folder row drills to that folder"
)
assert "countTd.textContent = String(f.documents)" in body
assert (
'makeDescCell(f, current.source, f.path, current.source + "/" + f.path)'
in body
), "the Description cell (task 05): the stored row + the ALWAYS-present Edit"
def test_breadcrumb_links_go_up_and_the_top_reset() -> None:
"""The breadcrumb: hidden at the top level; when drilled in, one
link per ancestor — the TOP level (→ `{ null, null }` — the reset),
the source (→ `{ source, "" }`), then the folder chain (→ each
ancestor folder) — the LAST segment a span with
aria-current=\"page\" (not a link)."""
js = _js()
start = js.find("function renderCrumb(")
body = js[start : js.find("function crumbSegment(")]
assert "if (current.source === null) {" in body
assert "crumbEl.hidden = true" in body, "the breadcrumb is hidden at the top level"
assert "crumbSegment(\"Knowledge base\", { source: null, folder: null })" in body, (
"the top-level segment goes back to the sources list (the reset)"
)
assert "crumbSegment(current.source, { source: current.source, folder: \"\" })" in body, (
"the source segment goes up to the source root"
)
assert "crumbSegment(part, { source: current.source, folder: acc })" in body, (
"each folder-ancestor segment goes up to that folder"
)
assert "crumbCurrent(part)" in body, "the last segment is the current span"
seg = js[js.find("function crumbSegment(") : js.find("function crumbCurrent(")]
assert 'a.className = "kb-crumb-link"' in seg
assert "a.textContent = label" in seg, "segment labels are textContent"
cur = js[js.find("function crumbCurrent(") : js.find("function goTo(")]
assert 'span.className = "kb-crumb-current"' in cur
assert 'span.setAttribute("aria-current", "page")' in cur, (
"the last segment is aria-current (a span, never a link)"
)
assert "span.textContent = label" in cur
def test_navigation_is_client_side_only() -> None:
"""goTo() is the ONLY navigation: set `current`, re-render. No
fetch (the tree is already in memory), no URL change (history is
untouched — the deep-link contract of the shell is unchanged)."""
js = _js()
body = js[js.find("function goTo(") : js.find("function makeSourceRow(")]
assert "current = { source: target.source, folder: target.folder };" in body
assert "renderLevel();" in body
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 ----------
def test_top_level_lists_sources_and_hides_the_file_table() -> None:
"""Top level: the breadcrumb + level block are hidden; the folders
table lists the SOURCES themselves (makeSourceRow for every source)
and the file table is ALWAYS hidden (files are seen per source —
`ls()` shows none at the top)."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("function renderEmpty(")]
top = body[body.find("if (current.source === null) {") :]
assert "levelEl.hidden = true;" in top, "the level block is hidden at the top level"
assert "for (const s of kbTree.sources) foldersTbody.appendChild(makeSourceRow(s));" in top, (
"ONE table for every level: at the top the rows are the sources"
)
assert "foldersWrap.hidden = false;" in top
assert "if (tableWrap) tableWrap.hidden = true;" in top, (
"the file table is always hidden at the top level"
)
def test_level_block_shows_the_stored_description_only() -> None:
"""Inside a source/folder: the level block shows the CURRENT
level's stored description — the title is the full source-relative
path (e.g. `alpha/two`; the source root shows the source name) —
and is HIDDEN when none is stored (the ls rule: count only, no
placeholder)."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("function renderEmpty(")]
assert "if (node.summary) {" in body, "the ls rule: no description → no block"
summary_if = body.find("if (node.summary) {")
shown = body[summary_if : body.find("} else {", summary_if)]
assert "levelTitleEl.textContent = current.folder" in shown
assert "current.source + \"/\" + current.folder" in shown, (
"the folder title is the full source-relative path"
)
assert ": current.source;" in shown, "the source root title is the source name"
assert "levelSummaryEl.textContent = node.summary;" in shown
assert "levelEl.hidden = false;" in shown
hidden = body[body.find("} else {", body.find("if (node.summary) {")) :]
assert "levelEl.hidden = true;" in hidden[:200], "no stored row → the block is hidden"
def test_level_lists_subfolders_and_direct_files_only() -> None:
"""A level's folders table = its DIRECT subfolders (hidden when
none); its file table = its DIRECT files (hidden when none) —
makeRow UNCHANGED (the file node carries no source: the row object
restores the flat /api/docs shape makeRow reads, so the phase-10/26
pins on makeRow stay green)."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("function renderEmpty(")]
assert 'children.filter((c) => c.kind === "folder")' in body
assert 'children.filter((c) => c.kind === "file")' in body
assert "for (const f of subfolders) foldersTbody.appendChild(makeFolderRow(f));" in body
assert "foldersWrap.hidden = subfolders.length === 0;" in body, (
"#folders-wrap hidden when the level has no subfolders"
)
assert "for (const f of files) {" in body
assert "source: current.source," in body, "the row object restores the source"
assert "makeRow({" in body, "makeRow is unchanged (the phase-10/26 pins)"
assert "if (tableWrap) tableWrap.hidden = files.length === 0;" in body, (
"the file table hides when the level has no direct files"
)
# makeRow itself is untouched — the standing pins on its body.
assert "link.href = documentUrl(d.source, d.path)" in js
assert "openDocumentModal(d.source, d.path, link)" in js
def test_render_clears_both_row_containers_before_filling() -> None:
"""Re-entrancy (the phase-77 pattern, extended to the second
container): the render clears BOTH tbodies at the top — a refresh
from a populated level into a sparser one leaves no ghost rows."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("\n }", start)]
clear_folders = body.find("if (foldersTbody) foldersTbody.replaceChildren();")
clear_docs = body.find("if (tbody) tbody.replaceChildren();")
assert 0 <= clear_folders < clear_docs, "both containers clear at the top"
for append in ("foldersTbody.appendChild", "tbody.appendChild"):
assert clear_docs < body.find(append), f"{append} must come after the clears"
# ---------- the stat cards: the whole-tree walk ----------
def test_stat_cards_walk_the_whole_tree() -> None:
"""The KB-wide stat cards walk the WHOLE tree (every file node at
any depth): document count, chunks sum, max indexed_at (fmtDate
reuse) — the values identical to the former flat walk. A
0-document registered source contributes nothing (and still lists
its row — the ls invariant)."""
js = _js()
body = js[js.find("function treeStats()") : js.find("function renderCrumb(")]
assert "docs += 1;" in body
assert "totalChunks += child.chunks;" in body
assert "if (child.indexed_at > last) last = child.indexed_at;" in body
assert "walk(child);" in body, "the walk recurses into subfolders"
assert "for (const s of kbTree.sources) walk(s);" in body, "every source is walked"
render = js[js.find("function renderLevel(") : js.find("function renderEmpty(")]
assert "const st = treeStats();" in render
assert "statDocs.textContent = String(st.docs);" in render
assert "statChunks.textContent = String(st.totalChunks);" in render
assert 'st.last ? fmtDate(st.last) : "–"' in render, "the fmtDate reuse"
# ---------- the empty state: the zero-sources semantic ----------
def test_empty_state_is_zero_sources_only() -> None:
"""The deliberate phase-97 semantic change (module docstring):
#sources-empty shows ONLY when the tree has zero sources — a
registered 0-document source renders its row instead (the ls
invariant). A failed tree fetch renders the same no-data state
(the former loadDocs failure behavior, unchanged in kind)."""
js = _js()
render = js[js.find("function renderLevel(") : js.find("function renderEmpty(")]
assert "if (!kbTree.sources.length) {" in render, (
"the empty state fires on ZERO SOURCES (not zero documents)"
)
assert "renderEmpty();" in render[render.find("if (!kbTree.sources.length) {"):]
empty_start = js.find("function renderEmpty(")
empty = js[empty_start : js.find("\n }", empty_start)]
assert 'statDocs.textContent = "0";' in empty
assert 'statChunks.textContent = "0";' in empty
assert 'statLast.textContent = "–";' in empty
assert "if (foldersWrap) foldersWrap.hidden = true;" in empty, "both wraps hidden"
assert "if (tableWrap) tableWrap.hidden = true;" in empty
assert "emptyEl.hidden = false;" in empty, "#sources-empty shows"
# The failure paths of the load land in the same no-data state.
load = js[js.find("async function loadTree()") : js.find("async function loadTree()") + 900]
assert load.count("renderEmpty();") >= 2, "a failed fetch renders the no-data state"
def test_module_docstring_documents_the_semantic_change() -> None:
"""The house module-docstring convention: the phase-97 section
records the tree load, the drill state, and the deliberate
0-document-source semantic change."""
js = _js()
header = js[: js.find("import { fetchIsAdmin }")]
assert "Phase 97 (task 04)" in header
assert "zero SOURCES" in header, "the empty-state semantic change is documented"
assert "0-document source renders its row" in header, (
"the registered 0-document source exception is documented"
)
assert "loadTree()" in header
# ---------- the never-stale reset-to-top fallback ----------
def test_reset_to_top_when_the_location_vanished() -> None:
"""PLAN §7.4: after a re-fetch, if the current source is no longer
in the tree (unregistered/pruned) OR the current folder no longer
exists under it, `current` RESETS to the top level BEFORE rendering
— no stale breadcrumb, no stale block. The folder existence check
mirrors the phase-94 rule: some indexed path starts with
`folder + "/"` (the source root — \"\" — always exists)."""
js = _js()
body = js[js.find("function resetVanishedLocation()") : js.find("function folderExistsIn(")]
assert "if (current.source === null) return;" in body, "the top level never vanishes"
assert "kbTree.sources.find((s) => s.name === current.source)" in body
assert body.count("current = { source: null, folder: null };") == 2, (
"both vanishing arms reset to the top level"
)
exists = js[js.find("function folderExistsIn(") : js.find("function currentLevelNode(")]
assert 'if (folderPath === "") return true;' in exists, "the source root always exists"
assert "p.startsWith(folderPath + \"/\")" in exists, "the phase-94 existence rule"
# The reset runs on the success path of the load, before the render
# (pinned in the load-order test — here: it is defined and called).
assert "resetVanishedLocation();" in js[js.find("async function loadTree()"):]
# ---------- the refresh wirings: loadTree at the three points ----------
def test_load_tree_wired_at_exactly_the_three_refresh_points_plus_boot() -> None:
"""loadDocs's call sites become loadTree's (same points, same
semantics): the boot load, the bor:view-refresh listener (admin
branch), applySyncSuccess (the KB just changed), and the
upload-success branch of startSyncPolling — and the old name is
gone. Exactly 5 occurrences of `loadTree()`: the definition +
those 4 calls (no other call site exists)."""
js = _js()
assert "loadDocs" not in js, "the old load name is gone from the module"
assert _code(js).count("loadTree()") == 5, (
"the definition + the 4 call sites, no others (code, not comments)"
)
# 1. the view-refresh listener, armed in the admin branch.
listener = js.find('addEventListener("bor:view-refresh"')
assert listener != -1
gate = js.find("const admin = await fetchIsAdmin();")
assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)"
assert "() => loadTree()" in js[listener : listener + 120]
# 2. the boot load — the last statement of mount, right after the
# listener is armed (the mount's own load is the first fetch).
assert 'root.addEventListener("bor:view-refresh", () => loadTree());\n loadTree();' in js, (
"the boot load follows the listener"
)
# 3. applySyncSuccess (the sync's terminal — the KB just changed).
success = js[js.find("function applySyncSuccess(") : js.find("function applySyncFailure(")]
assert "loadTree();" in success
# 4. the upload-success branch of the tick (settle + refresh).
tick = js[js.find("function startSyncPolling(") : js.find("function startSync()")]
up_ok = tick.find('uploadStatus && uploadStatus.state === "success"')
up_fail = tick.find('uploadStatus && uploadStatus.state === "failed"')
assert -1 < up_ok < up_fail
assert "loadTree();" in tick[up_ok:up_fail], "the upload success refreshes the tree"
def test_anonymous_branch_never_fetches_the_tree() -> None:
"""The phase-16 soft rule (unchanged in kind): the anonymous branch
gates the catalog in / out with NO catalog fetch — no /api/docs/
tree request at all, and the tree surfaces stay hidden (they ship
hidden and never fill)."""
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 "fetch(" not in branch, "anonymous never fetches the catalog"
assert "loadTree" not in branch, "anonymous never starts a tree load"
assert "if (foldersWrap) foldersWrap.hidden = true;" in branch, (
"the folders wrap is part of the anonymous hide"
)
assert "if (tableWrap) tableWrap.hidden = true;" in branch
assert "if (gateEl) gateEl.hidden = false;" in branch
# ---------- the textContent contract ----------
def _code(js: str) -> str:
"""sources.js with comments stripped (a comment may legally carry
any string — the count pins below must only see code)."""
no_block = re.sub(r"/\*.*?\*/", "", js, flags=re.S)
return re.sub(r"//.*", "", no_block)
def test_tree_cells_never_use_innerhtml_with_derived_data() -> None:
"""The module's standing rule: document-derived text goes through
textContent only. The ONE innerHTML left in the module CODE is the
static (non-derived) sync-error modal skeleton — unchanged since
phase 41."""
js = _js()
code = _code(js)
assert code.count("innerHTML") == 1, (
"only the static sync-modal skeleton may use innerHTML (code, not comments)"
)
modal = code.find("function createSyncModal()")
assert modal != -1
assert code.find("innerHTML") > modal, "the single innerHTML is the sync-modal creation"
# The tree's derived-text sinks are all textContent (spot the
# contract across the builders + the level block).
for sink in (
"link.textContent = s.name",
"link.textContent = f.path.split(\"/\").pop()",
'text.textContent = (node && node.summary) || ""',
"levelSummaryEl.textContent = node.summary;",
"a.textContent = label",
"span.textContent = label",
"countTd.textContent = String(s.documents)",
"countTd.textContent = String(f.documents)",
):
assert sink in js, f"the derived-text contract: {sink!r}"
# ---------- the file-table wrap lookup (the new class collision) ----------
def test_file_wrap_lookup_avoids_the_new_folders_wrap() -> None:
"""#folders-wrap ALSO carries the shared .table-wrap class (the
card language) — the FILE table's wrap is looked up through its
own table, not the class (a class lookup would hit #folders-wrap
first in document order and hide/show the wrong card)."""
js = _js()
assert 'root.querySelector("#docs-table").parentElement' in js
assert 'root.querySelector(".table-wrap")' not in js, (
"the bare class lookup must not survive (it would hit #folders-wrap)"
)
# ---------- the styles: the tree's classes, no new hue ----------
def test_styles_carry_the_tree_classes_with_no_new_hue() -> None:
"""The new classes exist (.kb-crumb + link/current/separator,
.kb-level, .kb-folders-table, .folder-link) and the phase-97 block
introduces NO new hue (the phase-92 monochrome invariant — every
color via a var(); no hex/rgb literal)."""
css = _text(STYLES_CSS)
for selector in (
".kb-crumb",
".kb-crumb-link",
".kb-crumb-current",
".kb-crumb-sep",
".kb-level",
".kb-folders-table",
".folder-link",
):
assert re.search(r"(?<![\w-])" + re.escape(selector) + r"\s*\{", css), (
f"styles.css must define {selector}"
)
block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)")
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)"
# The drill link keeps the AA brand-ink pair + the hover/focus
# affordance (text + color, never color alone).
link = block[block.find(".folder-link {") : block.find(".folder-link:hover")]
assert "var(--brand-ink)" in link
assert ".folder-link:hover, .folder-link:focus-visible" in block, (
"the drill affordance covers hover AND focus"
)
# ---------- the folder-description editor (phase 97, task 05) ----------
#
# The phase-57 edit affordance, mirrored: Edit → inline textarea
# (prefilled) → Save/Cancel → live-region status, wired to
# PATCH /api/folders/summary (task 03). The browser behavior is
# E2E-covered by the phase's dedicated suite (task 06); this module
# pins the source-level contract (the house pattern of
# tests/unit/test_summary_edit_ui.py for the phase-57 analog).
def test_level_block_ships_the_static_edit_button() -> None:
"""The level block ships the phase-57 Edit button in the static
HTML: a real type=button with the .kb-summary-edit class and the
"Edit" label, INSIDE #kb-level after p#kb-level-summary — in the
.kb-level-body (the editor swaps inside it, the <h2> stays put)."""
view = _rag_view(_text(SHELL_HTML))
assert '<div class="kb-level-body">' in view, (
"the level's description UI ships in the .kb-level-body"
)
assert (
'<button type="button" class="kb-summary-edit" id="kb-level-edit">Edit</button>'
in view
), "the static level Edit button (phase-57 label, real button)"
body = view[view.find('id="kb-level"') : view.find('id="folders-wrap"')]
assert (
body.find('<h2 id="kb-level-title"></h2>')
< body.find('<p id="kb-level-summary"></p>')
< body.find('id="kb-level-edit"')
), "title → description → Edit button, in order"
def test_row_description_cell_builds_text_and_always_present_edit() -> None:
"""makeDescCell (the shared Description-cell builder, called from
BOTH row builders): the stored description as a text node
(textContent — never innerHTML) + the Edit button added
UNCONDITIONALLY — a description can be CREATED where none is
stored (a < 2-document folder, the generator's fail-soft miss), so
there is NO gate on the stored value. The button is a real
type=button with a human aria-label, and the shared editor is
wired with a CONSTANT target { node, source, folder }."""
body = _fn(_js(), "makeDescCell")
assert 'text.textContent = (node && node.summary) || ""' in body, (
"the stored description is a text node (or an empty cell)"
)
assert 'btn.type = "button"' in body
assert 'btn.className = "kb-summary-edit"' in body
assert 'btn.textContent = "Edit"' in body
assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body
assert "td.append(text, btn)" in body
assert "if (node" not in body, (
"always present: no gate on the stored description"
)
assert "getTarget: () => ({ node, source, folder })" in body, (
"a row's target is a constant (its own node)"
)
def test_editor_swap_builds_textarea_save_cancel_and_live_region() -> None:
"""Edit swaps the description UI for the inline editor: a
<textarea class="kb-summary-editor"> prefilled via .value (NEVER
innerHTML — the XSS contract), the Save / Cancel real type=buttons,
and the <p class="kb-summary-status" role="status"
aria-live="polite"> live region (the shared parts are built once).
The Edit button hides while the editor is open; hide → swap →
focus the textarea, in order."""
body = _fn(_js(), "wireDescriptionEdit")
open_body = body[
body.find("function openEditor()") : body.find("async function saveDescription()")
]
assert 'editor.className = "kb-summary-editor"' in open_body
assert (
'editor.value = typeof target.node.summary === "string" ? target.node.summary : ""'
in open_body
), "prefill via .value — value, not innerHTML"
assert "container.replaceChildren(editor, actions, status)" in open_body
hide = open_body.find("editBtn.hidden = true")
swap = open_body.find("container.replaceChildren(editor, actions, status)")
focus = open_body.find("editor.focus()")
assert -1 < hide < swap < focus, "hide Edit → swap → focus the textarea"
# The shared parts (built once at wiring).
assert 'actions.className = "kb-summary-actions"' in body
assert 'mkBtn("kb-summary-save", "Save")' in body
assert 'mkBtn("kb-summary-cancel", "Cancel")' in body
assert 'status.className = "kb-summary-status"' in body
assert 'status.setAttribute("role", "status")' in body
assert 'status.setAttribute("aria-live", "polite")' in body
# The bindings.
assert 'editBtn.addEventListener("click", openEditor)' in body
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
# The textarea is rebuilt on every open (fresh prefill each time).
assert "editor = document.createElement(\"textarea\")" in open_body
assert "let editor = null;" in body
# XSS contract: the whole affordance is textContent/.value only.
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
assert "innerHTML" not in code, "XSS contract: no innerHTML in the wiring"
def test_save_patches_the_exact_endpoint_with_the_folder_body() -> None:
"""Save → PATCH /api/folders/summary (the task-03 endpoint) with
the EXACT body { source, folder_path, summary } — folder_path ""
for the source root (makeSourceRow passes ""), the
source-relative folder path otherwise (f.path), JSON content
type. Exactly ONE call site in the module (the shared editor)."""
js = _js()
assert js.count('fetch("/api/folders/summary"') == 1, (
"exactly one PATCH call site (the shared editor)"
)
body = _fn(js, "wireDescriptionEdit")
save = body[body.find("async function saveDescription()") :]
assert 'method: "PATCH"' in save
assert '"Content-Type": "application/json"' in save
assert "JSON.stringify({ source, folder_path: folder, summary: value })" in save, (
"the exact body shape: { source, folder_path, summary }"
)
# folder_path "" for the source root; the source-relative path for
# folder rows.
src_row = _fn(js, "makeSourceRow")
assert 'makeDescCell(s, s.name, "", s.name)' in src_row, "root: folder_path \"\""
f_row = _fn(js, "makeFolderRow")
assert (
"makeDescCell(f, current.source, f.path, current.source + \"/\" + f.path)" in f_row
), "folder rows: the source-relative folder path"
def test_save_success_updates_kbtree_in_place_and_announces() -> None:
"""A 200 syncs the in-memory kbTree node IN PLACE (node.summary =
data.summary — no re-fetch: the tree state stays coherent, the
re-fetch is the safety net), then re-renders the text node via
textContent only, with the EXACT status copies: "Description
updated." for a saved text, "Description cleared." for the null
(cleared) echo the server returns."""
body = _fn(_js(), "wireDescriptionEdit")
save = body[body.find("async function saveDescription()") :]
ok_i = save.find("if (!res.ok)")
json_i = save.find("await res.json()")
sync_i = save.find("node.summary = data.summary")
announce_i = save.find(
'closeEditor(data.summary === null ? "Description cleared." : "Description updated.")'
)
assert -1 < ok_i < json_i < sync_i < announce_i, (
"non-ok checked first → JSON → in-place node sync → announce"
)
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
assert "textEl.textContent = value" in close, (
"the display state re-renders the text node (textContent only)"
)
assert 'status.textContent = message' in close
def test_empty_save_clears_hides_the_level_or_empties_the_cell() -> None:
"""An empty save (the server echoes summary null) empties the text
(row cell) and, for the level block, hides the WHOLE block (the ls
rule) via onCleared — with the phase-57 announcement beat first
(setTimeout) so the role=status "Description cleared." is still
readable, GUARDED so a navigate-away first (or a re-create within
the beat) can't get the block yanked out from under a newer level.
A row cell passes NO onCleared: it just goes empty, its
always-present Edit button stays (a description can be
re-created)."""
js = _js()
body = _fn(js, "wireDescriptionEdit")
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
clear_i = close.find('if (value.trim() === "" && onCleared) onCleared();')
focus_i = close.find("editBtn.focus()")
assert -1 < clear_i < focus_i, "cleared → onCleared; otherwise focus the opener"
# The level wiring passes the guarded delayed hide.
wire = js[
js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")
]
assert "onCleared" in wire
assert "setTimeout" in wire, "the phase-57 announcement beat before the hide"
assert "levelEl.hidden = true" in wire
guard = wire[wire.find("setTimeout") :]
assert "currentLevelNode()" in guard and "!n.summary" in guard, (
"the hide is guarded: only if the current node still has none"
)
# The row cells pass NO onCleared (makeDescCell's call omits it).
cell = _fn(js, "makeDescCell")
assert "onCleared" not in cell, "a row cell never hides — the button stays"
def test_cancel_restores_the_text_node() -> None:
"""Cancel → closeEditor(""): the display state restored (the text
element re-rendered from the node — the stored text was NEVER
mutated: only the editor held the draft), the (empty) live region
kept, the Edit button un-hidden + re-focused. Cancel never
fetches (no PATCH was sent)."""
body = _fn(_js(), "wireDescriptionEdit")
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
render_i = close.find("textEl.textContent = value")
unhide_i = close.find("editBtn.hidden = false")
restore_i = close.find("container.replaceChildren(textEl, editBtn, status)")
assert -1 < render_i < unhide_i < restore_i, (
"re-render → unhide → restore (focus follows for a non-clear)"
)
assert "fetch(" not in close, "cancel never fetches"
def test_failure_keeps_the_editor_with_neutral_copy() -> None:
"""A failed Save (non-ok HTTP OR network) keeps the editor open
with the user's text (no swap back, no node mutation) and shows
neutral retry copy (phase-55 convention — no sign-in wording):
"…try again." for a non-ok response, "…is the app reachable?" for
the network path (the phase-55 pin style: the failure slices carry
neither a close nor a stored-text touch)."""
body = _fn(_js(), "wireDescriptionEdit")
save = body[body.find("async function saveDescription()") :]
nonok = save.find("if (!res.ok)")
neutral = save.find("Couldn't save the description — try again.")
assert -1 < nonok < neutral, "the non-ok branch lands on the neutral copy"
catch_i = save.find("} catch {")
reachable = save.find("Couldn't save the description — is the app reachable?")
assert -1 < catch_i < reachable, "the network path carries the reachable? copy"
assert "signed in" not in body, "no sign-in wording (neutral retry copy)"
# The two FAILURE branches never close the editor or touch the node
# — the editor stays open with the user's text.
nonok_slice = save[nonok : save.find("await res.json()")]
assert "closeEditor" not in nonok_slice
assert "node.summary" not in nonok_slice
catch_slice = save[catch_i : save.find("finally")]
assert "closeEditor" not in catch_slice
assert "node.summary" not in catch_slice
def test_save_double_click_guard_releases_in_finally() -> None:
"""One PATCH at a time: Save disables itself BEFORE the fetch and
re-enables in the ``finally`` (every outcome — success, clear,
non-ok, network — never leaves a stale disabled button, PLAN
§7.4). The reset() teardown re-enables too (a torn-down editor's
Save must be usable again after the row is rebuilt… the button
ships disabled-ready for the next open)."""
body = _fn(_js(), "wireDescriptionEdit")
save = body[body.find("async function saveDescription()") :]
disable_i = save.find("saveBtn.disabled = true")
fetch_i = save.find('fetch("/api/folders/summary"')
finally_i = save.find("finally")
enable_i = save.find("saveBtn.disabled = false")
assert -1 < disable_i < fetch_i < finally_i < enable_i, (
"disable before the fetch; re-enable in the finally"
)
assert body.count("saveBtn.disabled = true") == 1
assert body.count("saveBtn.disabled = false") == 2, (
"the save finally + the reset() teardown"
)
def test_level_editor_uses_a_target_getter_and_resets_on_every_rerender() -> None:
"""The level block is PERSISTENT (reused across levels as you
drill) — its target is a GETTER reading `current` +
currentLevelNode() at open/save time (a row's is a constant),
with the PATCH pair from the drill state (folder "" at the source
root). The handle's reset() is called on EVERY re-render
(renderLevel + renderEmpty) so a navigate-away / refresh never
leaves a stale open editor (PLAN §7.4); reset tears down only an
OPEN editor, with no message, and the display state it restores
is text + button (a stale status line must not survive)."""
js = _js()
wire = js[
js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")
]
assert "getTarget: () => {" in wire, "the level's target is a getter"
assert "currentLevelNode()" in wire
assert "{ node, source: current.source, folder: current.folder }" in wire, (
"the PATCH pair from the drill state (folder \"\" at the source root)"
)
# reset() runs on both re-render paths.
rl = js.find("function renderLevel(")
render = js[rl : js.find("\n }", rl)]
assert "if (levelEditor) levelEditor.reset();" in render
re_ = js.find("function renderEmpty(")
empty = js[re_ : js.find("\n }", re_)]
assert "if (levelEditor) levelEditor.reset();" in empty
# The handle: reset is a no-op for a closed editor, and the display
# state is text + button (the status line is dropped).
body = _fn(js, "wireDescriptionEdit")
reset = body[body.find("reset() {") :]
assert "if (!isOpen) return;" in reset
assert "container.replaceChildren(textEl, editBtn);" in reset, (
"the display state is text + button (the status line is dropped)"
)
def test_editor_wiring_never_uses_innerhtml() -> None:
"""The whole task-05 wiring (mkBtn, wireDescriptionEdit,
makeDescCell) is createElement + textContent/.value only — the
module's single innerHTML stays the static sync-modal skeleton
(the phase-41 shape, unchanged)."""
js = _js()
code = _code(js)
assert code.count("innerHTML") == 1, (
"only the static sync-modal skeleton may use innerHTML (code, not comments)"
)
for name in ("mkBtn", "wireDescriptionEdit", "makeDescCell"):
fn_code = re.sub(r"//.*?$|/\*.*?\*/", "", _fn(js, name), flags=re.S | re.M)
assert "innerHTML" not in fn_code, f"{name} must be textContent/.value only"
def test_module_docstring_documents_the_edit_section() -> None:
"""The house per-phase module-note convention: the phase-97
task-05 section records the shared editor, the PATCH body
(folder_path "" for the root), the exact status copies, the
in-place kbTree update, and the reset contract."""
js = _js()
header = js[: js.find("import { fetchIsAdmin }")]
assert "Phase 97 (task 05)" in header
assert "wireDescriptionEdit()" in header
assert "PATCH /api/folders/summary" in header
assert '"Description updated."' in header
assert '"Description cleared."' in header
assert "IN PLACE" in header
def test_styles_carry_the_folder_editor_classes() -> None:
"""styles.css carries the five task-named .kb-summary-* classes on
the house dark-tech palette: the 24px+ edit target with the
[hidden] override, the 8rem-min editor (the phase-57 spec), the
solid brand Save pill (--bg on --brand, borderless), the ink-soft
Cancel + status (AA), :focus-visible via the global rule, and NO
new hue in the phase-97 block (the phase-92 invariant — the
task-05 block sits inside it)."""
css = _text(STYLES_CSS)
for cls in (
".kb-summary-edit",
".kb-summary-editor",
".kb-summary-save",
".kb-summary-cancel",
".kb-summary-status",
):
assert re.search(r"(?<![\w-])" + re.escape(cls) + r"\s*\{", css), (
f"styles.css must define {cls}"
)
block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)")
assert "kb-summary-editor" in block, "the task-05 block lives in the phase-97 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)"
edit = css[css.find(".kb-summary-edit {") :]
edit = edit[: edit.find("\n}")]
assert "min-height: 24px" in edit, "the 24px+ edit target"
assert "var(--line)" in edit and "var(--ink-soft)" in edit
hidden = css.find(".kb-summary-edit[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 80], (
"the hidden attr must beat the base display rule"
)
editor = css[css.find(".kb-summary-editor {") :]
editor = editor[: editor.find("\n}")]
for prop in ("display: block", "width: 100%", "min-height: 8rem", "resize: vertical"):
assert prop in editor, f".kb-summary-editor must keep {prop}"
save = css[css.find(".kb-summary-save {") :]
save = save[: save.find("\n}")]
assert "background: var(--brand)" in save and "color: var(--bg)" in save
assert "border: 0" in save, "the solid brand pill family (Save/Share)"
status = css[css.find(".kb-summary-status {") :]
status = status[: status.find("\n}")]
assert "var(--ink-soft)" in status, "AA status copy (5.1:1)"
# :focus-visible via the GLOBAL 3px outline rule (no local
# suppression for these controls).
assert ":focus-visible {" in css
assert "outline: 3px solid var(--brand)" in css