Everything is verified green. Final report: **Phase 106 — Document dates (final verification pass; all 10 tasks already complete)** - Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed. - `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓) - `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up) - 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation** - `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings** **Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions. - **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring). - **Next pending phase:** none — `todo/` holds only this phase.
1534 lines
76 KiB
Python
1534 lines
76 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 ``#docs-table`` — phase 106,
|
||
task 08, D8, added the Created column BEFORE Indexed, making it the
|
||
6-column contract). 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 | Updated | Description head — phase 106
|
||
(task 08, D8) added the Updated column (the subtree's MAX document
|
||
created_at, D9) BETWEEN Documents and Description) in order after
|
||
``#stat-cards`` and BEFORE the file table (now the 6-column
|
||
Source | Path | Title | Chunks | Created | Indexed head — phase 106
|
||
(task 08, D8) added the Created column BEFORE Indexed);
|
||
* 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).
|
||
|
||
Phase 98 (task 04) adds the "SUMMARY PENDING" markers — the pins this
|
||
module gains for them:
|
||
|
||
* the row Description cell's THREE text states (``makeDescCell``):
|
||
stored → the stored text (NEVER the marker); no stored +
|
||
``summary_pending`` → the ``kb-summary-pending`` class on the
|
||
existing text span + the exact ``Summary pending`` copy + the exact
|
||
D4 title (textContent/title only — the house rule); neither → the
|
||
empty cell (the ls rule, unchanged) — with the Edit button
|
||
UNCONDITIONAL in all three (a manual save creates the row);
|
||
* the in-place clear: the editor's success path sets
|
||
``node.summary_pending = false`` immediately after
|
||
``node.summary = data.summary`` (no re-fetch — the marker clears in
|
||
the surface where the edit happened);
|
||
* the level block's OR condition (a stored description OR
|
||
``summary_pending``) + the EXACT pending note — neither stored nor
|
||
pending stays hidden (the ls rule, unchanged) — and the level's
|
||
pending text takes the muted ``kb-summary-pending`` class on render
|
||
(the block is REUSED across levels — a stored level clears it);
|
||
* the editor's close re-derives the display state from the node (task
|
||
05 defect fix): the pending marker's muted class + D4 tooltip
|
||
CANNOT survive a save (the flag is cleared first — the in-place
|
||
clear, with the stale "next sync" tooltip gone), and a cancel
|
||
restores the surface's pending display (each surface passes its
|
||
pending copy via ``pendingText`` — the row's ``Summary pending``
|
||
marker, the level's D4 note — and its tooltip via ``pendingTitle``,
|
||
the row only);
|
||
|
||
* ``.kb-summary-pending`` in styles.css (the ink-soft muted AA pair —
|
||
text + color, never color alone; no font/white-space/italic
|
||
overrides, so the row height is unchanged; 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 |
|
||
Updated | Description — phase 106 (task 08, D8), visually-hidden
|
||
caption, the ``.docs-table`` language + ``.kb-folders-table``).
|
||
The file table's head is the 6-column contract (Source | Path |
|
||
Title | Chunks | Created | Indexed — phase 106 (task 08, D8))."""
|
||
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\">Updated</th>", # phase 106 (task 08, D8)
|
||
"<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's head (the 6-column contract — phase 106 (task
|
||
# 08, D8) added Created BEFORE Indexed; 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", "Created", "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()
|
||
# Slice to the next function (the row builders grew with phase 106's
|
||
# Updated cell — a fixed window would drift out of the function).
|
||
body = js[js.find("function makeSourceRow(") : js.find("function makeFolderRow(")]
|
||
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 : js.find("function renderLevel(")]
|
||
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_drill_navigation_pushes_state_only_entries() -> None:
|
||
"""Phase 99 (task 02, D2): every user-initiated drill is a history
|
||
entry — ``goTo`` is the push variant of ``applyTarget``: when the
|
||
target DIFFERS from the current level (a re-click of the current
|
||
row/segment pushes NO duplicate entry), it FIRST records a
|
||
STATE-ONLY entry — ``history.pushState({ view: "rag", kb: target },
|
||
"")`` — the second arg is ``""`` and there is no third (the URL
|
||
stays the shell's pathname: no new route, the phase-76 deep-link
|
||
surface is untouched) — then sets ``current`` + re-renders. The
|
||
drill still never fetches (the tree is in memory) and the module
|
||
never ``replaceState``s."""
|
||
js = _js()
|
||
start = js.find("function applyTarget(")
|
||
assert start != -1, "applyTarget must exist (the two-step drill)"
|
||
body = js[start : js.find("function makeSourceRow(")]
|
||
gate_i = body.find(
|
||
"if (push && (target.source !== current.source || target.folder !== current.folder)) {"
|
||
)
|
||
push_i = body.find('history.pushState({ view: "rag", kb: target }, "")')
|
||
set_i = body.find("current = { source: target.source, folder: target.folder };")
|
||
render_i = body.find("renderLevel();")
|
||
assert -1 < gate_i < push_i < set_i < render_i, (
|
||
"push gate (target differs from current) → pushState → set current → render"
|
||
)
|
||
# STATE-ONLY: exactly ONE pushState call site — the "" second arg,
|
||
# no third (no URL change).
|
||
assert _code(body).count("pushState") == 1, (
|
||
"ONE pushState call site — the state-only drill entry"
|
||
)
|
||
assert "replaceState" not in _code(js), "no replaceState anywhere in the module"
|
||
# goTo is the push variant — every drill call site keeps calling it.
|
||
go = body[body.find("function goTo(") :]
|
||
assert "applyTarget(target, true)" in go, "goTo pushes (the user-initiated drill)"
|
||
assert "fetch(" not in body, "the drill never fetches (client-side only)"
|
||
|
||
|
||
# ---------- 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_or_the_pending_note() -> 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) —
|
||
or, since Phase 98 (task 04, D4), the PENDING note when the level
|
||
is summary_pending (no stored description yet — the next sync's
|
||
gap-fill will generate it, or the owner writes one via the block's
|
||
Edit button). HIDDEN only when NEITHER is stored nor pending (the
|
||
ls rule: count only, no placeholder — unchanged)."""
|
||
js = _js()
|
||
start = js.find("function renderLevel(")
|
||
body = js[start : js.find("function renderEmpty(")]
|
||
cond = "if (node.summary || node.summary_pending) {"
|
||
assert cond in body, "the block shows for a stored OR a pending level"
|
||
shown = body[body.find(cond) : body.find("} else {", body.find(cond))]
|
||
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, (
|
||
"the stored text first, the pending note as the fall-through"
|
||
)
|
||
assert (
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)"
|
||
in shown
|
||
), "the EXACT D4 pending note"
|
||
assert (
|
||
'levelSummaryEl.className = node.summary ? "" : "kb-summary-pending";'
|
||
in shown
|
||
), (
|
||
"the pending text is the muted marker style — and a stored level CLEARS a "
|
||
"previous pending level's class (the block is reused, task-05 defect fix)"
|
||
)
|
||
assert "levelEl.hidden = false;" in shown
|
||
hidden = body[body.find("} else {", body.find(cond)) :]
|
||
assert "levelEl.hidden = true;" in hidden[:200], (
|
||
"neither stored nor pending → the block is hidden (the ls rule)"
|
||
)
|
||
|
||
|
||
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 — since
|
||
# Phase 99 (task 02, D2) its body is the ALIGNMENT step (a
|
||
# kb-carrying history.state entry keeps the drill — the
|
||
# active-link re-click; a kb-less entry starts at the top — a
|
||
# fresh nav visit) BEFORE the re-fetch.
|
||
listener = js.find('addEventListener("bor:view-refresh"')
|
||
assert listener != -1
|
||
gate = js.find("const admin = await fetchIsAdmin();")
|
||
assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)"
|
||
listener_body = js[listener : js.find('window.addEventListener("popstate"')]
|
||
state_i = listener_body.find("history.state && history.state.kb")
|
||
adopt_i = listener_body.find("applyTarget(kb, false)")
|
||
reset_i = listener_body.find("applyTarget({ source: null, folder: null }, false)")
|
||
load_i = listener_body.find("loadTree();")
|
||
assert -1 < state_i < adopt_i < reset_i < load_i, (
|
||
"align with history.state (adopt kb / reset to top) BEFORE the re-fetch"
|
||
)
|
||
assert "pushState" not in _code(listener_body), ("the alignment adopts only — no push")
|
||
# 2. the boot load — the last statement of mount, right after the
|
||
# listeners are armed (the mount's own load is the first fetch).
|
||
pop = js.find('window.addEventListener("popstate"')
|
||
boot = js[js.find("});", pop) + 3 :] # past the popstate listener's close
|
||
assert boot.strip().startswith("loadTree();"), (
|
||
"the boot load follows the listeners (no pushState on boot)"
|
||
)
|
||
# 3. applySyncSuccess (the sync's terminal — the KB just changed).
|
||
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.summary",
|
||
'text.textContent = "Summary pending"',
|
||
"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 description text as a text node
|
||
(textContent — never innerHTML) + the Edit button built
|
||
UNCONDITIONALLY — a description can be CREATED where none is
|
||
stored (a < 2-document folder, the generator's fail-soft miss), so
|
||
the button sits OUTSIDE the three-state text branch (Phase 98:
|
||
the text gates on the node state, the button never does). The
|
||
button is a real type=button with a human aria-label, and the
|
||
shared editor is wired with a CONSTANT target { node, source,
|
||
folder }. Phase 99 (task 01, D1): text + button live in ONE
|
||
`div.kb-desc-cell` flex wrapper (the <td> holds only the wrapper),
|
||
and the editor's container IS the wrapper (the open/close swaps
|
||
fill it — the cell layout survives the editor swap)."""
|
||
body = _fn(_js(), "makeDescCell")
|
||
# The Edit button is built unconditionally — the whole button
|
||
# block (from its creation to the append) carries no `if` gate on
|
||
# the stored description or the pending flag.
|
||
btn_block = body[
|
||
body.find("const btn = document.createElement") : body.find("wrap.append(text, btn)")
|
||
]
|
||
assert "if (" not in btn_block, (
|
||
"always present: no gate on the stored description"
|
||
)
|
||
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 'wrap.className = "kb-desc-cell"' in body, "the ONE flex wrapper"
|
||
assert "wrap.append(text, btn)" in body, "text + button, in order, in the wrapper"
|
||
assert "td.append(wrap)" in body, "the <td> holds the wrapper (no new td class)"
|
||
assert "container: wrap" in body, (
|
||
"the editor's container is the WRAPPER (Phase 99 — the swap fills it)"
|
||
)
|
||
assert "getTarget: () => ({ node, source, folder })" in body, (
|
||
"a row's target is a constant (its own node)"
|
||
)
|
||
# The level block's editor is UNTOUCHED — its container is still
|
||
# .kb-level-body (the D1 escape hatch keeps its full text).
|
||
js = _js()
|
||
wire = js[js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")]
|
||
assert "container: levelBody" in wire
|
||
|
||
|
||
def test_desc_cell_pending_marker_branch() -> None:
|
||
"""Phase 98 (task 04, D4): makeDescCell's THREE text states, in
|
||
order — (1) a stored summary → the stored text, NEVER the
|
||
marker; (2) no stored summary AND node.summary_pending → the
|
||
marker: the kb-summary-pending class on the EXISTING text span +
|
||
the exact "Summary pending" copy + the exact D4 title
|
||
(textContent/title only — the house rule, no innerHTML); (3)
|
||
neither stored nor pending → the empty cell (the ls rule,
|
||
unchanged)."""
|
||
body = _fn(_js(), "makeDescCell")
|
||
stored_i = body.find("if (node && node.summary) {")
|
||
pending_i = body.find("else if (node && node.summary_pending) {")
|
||
empty_i = body.find("} else {")
|
||
assert -1 < stored_i < pending_i < empty_i, ("stored → pending → empty, in order")
|
||
stored = body[stored_i:pending_i]
|
||
assert "text.textContent = node.summary;" in stored, "stored → the stored text"
|
||
assert "kb-summary-pending" not in stored, "a stored summary is NEVER the marker"
|
||
assert "Summary pending" not in stored
|
||
pending = body[pending_i:empty_i]
|
||
assert 'text.classList.add("kb-summary-pending")' in pending, (
|
||
"Phase 99: the marker TOGGLES onto the .kb-desc-text base class"
|
||
)
|
||
assert 'text.className = "kb-summary-pending"' not in body, (
|
||
"a bare className re-assignment would drop the clamp's base class"
|
||
)
|
||
assert 'text.textContent = "Summary pending"' in pending, "the D4 marker copy"
|
||
assert (
|
||
'text.title = "No stored description yet — the next sync will generate one."'
|
||
in pending
|
||
), "the D4 title"
|
||
empty = body[empty_i : empty_i + 160]
|
||
assert 'text.textContent = ""' in empty, "neither stored nor pending → the empty cell"
|
||
# The marker is textContent/title only — the house rule (no
|
||
# innerHTML anywhere in the cell builder).
|
||
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||
assert "innerHTML" not in code
|
||
|
||
|
||
def test_save_success_clears_the_pending_flag_in_place() -> None:
|
||
"""Phase 98 (task 04, D4): a successful save syncs the in-memory
|
||
node IN PLACE — node.summary = data.summary AND, immediately
|
||
after, node.summary_pending = false (a created/updated description
|
||
is no longer pending: the marker clears in the surface where the
|
||
edit happened, NO re-fetch). The flag clear sits between the
|
||
summary sync and the announcement, and nothing after it re-fetches
|
||
the tree (the re-fetch stays the safety net, not the in-place
|
||
clear)."""
|
||
body = _fn(_js(), "wireDescriptionEdit")
|
||
save = body[body.find("async function saveDescription()") :]
|
||
sync_i = save.find("node.summary = data.summary")
|
||
clear_i = save.find("node.summary_pending = false")
|
||
announce_i = save.find(
|
||
'closeEditor(data.summary === null ? "Description cleared." : "Description updated.")'
|
||
)
|
||
assert -1 < sync_i < clear_i < announce_i, (
|
||
"summary sync → pending-flag clear → announce (in place)"
|
||
)
|
||
tail = save[clear_i:]
|
||
assert "loadTree()" not in tail and 'fetch("' not in tail, (
|
||
"the in-place clear is the whole of it — no re-fetch"
|
||
)
|
||
|
||
|
||
def test_close_editor_rederives_the_pending_display() -> None:
|
||
"""Phase 98 (task 05 defect fix, D4): closeEditor re-renders the
|
||
display state from the node with the SAME three-state rule the
|
||
surfaces use — the pending marker's muted class + D4 tooltip
|
||
CANNOT survive a save (the success path cleared
|
||
``node.summary_pending`` first, so a stale "next sync" tooltip
|
||
under a just-created description is impossible), and a cancel
|
||
RESTORES the surface's pending display. Each surface passes its
|
||
pending copy via ``pendingText`` (the row's ``Summary pending``
|
||
marker, the level's D4 note) and its tooltip via ``pendingTitle``
|
||
(the row only — D4's title is the row cell's, the level's <p>
|
||
carries none). textContent/class/title only — the house rule."""
|
||
body = _fn(_js(), "wireDescriptionEdit")
|
||
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
|
||
flag_i = close.find('const pending = node !== null && stored === "" && node.summary_pending;')
|
||
value_i = close.find("const value = pending && pendingText ? pendingText : stored;")
|
||
class_i = close.find('textEl.classList.toggle("kb-summary-pending", pending);')
|
||
title_i = close.find("if (pendingTitle) textEl.title = pendingTitle;")
|
||
hover_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {')
|
||
full_i = close.find("textEl.title = stored;")
|
||
clear_title_i = close.find('textEl.removeAttribute("title")')
|
||
render_i = close.find("textEl.textContent = value")
|
||
assert -1 < flag_i < value_i < class_i < title_i < hover_i < full_i, (
|
||
"flag → value → class toggle → title (pending / hover-full), in order"
|
||
)
|
||
assert full_i < clear_title_i < render_i, "title-set → title-clear → text, in order"
|
||
# Phase 99 (task 01): the class line is a TOGGLE (the base classes
|
||
# survive — the row's span keeps .kb-desc-text, the clamp; the
|
||
# level's <p> keeps none), and the CLAMPED row span's title is
|
||
# re-derived from the stored text (the D1 hover escape hatch — a
|
||
# stale pre-edit title cannot survive a save; the unclamped
|
||
# level's <p> is gated out by the kb-desc-text check).
|
||
assert 'textEl.className' not in close, "no bare className re-assignment"
|
||
# The row passes its marker copy + the EXACT D4 tooltip…
|
||
cell = _fn(_js(), "makeDescCell")
|
||
assert 'pendingText: "Summary pending"' in cell
|
||
assert (
|
||
'pendingTitle: "No stored description yet — the next sync will generate one."'
|
||
in cell
|
||
), "the D4 tooltip is the row's"
|
||
# …and the level passes the D4 note WITHOUT a tooltip.
|
||
js = _js()
|
||
wire = js[js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")]
|
||
assert 'pendingText:' in wire and "pendingTitle" not in wire, (
|
||
"the level's <p> carries the note, never a tooltip (D4)"
|
||
)
|
||
assert (
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)"
|
||
in wire
|
||
), "the level's pending copy is the D4 note"
|
||
|
||
|
||
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
|
||
|
||
|
||
# ---------- the "Summary pending" markers (phase 98, task 04) ----------
|
||
|
||
|
||
def test_styles_carry_the_pending_marker_class() -> None:
|
||
"""Phase 98 (task 04, D4): .kb-summary-pending mutes the row-cell
|
||
marker via the ink-soft token (5.1:1 on --surface — AA; text +
|
||
color, never color alone — B5). The class sits on the EXISTING
|
||
description text span, so the row cell's font-size/line-height
|
||
apply — no font/white-space/italic overrides (the marker must not
|
||
change row height), and the phase-97 block carries no new hue
|
||
(the phase-92 monochrome invariant)."""
|
||
css = _text(STYLES_CSS)
|
||
m = re.search(r"(?<![\w-])\.kb-summary-pending\s*\{([^}]*)\}", css)
|
||
assert m, "styles.css must define .kb-summary-pending"
|
||
rule = m.group(1)
|
||
assert "color: var(--ink-soft)" in rule, "the muted AA pair (5.1:1 on --surface)"
|
||
for prop in ("font-style", "font-size", "line-height", "white-space"):
|
||
assert prop not in rule, f"the marker keeps the row's {prop} (row height unchanged)"
|
||
block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)")
|
||
assert ".kb-summary-pending" in block, "the marker class 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)"
|
||
|
||
|
||
def test_module_docstring_documents_the_pending_markers() -> None:
|
||
"""The house per-phase module-note convention: the phase-98
|
||
task-04 section records the marker's surfaces — the row cell's
|
||
D4 copy + title, the level block's pending note — and the
|
||
in-place clear on the editor's success path."""
|
||
js = _js()
|
||
header = js[: js.find("import { fetchIsAdmin }")]
|
||
assert "Phase 98 (task 04)" in header
|
||
assert '"Summary pending"' in header, "the D4 marker copy"
|
||
assert "No stored description yet — the next sync will generate one." in header, (
|
||
"the D4 row-cell title"
|
||
)
|
||
assert (
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)"
|
||
in header
|
||
), "the level block's pending note"
|
||
assert "node.summary_pending = false" in header, "the in-place clear"
|
||
|
||
|
||
# ---------- the one-line Description clamp (phase 99, task 01) ----------
|
||
# D1: the clamp is VISUAL only — the row's Description cell is ONE flex
|
||
# row (text flexes + ellipsizes, the Edit button stays fixed), the full
|
||
# text stays in the DOM + on the span's hover title, and the level
|
||
# block keeps the full unclamped description. The measured-height proof
|
||
# lands in the phase's E2E (task 03); this module pins the structure
|
||
# and the CSS.
|
||
|
||
|
||
def _css_rule(css_text: str, selector: str) -> str:
|
||
"""The declarations of the rule with exactly this selector (the
|
||
task-named classes own their base rule — no compound matches)."""
|
||
m = re.search(r"(?<![\w-])" + re.escape(selector) + r"\s*\{([^}]*)\}", css_text)
|
||
assert m, f"styles.css must define {selector}"
|
||
return m.group(1)
|
||
|
||
|
||
def test_desc_cell_hover_title_covers_real_description_text_only() -> None:
|
||
"""Phase 99 (task 01, D1): the text span (`.kb-desc-text` — the
|
||
base class, set UNCONDITIONALLY before the three-state branch) AL-
|
||
WAYS carries `title` = the FULL description text for real
|
||
(non-empty) stored text — the hover escape hatch of the visual
|
||
clamp; the phase-98 marker keeps its OWN D4 title (never over-
|
||
ridden by the hover rule); the empty cell carries no title at all.
|
||
textContent/title only — the house rule (no innerHTML)."""
|
||
body = _fn(_js(), "makeDescCell")
|
||
base_i = body.find('text.className = "kb-desc-text"')
|
||
stored_i = body.find("if (node && node.summary) {")
|
||
pending_i = body.find("else if (node && node.summary_pending) {")
|
||
empty_i = body.find("} else {")
|
||
assert -1 < base_i < stored_i, "the base clamp class is set before the state branch"
|
||
stored = body[stored_i:pending_i]
|
||
assert "text.title = node.summary;" in stored, "stored → title = the full text (hover)"
|
||
assert "Summary pending" not in stored, "the stored branch never touches the marker"
|
||
pending = body[pending_i:empty_i]
|
||
assert (
|
||
'text.title = "No stored description yet — the next sync will generate one."'
|
||
in pending
|
||
), "the marker keeps its OWN D4 title"
|
||
empty = body[empty_i : empty_i + 200]
|
||
assert "text.title" not in empty, "the empty cell carries no title"
|
||
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||
assert "innerHTML" not in code, "the house rule: no innerHTML in the cell builder"
|
||
|
||
|
||
def test_close_editor_rederives_the_hover_title_from_the_stored_text() -> None:
|
||
"""Phase 99 (task 01, D1): the hover title is the escape hatch for
|
||
the CLAMPED text, so a Save/Cancel must re-derive it — a stale
|
||
pre-edit title cannot survive a save (the span is re-inserted
|
||
into the wrapper by the swap, attributes included). The re-
|
||
derivation is gated on the kb-desc-text class: only the clamped
|
||
row span gets the full-text title; the unclamped level's <p>
|
||
(full text visible) keeps its no-title behavior, and an emptied
|
||
(cleared) cell loses its title."""
|
||
body = _fn(_js(), "wireDescriptionEdit")
|
||
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
|
||
gate_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {')
|
||
set_i = close.find("textEl.title = stored;")
|
||
clear_i = close.find('textEl.removeAttribute("title")')
|
||
assert -1 < gate_i < set_i < clear_i, "gated set (stored + clamped span) → clear, in order"
|
||
assert close.count("textEl.title =") == 2, (
|
||
"exactly: pendingTitle (the marker's own) + stored (the hover full text) — nothing else"
|
||
)
|
||
|
||
|
||
def test_styles_carry_the_one_line_clamp() -> None:
|
||
"""Phase 99 (task 01, D1): the Description cell is one flex row —
|
||
.kb-desc-cell (flex + center + the 0.4rem gap that replaces the
|
||
button's old margin-left + min-width: 0) and .kb-desc-text (flex:
|
||
1 1 auto + min-width: 0 — the flex item may shrink — + the
|
||
ellipsis triad). The column itself is white-space: nowrap (was
|
||
normal — the column width is the clamp's budget; the min/max width
|
||
+ font-size stay). The button's margin-left rule is GONE, the
|
||
level block's margin-top rule is untouched, and the open editor
|
||
fills the flex wrapper (flex: 1 1 auto + min-width: 0). .kb-level
|
||
p is UNTOUCHED (the full unclamped description — D1), and the
|
||
phase-97 block carries no new hue (the phase-92 invariant)."""
|
||
css = _text(STYLES_CSS)
|
||
cell = _css_rule(css, ".kb-desc-cell")
|
||
for prop in ("display: flex", "align-items: center", "gap: 0.4rem", "min-width: 0"):
|
||
assert prop in cell, f".kb-desc-cell must carry {prop!r}"
|
||
text = _css_rule(css, ".kb-desc-text")
|
||
for prop in (
|
||
"flex: 1 1 auto",
|
||
"min-width: 0",
|
||
"overflow: hidden",
|
||
"text-overflow: ellipsis",
|
||
"white-space: nowrap",
|
||
):
|
||
assert prop in text, f".kb-desc-text must carry {prop!r}"
|
||
# Phase 106 (task 08): the clamp follows the Description cell, which
|
||
# moved to the 4th column (Updated took 3rd).
|
||
col_i = css.find(".kb-folders-table td:nth-child(4) {")
|
||
assert col_i != -1
|
||
col = css[col_i : col_i + 400]
|
||
col = col[: col.find("\n}")]
|
||
assert "white-space: nowrap" in col, "the column is one line (was: normal)"
|
||
assert "white-space: normal" not in col
|
||
for prop in ("min-width: 18rem", "max-width: 44rem", "font-size: 0.88rem"):
|
||
assert prop in col, f"the column keeps {prop!r} (the clamp's budget)"
|
||
assert ".kb-folders-table .kb-summary-edit" not in css, (
|
||
"the button's margin-left is gone — the wrapper's gap replaces it"
|
||
)
|
||
lvl_i = css.find(".kb-level-body .kb-summary-edit")
|
||
assert lvl_i != -1
|
||
assert "margin-top: 0.5rem" in css[lvl_i : lvl_i + 80], (
|
||
"the level block's spacing rule is untouched"
|
||
)
|
||
fill_i = css.find(".kb-desc-cell .kb-summary-editor")
|
||
assert fill_i != -1
|
||
fill = css[fill_i : fill_i + 80]
|
||
assert "flex: 1 1 auto" in fill and "min-width: 0" in fill, (
|
||
"the open editor fills the flex wrapper"
|
||
)
|
||
# The level block keeps the FULL unclamped description (D1) — its
|
||
# rule is unchanged (no white-space/ellipsis additions).
|
||
m = re.search(r"(?<![\w-])\.kb-level p\s*\{([^}]*)\}", css)
|
||
assert m, "the level block's <p> rule must exist"
|
||
assert "white-space" not in m.group(1) and "text-overflow" not in m.group(1)
|
||
block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)")
|
||
assert ".kb-desc-cell" in block and ".kb-desc-text" in block, (
|
||
"the clamp lives in the phase-97 tree region"
|
||
)
|
||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no new hue (phase-92 invariant)"
|
||
assert not re.search(r"rgba?\(", block), "no new hue (phase-92 invariant)"
|
||
|
||
|
||
def test_module_docstring_documents_the_one_line_clamp() -> None:
|
||
"""The house per-phase module-note convention: the phase-99
|
||
task-01 section records the flex wrapper structure (text flexes +
|
||
ellipsizes, button fixed), the VISUAL-only clamp (full text in the
|
||
DOM + on the hover title), the marker's own title, and the wrapper
|
||
as the editor's container (the level block's container stays
|
||
.kb-level-body)."""
|
||
js = _js()
|
||
header = js[: js.find("import { fetchIsAdmin }")]
|
||
assert "Phase 99 (task 01, D1)" in header
|
||
assert "kb-desc-cell" in header and "kb-desc-text" in header
|
||
assert "VISUAL only" in header, "the clamp is documented as visual only"
|
||
assert "title" in header, "the hover escape hatch is documented"
|
||
assert "kb-level-body" in header, "the level block's container is documented as kept"
|
||
|
||
|
||
# ---------- the history integration (phase 99, task 02, D2) ----------
|
||
# The drill state IS the history state: every drill pushes a state-
|
||
# only entry (no URL change), Back/Forward adopt the entry's kb (or
|
||
# reset to the top), the re-show aligns before the re-fetch, and the
|
||
# anonymous gate installs no listeners. The browser proof lands in
|
||
# the phase's E2E (task 03); this module pins the wiring.
|
||
|
||
|
||
def test_popstate_adopts_the_entry_kb_or_resets_to_top() -> None:
|
||
"""Phase 99 (task 02, D2): the window popstate — armed exactly
|
||
ONCE, in the ADMIN branch (after the whoami gate, next to the
|
||
refresh listener) — ADOPTS the popped entry's ``event.state.kb``
|
||
via the no-push variant (the browser owns its entries —
|
||
adopt/reset never push), and an entry WITHOUT a kb (the boot
|
||
entry, the router's view entries, any foreign state) RESETS the
|
||
drill to the TOP level. Rendering a currently-hidden view is
|
||
harmless: the router's own popstate (registered earlier, at shell
|
||
boot) owns the view switch for foreign entries; rag-internal
|
||
entries never change the pathname, so this listener's render is
|
||
the visible one."""
|
||
js = _js()
|
||
assert js.count('window.addEventListener("popstate"') == 1, (
|
||
"armed exactly once (mount-once — the router mounts a view ONCE)"
|
||
)
|
||
pop = js.find('window.addEventListener("popstate"')
|
||
gate = js.find("const admin = await fetchIsAdmin();")
|
||
refresh = js.find('root.addEventListener("bor:view-refresh"')
|
||
assert -1 < gate < refresh < pop, (
|
||
"armed in the admin branch, after the gate, next to the refresh listener"
|
||
)
|
||
body = js[pop : js.find("});", pop)]
|
||
kb_i = body.find("event.state && event.state.kb")
|
||
adopt_i = body.find("applyTarget(kb, false)")
|
||
reset_i = body.find("applyTarget({ source: null, folder: null }, false)")
|
||
assert -1 < kb_i < adopt_i < reset_i, (
|
||
"read event.state.kb → adopt (no push) / reset to the top"
|
||
)
|
||
assert "pushState" not in _code(body), ("adopt/reset never push (the browser owns its entries)")
|
||
|
||
|
||
def test_refresh_alignment_adopts_history_state_before_the_refetch() -> None:
|
||
"""Phase 99 (task 02, D2): the bor:view-refresh listener (the
|
||
phase-77 re-show) aligns ``current`` with ``history.state``
|
||
BEFORE ``loadTree()`` — a kb-carrying top entry (the active-link
|
||
re-click pushed NOTHING — the drilled entry is still on top)
|
||
KEEPS the drill (``applyTarget(kb, false)``); a kb-less entry (a
|
||
fresh nav visit) RESETS to the top level. The alignment adopts —
|
||
it never pushes."""
|
||
js = _js()
|
||
listener = js.find('root.addEventListener("bor:view-refresh"')
|
||
assert listener != -1
|
||
body = js[listener : js.find("});", listener)]
|
||
state_i = body.find("history.state && history.state.kb")
|
||
adopt_i = body.find("applyTarget(kb, false)")
|
||
reset_i = body.find("applyTarget({ source: null, folder: null }, false)")
|
||
load_i = body.find("loadTree();")
|
||
assert -1 < state_i < adopt_i < reset_i < load_i, (
|
||
"align (adopt kb / reset to top) BEFORE the re-fetch"
|
||
)
|
||
assert "pushState" not in _code(body), "the alignment never pushes"
|
||
|
||
|
||
def test_anonymous_branch_installs_no_navigation_listeners() -> None:
|
||
"""Phase 99 (task 02): the anonymous gate installs NO listeners at
|
||
all — no popstate, no refresh (the phase-16 rule: anonymous shows
|
||
the gate, fetches nothing, and owns no drill state — both
|
||
listeners are armed only in the admin branch)."""
|
||
js = _js()
|
||
start = js.find("if (!admin) {")
|
||
end = js.find("if (gateEl) gateEl.hidden = true;")
|
||
assert -1 < start < end, "the anonymous branch must exist"
|
||
branch = js[start:end]
|
||
assert "addEventListener" not in branch, (
|
||
"the anonymous branch arms no listeners (no popstate, no refresh)"
|
||
)
|
||
|
||
|
||
def test_module_docstring_documents_the_history_integration() -> None:
|
||
"""The house per-phase module-note convention: the phase-99
|
||
task-02 section records the push/adopt/reset rules — the
|
||
STATE-ONLY push (the URL STAYS PUT, no new route — the phase-76
|
||
deep-link surface untouched), the duplicate-push skip, the
|
||
popstate adopt/reset, the refresh alignment before the re-fetch,
|
||
the no-boot-push contract, the UNCHANGED (no-push)
|
||
resetVanishedLocation, and the UNTOUCHED router module."""
|
||
js = _js()
|
||
header = js[: js.find("import { fetchIsAdmin }")]
|
||
assert "Phase 99 (task 02, D2)" in header
|
||
assert "STATE-ONLY" in header, "the state-only push is documented"
|
||
assert "STAYS PUT" in header, "the URL-stays-put note"
|
||
assert "no duplicate history" in header, "the duplicate-push skip"
|
||
assert "ADOPTS" in header and "popstate" in header, "the popstate adopt"
|
||
assert "RESETS the drill to the TOP level" in header, "the reset branch"
|
||
assert "BEFORE the re-fetch" in header, "the refresh alignment"
|
||
assert "Boot pushes NOTHING" in header, "no pushState on boot"
|
||
assert "resetVanishedLocation is UNCHANGED" in header
|
||
assert "module is UNTOUCHED" in header, "the router module is untouched"
|