Files
brain-of-reese/tests/unit/test_kb_tree_builder.py
T
ducoterra f665a83b1a
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 11s
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`.
2026-09-13 00:23:05 -04:00

418 lines
17 KiB
Python

"""Unit tests: ``app.api.docs.build_kb_tree`` (phase 97, task 02).
The PURE tree builder behind ``GET /api/docs/tree`` — the RAG view's
drill-down tree, the same tree the agent's ``ls`` walks plus file
metadata. Driven without a database (module-level function, plain
inputs): multi-source ordering (the superset rule), the 0-document
registered source, the nested recursive counts, the phase-94 existence
rule, the file/folder name collision, ordering, summaries, verbatim
file metadata, and the ``group_folder_listing`` cross-check property
("the UI shows what the agent sees") at the root and one nested level.
"""
from __future__ import annotations
from app.api.docs import TreeDocRow, build_kb_tree
from app.rag.agent import group_folder_listing
T0 = "2026-09-01T08:00:00+00:00"
T1 = "2026-09-02T08:00:00+00:00"
T2 = "2026-09-03T08:00:00+00:00"
def _folder_nodes(node) -> list:
"""The folder-kind children of a source/folder node, in order."""
return [child for child in node.children if child.kind == "folder"]
def _file_nodes(node) -> list:
"""The file-kind children of a source/folder node, in order."""
return [child for child in node.children if child.kind == "file"]
def test_multi_source_registry_order_leads_and_indexed_only_appended() -> None:
"""Sources = the registry names in order, then the distinct
indexed-only sources in alphabetical order (the superset rule)."""
names = ["beta", "alpha", "empty"] # registry order — NOT alphabetical
doc_rows: list[TreeDocRow] = [
("beta", "b.md", "B", 1, T0),
("alpha", "a.md", "A", 1, T0),
("gamma", "g.md", "G", 1, T0), # indexed-only → appended
("delta", "d.md", "D", 1, T0), # indexed-only → appended
]
tree = build_kb_tree(names, doc_rows, {})
assert [s.name for s in tree] == ["beta", "alpha", "empty", "delta", "gamma"]
# The registry sources keep their registry order even though it is
# not alphabetical; the indexed-only ones trail, sorted.
assert tree[0].name == "beta"
assert tree[1].name == "alpha"
assert tree[3].name == "delta"
assert tree[4].name == "gamma"
def test_registered_zero_document_source_lists_empty() -> None:
"""A registered source with no indexed documents lists (the
phase-70/72 invariant): ``documents: 0``, no children, summary null
when nothing is stored."""
tree = build_kb_tree(["solo"], [], {})
assert len(tree) == 1
assert tree[0].name == "solo"
assert tree[0].documents == 0
assert tree[0].children == []
assert tree[0].summary is None
def test_nested_document_counts_into_source_ancestors_and_own_folder() -> None:
"""A document under ``a/b/c/`` contributes to the source, to ``a``,
to ``a/b``, and to ``a/b/c`` (the recursive subtree, the phase-94
``ls`` count rule)."""
rows = [
("S", "a/b/c/deep.md", "Deep", 1, T0),
("S", "a/b/shallow.md", "Shallow", 1, T0),
("S", "a/top.md", "Top", 1, T0),
("S", "root.md", "Root", 1, T0),
]
(source,) = build_kb_tree(["S"], rows, {})
assert source.documents == 4
a = _folder_nodes(source)[0]
assert a.path == "a"
assert a.documents == 3 # deep + shallow + top
a_b = _folder_nodes(a)[0]
assert a_b.path == "a/b"
assert a_b.documents == 2 # deep + shallow
a_b_c = _folder_nodes(a_b)[0]
assert a_b_c.path == "a/b/c"
assert a_b_c.documents == 1 # deep only
assert [f.path for f in _file_nodes(a_b_c)] == ["a/b/c/deep.md"]
assert [f.path for f in _file_nodes(a)] == ["a/top.md"]
def test_existence_rule_a_file_path_is_never_a_folder() -> None:
"""A folder node appears only with a true descendant (some path
starts with ``folder + "/"``); a document's own path — even one
with dots — never creates a folder."""
rows = [
("S", "x.md", "X", 1, T0),
("S", "x.y/z.md", "Z", 1, T0),
]
(source,) = build_kb_tree(["S"], rows, {})
folders = _folder_nodes(source)
# Only ``x.y`` exists (``x.y/z.md`` starts with ``x.y/``); ``x`` and
# ``x.md`` are file names, not folders.
assert [f.path for f in folders] == ["x.y"]
assert [f.path for f in _file_nodes(source)] == ["x.md"]
assert [f.path for f in _file_nodes(folders[0])] == ["x.y/z.md"]
def test_file_folder_name_collision_both_appear() -> None:
"""A document sharing a directory's name: BOTH appear — the folder
node (via its descendants) and the file node (its own row); the
colliding file counts into the folder's subtree (the ``ls`` count
rule's ``path == folder`` arm)."""
rows = [
("S", "a", "File A", 1, T0), # a file wearing the folder's name
("S", "a/b.md", "B", 1, T0), # makes ``a`` a folder
]
(source,) = build_kb_tree(["S"], rows, {})
folders = _folder_nodes(source)
files = _file_nodes(source)
assert [f.path for f in folders] == ["a"]
assert folders[0].documents == 2 # "a" itself + "a/b.md"
assert [f.path for f in files] == ["a"]
assert files[0].title == "File A"
assert [f.path for f in _file_nodes(folders[0])] == ["a/b.md"]
def test_subfolder_path_order_and_file_catalog_order() -> None:
"""Direct subfolders list in path (sorted) order; direct files keep
the input (catalog — ``GET /api/docs``) order, independent of the
subfolder ordering."""
rows = [
("S", "zeta/z1.md", "Z1", 1, T0),
("S", "alpha/a1.md", "A1", 1, T0),
("S", "mike/m1.md", "M1", 1, T0),
("S", "beta/b1.md", "B1", 1, T0),
("S", "z-file.md", "Z", 1, T0), # file AFTER the folders in input
("S", "a-file.md", "A", 1, T0), # file before it in input
]
(source,) = build_kb_tree(["S"], rows, {})
assert [f.path for f in _folder_nodes(source)] == ["alpha", "beta", "mike", "zeta"]
# Input order is preserved for the files (z-file.md precedes
# a-file.md in the input, so it does too here — catalog order is
# the INPUT order, not a re-sort).
assert [f.path for f in _file_nodes(source)] == ["z-file.md", "a-file.md"]
def test_summaries_present_and_absent() -> None:
"""``summary`` is the stored row (source root ``""`` or folder
path) or null when absent — any row (AI or manual is indistinguishable
here; the builder carries whatever is stored)."""
rows = [
("S", "one/a.md", "A", 1, T0),
("S", "one/b.md", "B", 1, T0),
("S", "two/c.md", "C", 1, T0),
]
summaries = {("S", ""): "Source desc.", ("S", "one"): "One desc."}
# ("S", "two") is NOT stored → null.
(source,) = build_kb_tree(["S"], rows, summaries)
assert source.summary == "Source desc."
one, two = _folder_nodes(source)
assert one.summary == "One desc."
assert two.summary is None
# File nodes carry no summary key at all (the 00_phase.md shape).
file = _file_nodes(one)[0]
assert set(file.model_dump()) == {"kind", "path", "title", "chunks", "indexed_at"}
assert "summary" not in file.__class__.model_fields
def test_file_metadata_unchanged_in_tree() -> None:
"""File ``title`` / ``chunks`` / ``indexed_at`` ride into the tree
verbatim from the catalogue row (no reformatting)."""
rows = [("S", "deep/x/y.md", "The Title", 7, T2)]
(source,) = build_kb_tree(["S"], rows, {})
file = _file_nodes(_folder_nodes(_folder_nodes(source)[0])[0])[0]
assert file.path == "deep/x/y.md"
assert file.title == "The Title"
assert file.chunks == 7
assert file.indexed_at == T2
def test_empty_inputs_empty_tree() -> None:
"""No registry sources and no indexed documents → an empty tree
(the RAG view's ``{"sources": []}`` case)."""
assert build_kb_tree([], [], {}) == []
def test_indexed_document_under_unlisted_source_is_impossible() -> None:
"""By construction (the superset rule) every doc source is listed —
the registry names lead and every other doc source follows; there
is no input where a document is dropped."""
names = ["reg-b", "reg-a"]
rows: list[TreeDocRow] = [
("reg-b", "b.md", "B", 1, T0),
("zzz", "z.md", "Z", 1, T0),
("aaa", "a.md", "A", 1, T0),
("reg-a", "a.md", "A2", 1, T0),
]
tree = build_kb_tree(names, rows, {})
listed = [s.name for s in tree]
assert listed == ["reg-b", "reg-a", "aaa", "zzz"]
doc_paths = {
f.path for s in tree for f in s.children if f.kind == "file"
}
# Every document from the input is present exactly once.
assert doc_paths == {"b.md", "z.md", "a.md"}
assert sum(s.documents for s in tree) == len(rows)
# --------------------------------------------------------------------
# The cross-check property — "the UI shows what the agent sees":
# for a single-source dataset the builder's level equals
# ``app.rag.agent.group_folder_listing``'s output.
# --------------------------------------------------------------------
#: The shared single-source dataset: nested folders, root files, a
#: summary-stored folder and an unstored one. Paths are in (source,
#: path) catalog order; titles map 1:1 to paths.
CROSS_ROWS: list[TreeDocRow] = [
("S", "note", "Note", 1, T0),
("S", "one/a.md", "A", 2, T1),
("S", "one/b.md", "B", 0, T1),
("S", "one/two/c.md", "C", 3, T1),
("S", "one/two/d.md", "D", 1, T1),
("S", "root.md", "Root", 4, T0),
("S", "zz/e.md", "E", 2, T2),
("S", "zz/f.md", "F", 2, T2),
]
CROSS_SUMMARIES = {
("S", ""): "Source desc.",
("S", "one"): "One desc.",
("S", "zz"): "Zz desc.",
# ("S", "one/two") deliberately unstored → null at that level.
}
def _cross_check(folder: str, builder_node) -> None:
"""Assert the builder's level *folder* equals
``group_folder_listing("S", folder, ...)`` — same subfolder
``(path, count, summary)`` triples in order AND same file
``(path, title)`` pairs in order (uncapped — the dataset is well
under the ``ls`` 50-line cap, so the cap is inert)."""
rows = [(path, title) for _source, path, title, _chunks, _stamp in CROSS_ROWS]
source_summaries = {
folder_path: summary
for (source, folder_path), summary in CROSS_SUMMARIES.items()
if source == "S"
}
subs, files, _total = group_folder_listing("S", folder, rows, source_summaries)
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(builder_node)] == subs
assert [(f.path, f.title) for f in _file_nodes(builder_node)] == [
(path, title) for _source, path, title in files
]
def test_cross_check_root_level_matches_group_folder_listing() -> None:
"""At the source root the builder's direct subfolders / direct
files equal the agent's root level, element for element."""
(source,) = build_kb_tree(["S"], CROSS_ROWS, CROSS_SUMMARIES)
_cross_check("", source)
# And the explicit expectations (the test is readable without the
# helper): one → 4 docs (its whole subtree), zz → 2.
assert source.documents == 8
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(source)] == [
("one", 4, "One desc."),
("zz", 2, "Zz desc."),
]
assert [(f.path, f.title) for f in _file_nodes(source)] == [
("note", "Note"),
("root.md", "Root"),
]
def test_cross_check_nested_level_matches_group_folder_listing() -> None:
"""One nested level (``one``): subfolder ``one/two`` (2 docs, no
stored summary) + the direct files ``one/a.md`` / ``one/b.md`` —
equal to the agent's drill into the same folder."""
(source,) = build_kb_tree(["S"], CROSS_ROWS, CROSS_SUMMARIES)
one = next(f for f in _folder_nodes(source) if f.path == "one")
_cross_check("one", one)
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(one)] == [
("one/two", 2, None),
]
assert [(f.path, f.title) for f in _file_nodes(one)] == [
("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)