phase: 98_sync_summary_visibility
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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:
2026-09-13 00:23:05 -04:00
parent 909c96c7bc
commit f665a83b1a
39 changed files with 3265 additions and 112 deletions
+146 -2
View File
@@ -48,7 +48,9 @@ class _FakeLLM:
Records each ``(system, user)`` request and the ``model`` kwarg;
returns the canned reply, or raises — either a fixed exception or a
per-folder failure keyed on the user message's ``Folder: …`` header
per-folder failure keyed on the user message's FIRST LINE (the
``Folder: …`` header) — EXACT match, so a nested folder's header
(``Folder: S/a/b``) can never shadow its parent's (``Folder: S/a``)
(the per-folder fail-soft tests).
"""
@@ -74,8 +76,9 @@ class _FakeLLM:
system = messages[0]["content"]
user = messages[-1]["content"]
self.requests.append((system, user))
header = user.splitlines()[0] if user else ""
for folder in self._fail_folders:
if FOLDER_HEADER_PREFIX + folder in user:
if header == FOLDER_HEADER_PREFIX + folder:
raise LLMError(f"simulated lite-model failure for {folder}")
if self._fail is not None:
raise self._fail
@@ -916,3 +919,144 @@ def test_gap_probe_subsumes_the_table_empty_gate(db: Session, clean_tables) -> N
db.execute(text("DELETE FROM folder_summaries"))
db.commit()
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")] # emptied again
# ---------- on_progress hook (phase 98, task 01) ----------
def _record_progress() -> tuple[list[tuple[int, int, str, str]], Any]:
"""A fresh event list + the ``on_progress`` recorder that appends
every ``(done, total, source, folder_path)`` event it receives."""
events: list[tuple[int, int, str, str]] = []
def record(done: int, total: int, source: str, folder_path: str) -> None:
events.append((done, total, source, folder_path))
return events, record
def test_on_progress_fires_once_per_candidate_in_sorted_key_order(
db: Session, clean_tables
) -> None:
"""Phase 98 (task 01): the hook fires once per candidate, in the
same sorted ``(source, folder_path)`` order the folders are
attempted — done climbs 1..total, total = the candidate count
(the single-doc FSU-solo root is not a candidate — no event)."""
_seed_catalogue(db)
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 3, "FSU", ""),
(2, 3, "FSU", "a"),
(3, 3, "FSU", "a/b"),
]
assert llm.calls == 3 # one event per attempt, in the same order
assert stats["generated"] == 3
def test_on_progress_manual_skip_still_advances(db: Session, clean_tables) -> None:
"""A manual-skip key is an INSTANT skip — no ``lite`` call burns,
but the counter still advances for it (D5: the UI's position moves
on either outcome)."""
_seed_catalogue(db)
db.add(
FolderSummary(
source="FSU", folder_path="a", summary="owner text",
manually_edited=True,
)
)
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 3, "FSU", ""),
(2, 3, "FSU", "a"), # the instant manual skip still advances
(3, 3, "FSU", "a/b"),
]
assert llm.calls == 2, "the skip itself burns no call"
assert stats["kept_manual"] == 1 and stats["generated"] == 2
def test_on_progress_failed_key_still_advances(db: Session, clean_tables) -> None:
"""A failed (``LLMError``) key still advances — the hook fires
BEFORE the attempt, so a fail-soft miss is visible to the UI as a
completed step (the run never flips to failed, neither does the
counter stall)."""
_seed_catalogue(db)
llm = _FakeLLM(fail_folders=("FSU/a",))
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 3, "FSU", ""),
(2, 3, "FSU", "a"), # the failed attempt still advances
(3, 3, "FSU", "a/b"),
]
assert stats["failed"] == 1 and stats["generated"] == 2
def test_on_progress_only_missing_reports_the_missing_count(
db: Session, clean_tables
) -> None:
"""Under ``only_missing=True`` ``total`` is the MISSING count (the
loop-start count after the missing filter, not the full candidate
count) and the events name exactly the missing keys, sorted."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.execute(
text(
"DELETE FROM folder_summaries"
" WHERE (source, folder_path) IN (('FSU', ''), ('FSU', 'a/b'))"
)
)
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(
generate_folder_summaries(db, llm, only_missing=True, on_progress=record)
)
assert events == [
(1, 2, "FSU", ""),
(2, 2, "FSU", "a/b"),
]
assert llm.calls == 2
assert stats["generated"] == 2
def test_on_progress_skip_true_fires_zero_calls(db: Session, clean_tables) -> None:
"""``skip=True`` (the ``--limit`` debug run) returns before the
loop — the hook never fires."""
_seed_catalogue(db)
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True, on_progress=record))
assert events == []
assert llm.calls == 0
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
def test_on_progress_none_is_a_zero_cost_noop(db: Session, clean_tables) -> None:
"""``on_progress=None`` (the ``scripts/import_docs.py`` CLI path)
behaves byte-identically to today: the fake LLM's call log, the
stats dict, and the stored rows are identical across a hooked and
a hook-less run."""
_seed_catalogue(db)
hooked = _FakeLLM()
events, record = _record_progress()
stats_hooked = asyncio.run(
generate_folder_summaries(db, hooked, on_progress=record)
)
requests_hooked = hooked.requests.copy()
assert len(events) == 3, "the hooked run fired (the contrast is real)"
db.rollback() # the generator only flushes — drop the uncommitted rows
plain = _FakeLLM()
stats_plain = asyncio.run(generate_folder_summaries(db, plain, on_progress=None))
assert plain.requests == requests_hooked, "the fake LLM's call log is unchanged"
assert stats_plain == stats_hooked
assert _rows(db) == {
("FSU", ""): REPLY, ("FSU", "a"): REPLY, ("FSU", "a/b"): REPLY
}
+148 -23
View File
@@ -34,6 +34,18 @@ settled result line is
off the status's {"message": "uploaded"} detail; the nameless variant
after a reload / on the 409 re-attach), and the success announce
names the next step.
Phase 98 (task 02) pins the SYNC job's phase-aware label (D2):
fmtSyncPhaseLabel — "overview" → exactly "Writing KB overview…",
"summaries" → "Summarizing folders… <current_summary> (n/m)" (the
folder part omitted while current_summary is null → the bare
"Summarizing folders… (n/m)"), anything else → the byte-identical
phase-64 fmtSyncLabel("sync", …) fall-through — plus the status-object
threading (the tick's branch 1 + the initSyncButton running re-attach
pass the WHOLE status; the upload job and the 202/409 click stay on
the bare label — the upload status has no phase) and the A4 title-rule
adjustment (the full untruncated label rides the button title for
EVERY running label — the file-only rule is gone).
"""
from __future__ import annotations
@@ -182,30 +194,86 @@ def test_fmt_sync_label_signature_and_prefixes() -> None:
# ---------- enterSyncRunningState: full path to title + announcer ----------
def test_enter_running_state_writes_full_path_to_title_and_announcer() -> None:
def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
"""fmtSyncPhaseLabel(status) — the SYNC job's phase-aware label
(phase 98 task 02, D2): "overview" → exactly "Writing KB
overview…" (no folder, no counts); "summaries" →
"Summarizing folders… <current_summary> (n/m)" — the folder part
(one leading space + current_summary) is omitted while
current_summary is null (the phase's first poll → the bare
"Summarizing folders… (n/m)"); anything else (the null prelude,
"import") → the phase-64 fmtSyncLabel fall-through, byte-identical
(same builder, same fields, same order). The UPLOAD job never
enters here — its status has no phase (D2)."""
body = _fn(_js(), "fmtSyncPhaseLabel")
assert "function fmtSyncPhaseLabel(status)" in body
# "overview": the exact copy, no folder, no counts.
i_ov = body.find('status.phase === "overview"')
i_sum = body.find('status.phase === "summaries"')
assert -1 < i_ov < i_sum
ov = body[i_ov:i_sum]
assert 'return "Writing KB overview…";' in ov
assert "current_summary" not in ov and "summaries_done" not in ov, (
"the overview label carries no folder and no counts"
)
# "summaries": the exact template — optional folder, counts always.
i_fall = body.find("return fmtSyncLabel")
assert i_sum < i_fall, "the summaries branch precedes the fall-through"
sum_ = body[i_sum:i_fall]
assert 'status.current_summary ? ` ${status.current_summary}` : ""' in sum_, (
"the folder part — one leading space + the folder, or nothing"
)
assert (
"return `Summarizing folders…${folder} "
"(${status.summaries_done}/${status.summaries_total})`;"
) in sum_, "the exact D2 copy (with and without the folder)"
# anything else (null prelude, "import"): byte-identical to today's
# sync label — same builder, same fields, same order.
assert (
'return fmtSyncLabel("sync", status.current_file, '
"status.files_done, status.files_total);"
) in body[i_fall:]
def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
"""The running-state entry keeps the §7.4 never-stale mechanics
(disabled, aria-busy, spinning icon, the stale .is-error removed)
and — phase 64 — writes the FULL untruncated current file to the
button title (removed when null: no file yet) and the full
untruncated fmtSyncLabel text to BOTH the label span and
#sync-result (the aria-live announcer reads the full live path;
CSS only ellipsizes the button's span)."""
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
a sync-kind call carrying the status object takes the
fmtSyncPhaseLabel form, while the upload job (no status — its
endpoint has no phase) and the 202/409 click keep the bare
fmtSyncLabel label, byte-identical. The FULL untruncated label —
whatever its form — rides the button title (set for EVERY running
label: the phase-64 "title only when there is a current file"
rule is adjusted away) and #sync-result (the aria-live announcer);
CSS ellipsizes only the label span."""
body = _fn(_js(), "enterSyncRunningState")
assert (
"function enterSyncRunningState(kind, currentFile, done, total, status)"
in body
)
assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
assert "syncIcon.classList.add(\"is-spinning\")" in body
assert "syncBtn.classList.remove(\"is-error\")" in body
assert "syncBtn.title = currentFile" in body, "the full path on hover"
assert 'syncBtn.removeAttribute("title")' in body, "removed when no file yet"
i_set = body.find("if (currentFile) syncBtn.title = currentFile")
i_remove = body.find('syncBtn.removeAttribute("title")')
assert -1 < i_set < i_remove, "title is set (not removed) only when a file exists"
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
i_label = body.find("fmtSyncLabel(kind, currentFile, done, total)")
# the phase-aware split: sync + status → the D2 builder; the
# fall-through (upload / the click, no status) is the byte-
# identical phase-64 builder.
i_phase = body.find("fmtSyncPhaseLabel(status)")
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
assert 'kind === "sync" && status' in body
assert -1 < i_phase < i_fall, "the phase label precedes the fall-through"
# A4: the untruncated label rides the title for EVERY running label
# (the remove-when-no-file rule is gone) and #sync-result.
assert "syncBtn.title = label" in body, "the full label on hover, always"
assert 'syncBtn.removeAttribute("title")' not in body, (
"the title is set for every running label (D2: not just file labels)"
)
i_title = body.find("syncBtn.title = label")
i_span = body.find("syncLabel.textContent = label")
i_result = body.find("syncResult.textContent = label")
assert -1 < i_label < i_span < i_result, (
"one label: built once, written to the span AND the announcer"
assert -1 < i_title < i_span < i_result, (
"one label: built once, written to title, span AND the announcer"
)
@@ -327,14 +395,16 @@ def test_click_branch_enters_sync_running_without_a_file() -> None:
def test_reattach_adopts_a_running_upload_only() -> None:
"""initSyncButton: the sync branches are unchanged (running
re-enters with the live file; the terminals render the last
result). With the sync IDLE it fetches the upload status: a RUNNING
upload run re-attaches (running state, upload kind — phase 90: no
live file, the label stays bare "Importing…" — the synthetic
running frame, the poll starts); a terminal upload is a no-op —
the fall-through is the plain idle settle (the boot-time loadTree()
already shows the current catalog)."""
"""initSyncButton: the sync terminals render the last result; the
RUNNING sync re-enters with the live file AND the whole status
(phase 98 task 02: the label is phase-aware — a mid-summaries
reload re-enters with the summaries label). With the sync IDLE it
fetches the upload status: a RUNNING upload run re-attaches
(running state, upload kind — phase 90: no live file, the label
stays bare "Importing…" — the synthetic running frame, the poll
starts); a terminal upload is a no-op — the fall-through is the
plain idle settle (the boot-time loadTree() already shows the
current catalog)."""
body = _fn(_js(), "initSyncButton")
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
assert 'fetch("/api/git-sources/upload/status")' in body
@@ -356,6 +426,61 @@ def test_reattach_adopts_a_running_upload_only() -> None:
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
def test_tick_sync_running_passes_the_status_for_the_phase_label() -> None:
"""Branch 1 of the two-job tick (phase 98 task 02): the sync
running state passes the WHOLE syncStatus object as the fifth
argument — the phase-aware label (overview / summaries) reads its
phase fields. The upload running branch stays on the BARE four-arg
label (its status has no phase — D2): no fifth argument, no status
object anywhere in the branch."""
tick = _tick(_js())
b_sync_run = tick.find('syncStatus.state === "running"')
b_up_run = tick.find('uploadStatus && uploadStatus.state === "running"')
b_sync_ok = tick.find('syncStatus.state === "success"')
assert -1 < b_sync_run < b_up_run < b_sync_ok
branch1 = tick[b_sync_run:b_up_run]
i_call = branch1.find("enterSyncRunningState(")
call = branch1[i_call:branch1.find(");", i_call)]
assert re.search(
r'"sync",\s*syncStatus\.current_file,\s*syncStatus\.files_done,\s*'
r"syncStatus\.files_total,\s*syncStatus\s*$",
call,
), "branch 1 passes the whole status (fifth arg) for the phase-aware label"
branch2 = tick[b_up_run:b_sync_ok]
i_call2 = branch2.find("enterSyncRunningState(")
call2 = branch2[i_call2:branch2.find(");", i_call2)]
assert re.fullmatch(
r'enterSyncRunningState\(\s*"upload",\s*uploadStatus\.current_file,\s*'
r"uploadStatus\.files_done,\s*uploadStatus\.files_total\s*$",
call2,
), "the upload job keeps the bare four-arg label — no status, no phase (D2)"
assert "phase" not in call2 and "summaries" not in call2
def test_reattach_sync_running_passes_the_status_for_the_phase_label() -> None:
"""initSyncButton (phase 98 task 02): the RUNNING sync re-attach
passes the WHOLE status object — a reload mid-summaries re-enters
the running state with the summaries label (the never-stale
contract); the RUNNING upload re-attach stays on the bare four-arg
label (its status has no phase — D2)."""
body = _fn(_js(), "initSyncButton")
i_sync = body.find('"sync", status.current_file')
assert i_sync != -1
call = body[i_sync:body.find(");", i_sync)]
assert re.search(
r'"sync",\s*status\.current_file,\s*status\.files_done,\s*'
r"status\.files_total,\s*status\s*$",
call,
), "the RUNNING sync re-attach passes the status object (fifth arg)"
i_up = body.find('"upload", upload.current_file')
assert i_up != -1
call2 = body[i_up:body.find(");", i_up)]
assert re.fullmatch(
r'"upload",\s*upload\.current_file,\s*upload\.files_done,\s*upload\.files_total\s*$',
call2,
), "the RUNNING upload re-attach keeps the bare four-arg label (D2)"
# ---------- the section header + the page comment ----------
+132
View File
@@ -283,3 +283,135 @@ def test_cross_check_nested_level_matches_group_folder_listing() -> None:
("one/a.md", "A"),
("one/b.md", "B"),
]
# --------------------------------------------------------------------
# summary_pending (phase 98, task 03) — the D3 rule (ONE concept):
# a SOURCE or FOLDER node is pending iff its recursive document count
# ≥ MIN_DOCS_PER_FOLDER (2) AND it has NO stored folder_summaries row
# (AI or manual — any row). That is exactly
# ``app.rag.folder_summaries.missing_folder_summaries``'s candidate
# set — the marker never drifts from the gap-fill (the integration
# cross-check in ``tests/integration/test_docs_api.py`` pins it
# end to end). FILE nodes carry no flag.
# --------------------------------------------------------------------
def test_folder_two_docs_no_stored_row_is_pending() -> None:
"""A ≥ 2-doc folder with NO stored row → ``summary_pending`` true
(the marker's "waiting to generate" semantics); file nodes carry
no flag at all (the file table has no description column)."""
rows = [
("S", "one/a.md", "A", 1, T0),
("S", "one/b.md", "B", 1, T0),
]
(source,) = build_kb_tree(["S"], rows, {})
(one,) = _folder_nodes(source)
assert one.documents == 2
assert one.summary is None
assert one.summary_pending is True
file = _file_nodes(one)[0]
assert "summary_pending" not in file.__class__.model_fields
assert "summary_pending" not in file.model_dump()
def test_folder_with_stored_row_is_not_pending() -> None:
"""The same ≥ 2-doc folder WITH a stored row — ANY row, the builder
cannot tell AI from manual — is NOT pending (a description exists).
A row on the folder does not cover the source root: with no
``(source, "")`` row the SOURCE node stays pending."""
rows = [
("S", "one/a.md", "A", 1, T0),
("S", "one/b.md", "B", 1, T0),
]
(source,) = build_kb_tree(["S"], rows, {("S", "one"): "Manual."})
(one,) = _folder_nodes(source)
assert one.summary == "Manual."
assert one.summary_pending is False
# The source root (2 docs) has no (source, "") row of its own.
assert source.summary is None
assert source.summary_pending is True
def test_single_document_folder_never_pending() -> None:
"""A < 2-document folder is NEVER pending (it never gets a summary
— its one file line IS its description), even with no stored row
— while its ≥ 2-doc source root (no root row) still is."""
rows = [
("S", "solo/only.md", "Only", 1, T0), # 1-doc folder
("S", "top.md", "Top", 1, T0), # source total = 2
]
(source,) = build_kb_tree(["S"], rows, {})
(solo,) = _folder_nodes(source)
assert solo.documents == 1
assert solo.summary_pending is False
assert source.summary_pending is True
def test_name_collision_pending_follows_recursive_count() -> None:
"""The phase-94 count-rule edge: documents ``one/a`` AND ``one/a/b``
→ folder ``one/a`` exists and its recursive count is 2 (the document
whose path EQUALS the folder name counts — the ``path == folder``
arm) → pending true with no stored row, even though the folder has
only ONE direct file — pending follows the RECURSIVE count, not
the number of direct children. A stored row on the NESTED folder
alone clears only that marker (the rule is per node)."""
rows = [
("S", "one/a", "File A", 1, T0), # a file wearing the folder's name
("S", "one/a/b.md", "B", 1, T0), # makes ``one/a`` a folder
]
(source,) = build_kb_tree(["S"], rows, {})
one = _folder_nodes(source)[0]
assert one.path == "one"
assert one.documents == 2
assert one.summary_pending is True
a = _folder_nodes(one)[0]
assert a.path == "one/a"
assert a.documents == 2 # "one/a" itself + "one/a/b.md"
assert len(_file_nodes(a)) == 1 # ONE direct file — the count is not that
assert a.summary_pending is True
# Stored row on the nested folder only: it clears that node, and
# only that node.
(nested,) = build_kb_tree(["S"], rows, {("S", "one/a"): "Nested."})
one2 = _folder_nodes(nested)[0]
a2 = _folder_nodes(one2)[0]
assert a2.summary == "Nested."
assert a2.summary_pending is False
assert one2.summary_pending is True
def test_source_root_pending_and_zero_document_source_never() -> None:
"""The source root: a source with ≥ 2 docs and NO ``(source, "")``
row → the SOURCE node is pending; the stored root row clears it.
A registered 0-document source is NEVER pending (0 < the minimum —
there is nothing to summarize), with or without a manual row."""
rows = [
("Full", "x/1.md", "1", 1, T0),
("Full", "y.md", "Y", 1, T0),
]
full, empty = build_kb_tree(["Full", "Empty"], rows, {})
assert full.documents == 2
assert full.summary_pending is True
assert empty.documents == 0
assert empty.summary_pending is False
full2, empty2 = build_kb_tree(
["Full", "Empty"], rows, {("Full", ""): "Root desc."}
)
assert full2.summary == "Root desc."
assert full2.summary_pending is False
assert empty2.summary_pending is False
def test_two_sources_pending_independently() -> None:
"""Pending is computed per source: with one source holding a root
row and the other not, only the rowless source's node is pending —
the markers never leak across sources."""
rows = [
("A", "a1.md", "A1", 1, T0),
("A", "a2.md", "A2", 1, T0),
("B", "b1.md", "B1", 1, T0),
("B", "b2.md", "B2", 1, T0),
]
a, b = build_kb_tree(["A", "B"], rows, {("A", ""): "A root."})
assert (a.summary, a.summary_pending) == ("A root.", False)
assert (b.summary, b.summary_pending) == (None, True)
+221 -21
View File
@@ -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"
+67 -13
View File
@@ -21,6 +21,19 @@ null/0/0); mid-run the status reports the file the (mock) import is
processing, through the runner's own hook closure (no file yet during
the clone/pull phase — A4); the terminal states clear
``current_file`` while keeping the run's final counts.
Phase 98 (task 01): the status's phase machine joins the pins — the
four new keys (``phase`` / ``current_summary`` / ``summaries_done`` /
``summaries_total``) ride along as null/0/0/0 idle, the prelude keeps
``phase: null``, the import reports ``phase: "import"``, and the
terminal states clear ``phase`` + ``current_summary`` while keeping
the run's final summary counts.
Phase 98 (task 02): the running-state title rule is adjusted — the
FULL untruncated LABEL (the file label, "Writing KB overview…", or
"Summarizing folders… <folder> (n/m)") rides the button title for
EVERY running label; the phase-64 "title only when there is a current
file" (remove-when-null) rule is gone.
"""
from __future__ import annotations
@@ -256,18 +269,29 @@ def test_sources_js_running_state_is_never_stale() -> None:
fresh run starts clean: the previous failure's title / aria-label /
.is-error come off NOW, not when the run settles.
Phase 64 (task 04): the button title carries the FULL untruncated
current file (removed when null — no file yet) and #sync-result
(the aria-live announcer) carries the same untruncated label."""
Phase 64 (task 04): #sync-result (the aria-live announcer) carries
the FULL untruncated label. Phase 98 (task 02): the title rule is
adjusted — the FULL untruncated LABEL (the file label, the overview
label, or the summaries label — whatever the running form is) rides
the button title for EVERY running label; the "title only when
there is a current file" (remove-when-null) rule is gone."""
js = _text(SOURCES_JS)
body = _body(js, "enterSyncRunningState")
assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
assert "syncBtn.title = currentFile" in body, "the full path on hover"
assert 'syncBtn.removeAttribute("title")' in body, "removed when no file yet"
title_if = body.find("if (currentFile) syncBtn.title = currentFile")
title_else = body.find("syncBtn.removeAttribute(\"title\")")
assert -1 < title_if < title_else, "title is set, not removed, only when a file exists"
assert "syncBtn.title = label" in body, (
"the full untruncated label on hover, for every running label"
)
assert 'syncBtn.removeAttribute("title")' not in body, (
"the title is set for EVERY running label (phase 98: the file-only "
"rule is adjusted away)"
)
title_i = body.find("syncBtn.title = label")
span_i = body.find("syncLabel.textContent = label")
result_i = body.find("syncResult.textContent = label")
assert -1 < title_i < span_i < result_i, (
"one label: built once, written to title, span AND the announcer"
)
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
assert "syncBtn.classList.remove(\"is-error\")" in body
assert "syncIcon.classList.add(\"is-spinning\")" in body
@@ -793,7 +817,12 @@ def _patch_sync_seams(
*,
skip: bool = False,
only_missing: bool = False,
on_progress: Callable[[int, int, str, str], None] | None = None,
) -> dict[str, int]:
# Phase 98 (task 01): the runner wires the progress hook into
# the generation branches — the fake accepts it (the state-
# machine tests' canned run never fires it: zero summary
# counters, like a no-gap skip).
return {"generated": 0, "failed": 0, "pruned": 0}
monkeypatch.setattr(sync_api, "generate_folder_summaries", fake_folder_summaries)
@@ -835,8 +864,9 @@ def test_idle_status_pins_full_shape_including_progress_keys(
fresh_sync_status: None,
) -> None:
"""Idle: the three phase-64 progress keys ride along as null/0/0,
and EVERY pre-existing key is unchanged — the full response dict is
pinned, so the current UI and every existing consumer keep working."""
the four phase-98 phase keys as null/0/0/0, and EVERY pre-existing
key is unchanged — the full response dict is pinned, so the
current UI and every existing consumer keep working."""
assert sync_api.sync_status() == {
"state": "idle",
"started_at": None,
@@ -846,6 +876,11 @@ def test_idle_status_pins_full_shape_including_progress_keys(
"current_file": None,
"files_done": 0,
"files_total": 0,
# Phase 98 (task 01): the phase-machine keys — null/0/0/0 idle.
"phase": None,
"current_summary": None,
"summaries_done": 0,
"summaries_total": 0,
}
@@ -869,19 +904,29 @@ def test_mid_run_status_reports_current_file(
thread, errors = _start_run()
try:
assert clone.started.wait(5.0), "the run never reached the clone phase"
# Clone/pull phase: running — but no file yet (A4: bare "Syncing…").
# Clone/pull prelude: running — but no file yet (A4: bare
# "Syncing…") and no phase yet either (D1: the prelude stays
# null — the bare label's pin).
s = sync_api.sync_status()
assert s["state"] == "running"
assert s["phase"] is None
assert s["current_file"] is None
assert s["current_summary"] is None
assert s["files_done"] == 0 and s["files_total"] == 0
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
clone.release.set()
assert fake_import.started.wait(5.0), "the run never reached the import"
# Import phase: the hook's file is live on the status.
# Import phase: the hook's file is live on the status, and the
# phase machine names the phase (the summary span has not
# started — its counters are still 0/0).
s = sync_api.sync_status()
assert s["state"] == "running"
assert s["phase"] == "import"
assert s["current_file"] == "repo/notes/deep.md"
assert s["files_done"] == 1
assert s["files_total"] == 3
assert s["current_summary"] is None
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
fake_import.release.set()
finally:
clone.release.set()
@@ -894,11 +939,17 @@ def test_mid_run_status_reports_current_file(
# assigned to the status above).
assert fake_import.prune_flags == [True]
assert fake_import.hook_calls == [("repo", "notes/deep.md", 1, 3)]
# Success terminal: current_file null, final counts retained.
# Success terminal: current_file null, final file counts retained;
# the phase machine clears phase + current_summary — the fake
# folder step never fired the summary hook, so the summary counts
# stay 0/0 (the keep-final-counts convention keeps what fired).
s = sync_api.sync_status()
assert s["state"] == "success"
assert s["phase"] is None
assert s["current_summary"] is None
assert s["current_file"] is None
assert s["files_done"] == 1 and s["files_total"] == 3
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
assert s["error"] is None
assert s["started_at"] is not None and s["finished_at"] is not None
@@ -942,7 +993,10 @@ def test_failed_terminal_clears_current_file_keeps_counts(
s = sync_api.sync_status()
assert s["state"] == "failed"
assert s["current_file"] is None # cleared in the terminal state
assert s["phase"] is None # phase 98: cleared in the terminal state
assert s["current_summary"] is None
assert s["files_done"] == 1 and s["files_total"] == 3 # final counts kept
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
assert s["detail"] == {}
error = s["error"] or ""
assert "*****@aipi.example.com" in error # credentials masked