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).
1015 lines
49 KiB
Python
1015 lines
49 KiB
Python
"""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
|