phase: 98_sync_summary_visibility
All verification complete. Final report: **Phase 98 — Sync summary visibility: final verification pass** (all 5 tasks already complete; implementation verified against the design, no defects found, no code changes needed) - **Implementation checked:** `SyncStatus` phase machine (4 new keys, terminal-keep counts), `on_progress` hook in `generate_folder_summaries`, `summary_pending` on `KbTreeSource`/`KbTreeFolder` + D3 rule in `build_kb_tree`, phase-aware sync labels + pending UI in `sources.js`, `.kb-summary-pending` CSS — all match decisions D1–D5. - **Unit + integration:** `uv run pytest` → 2184 tests, 0 failed/errors (exit 0) - **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (criterion >90% ✓; `app/api/sync.py` and `app/rag/folder_summaries.py` at 100%) - **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings - **Phase E2E (isolation):** `uv run pytest tests/e2e/test_sync_summary_visibility.py -v --no-cov` → **3 passed** (phase machine, live label, pending markers + gap-fill self-heal) - **Regression suites (each isolated, `--no-cov`):** test_kb_tree ✓, test_ls_tree_drilldown 3 ✓, test_sync_button 3 ✓, test_sync_upload_progress 4 ✓, test_oneshot_llm_retry 2 ✓, test_local_directory_sources 3 ✓ - **Completion criteria:** all 7 verified green — status phase fields + terminal semantics; `Writing KB overview…`/`Summarizing folders… (n/m)` labels (title + aria-live); pending set == `missing_folder_summaries` (integration cross-check pinned at `test_docs_api.py:428`); CLI/`ls` byte-identity (no changes to those paths, pins green); suite/coverage/lint gates; dedicated + regression E2E. Commit left to the harness per protocol (no `git add`/`commit` run). - **Decisions/deviations:** none — no fixes were required this pass. - **Next pending phase:** `99_kb_tree_table_and_back_nav`.
This commit is contained in:
+221
-21
@@ -68,6 +68,39 @@ affordance, mirrored) — the source pins this module gains for it:
|
||||
``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
|
||||
|
||||
@@ -358,27 +391,45 @@ def test_top_level_lists_sources_and_hides_the_file_table() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_level_block_shows_the_stored_description_only() -> None:
|
||||
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) —
|
||||
and is HIDDEN when none is stored (the ls rule: count only, no
|
||||
placeholder)."""
|
||||
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(")]
|
||||
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)]
|
||||
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
|
||||
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("if (node.summary) {")) :]
|
||||
assert "levelEl.hidden = true;" in hidden[:200], "no stored row → the block is hidden"
|
||||
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:
|
||||
@@ -595,8 +646,9 @@ def test_tree_cells_never_use_innerhtml_with_derived_data() -> None:
|
||||
for sink in (
|
||||
"link.textContent = s.name",
|
||||
"link.textContent = f.path.split(\"/\").pop()",
|
||||
'text.textContent = (node && node.summary) || ""',
|
||||
"levelSummaryEl.textContent = node.summary;",
|
||||
"text.textContent = node.summary",
|
||||
'text.textContent = "Summary pending"',
|
||||
"levelSummaryEl.textContent = node.summary ||",
|
||||
"a.textContent = label",
|
||||
"span.textContent = label",
|
||||
"countTd.textContent = String(s.documents)",
|
||||
@@ -686,30 +738,135 @@ def test_level_block_ships_the_static_edit_button() -> None:
|
||||
|
||||
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
|
||||
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
|
||||
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 }."""
|
||||
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 }."""
|
||||
body = _fn(_js(), "makeDescCell")
|
||||
assert 'text.textContent = (node && node.summary) || ""' in body, (
|
||||
"the stored description is a text node (or an empty cell)"
|
||||
# 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("td.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 "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_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.className = "kb-summary-pending"' in pending
|
||||
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.className = pending ? "kb-summary-pending" : "";')
|
||||
title_i = close.find("if (pendingTitle) textEl.title = pendingTitle;")
|
||||
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 < clear_title_i < render_i, (
|
||||
"flag → value → class → title (set/clear) → text, in order"
|
||||
)
|
||||
# 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
|
||||
@@ -1012,3 +1169,46 @@ def test_styles_carry_the_folder_editor_classes() -> None:
|
||||
# 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"
|
||||
|
||||
Reference in New Issue
Block a user