"""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 import pytest from pydantic import ValidationError from app.api.docs import ( TreeDocRow, _folder_counts, _kb_totals, _subtree_max, build_kb_tree, ) from app.rag.agent import group_folder_listing from app.schemas import KbTree, KbTreeFile, KbTreeTotals T0 = "2026-09-01T08:00:00+00:00" T1 = "2026-09-02T08:00:00+00:00" T2 = "2026-09-03T08:00:00+00:00" # Phase 106 (task 05): the 6th catalogue element — the document's # creation date (``created_at``, ISO-8601). Deliberately DISTINCT from # the ``indexed_at`` stamps so a test that confuses the two columns # fails loudly. C0 = "2020-01-01T00:00:00+00:00" C1 = "2021-06-15T12:00:00+00:00" C2 = "2022-03-01T06:00:00+00:00" C3 = "2023-11-30T23:59:59+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, C0), ("alpha", "a.md", "A", 1, T0, C0), ("gamma", "g.md", "G", 1, T0, C0), # indexed-only → appended ("delta", "d.md", "D", 1, T0, C0), # 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 # Phase 106 (D9): a 0-document source has no dates at all. assert tree[0].updated_at 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, C0), ("S", "a/b/shallow.md", "Shallow", 1, T0, C0), ("S", "a/top.md", "Top", 1, T0, C0), ("S", "root.md", "Root", 1, T0, C0), ] (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, C0), ("S", "x.y/z.md", "Z", 1, T0, C1), ] (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, C0), # a file wearing the folder's name ("S", "a/b.md", "B", 1, T0, C1), # 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, C0), ("S", "alpha/a1.md", "A1", 1, T0, C0), ("S", "mike/m1.md", "M1", 1, T0, C0), ("S", "beta/b1.md", "B1", 1, T0, C0), ("S", "z-file.md", "Z", 1, T0, C0), # file AFTER the folders in input ("S", "a-file.md", "A", 1, T0, C0), # 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, C0), ("S", "one/b.md", "B", 1, T0, C0), ("S", "two/c.md", "C", 1, T0, C0), ] 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 on the WIRE (the # 00_phase.md shape — the model_dump set check below is the wire # pin); since phase 106 they DO carry the creation date # (``created_at`` — the RAG view's ``Created`` column). file = _file_nodes(one)[0] assert set(file.model_dump()) == { "kind", "path", "title", "chunks", "created_at", "indexed_at" } # Phase 122 (task 04): the image-affordance fields DO exist on the # class now (image file nodes set them — the wire omission for text # nodes is the ``KbTreeFile`` serializer, pinned by the model_dump # set check above: a text node never leaks the three keys). from app.schemas import KbTreeFile assert {"is_image", "image_url", "summary"} <= set(KbTreeFile.model_fields) def test_file_metadata_unchanged_in_tree() -> None: """File ``title`` / ``chunks`` / ``created_at`` (phase 106) / ``indexed_at`` ride into the tree verbatim from the catalogue row (no reformatting) — and the two stamps stay distinct (the ``created_at`` date is not confused with the ``indexed_at`` stamp).""" rows = [("S", "deep/x/y.md", "The Title", 7, T2, C2)] (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.created_at == C2 # phase 106: verbatim from the catalogue row 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, C0), ("zzz", "z.md", "Z", 1, T0, C1), ("aaa", "a.md", "A", 1, T0, C2), ("reg-a", "a.md", "A2", 1, T0, C3), ] 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, C0), ("S", "one/a.md", "A", 2, T1, C1), ("S", "one/b.md", "B", 0, T1, C1), ("S", "one/two/c.md", "C", 3, T1, C2), ("S", "one/two/d.md", "D", 1, T1, C2), ("S", "root.md", "Root", 4, T0, C3), ("S", "zz/e.md", "E", 2, T2, C0), ("S", "zz/f.md", "F", 2, T2, C0), ] 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 ``(source, path, title, date)`` 4-tuples in order (uncapped — the dataset is well under the ``ls`` 50-line cap, so the cap is inert). Phase 106 (task 06, D5): the cross-check compares the builder's OUTPUT node projections against the agent's extended file shape — the node's ``created_at`` DATE PART (the same ``YYYY-MM-DD`` the agent's ``ls`` line renders) joins the comparison. """ rows = [ (path, title, created[:10]) for _source, path, title, _chunks, _stamp, created 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, f.created_at[:10]) for f in _file_nodes(builder_node)] == [ (path, title, date) for _source, path, title, date 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 (1) 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, C0), ("S", "one/b.md", "B", 1, T0, C0), ] (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, C0), ("S", "one/b.md", "B", 1, T0, C0), ] (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_is_pending() -> None: """The ≥ 1 rule: a 1-document folder with NO stored row IS pending (its one file line no longer exempts it — the next sync summarizes it), as is its source root (2 docs, no root row). A registered 0-document source is the only never-pending case.""" rows = [ ("S", "solo/only.md", "Only", 1, T0, C0), # 1-doc folder ("S", "top.md", "Top", 1, T0, C1), # source total = 2 ] (source,) = build_kb_tree(["S"], rows, {}) (solo,) = _folder_nodes(source) assert solo.documents == 1 assert solo.summary_pending is True 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, C0), # a file wearing the folder's name ("S", "one/a/b.md", "B", 1, T0, C1), # 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 ≥ 1 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, C0), ("Full", "y.md", "Y", 1, T0, C1), ] 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, C0), ("A", "a2.md", "A2", 1, T0, C1), ("B", "b1.md", "B1", 1, T0, C2), ("B", "b2.md", "B2", 1, T0, C3), ] 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) # -------------------------------------------------------------------- # Document dates (phase 106, task 05, D8/D9) — the pure builder's date # threading: file ``created_at`` verbatim; folder/source ``updated_at`` # = the subtree's MAX document ``created_at`` (derived as the builder # recurses, never stored; ``None`` for a node with no documents). # -------------------------------------------------------------------- def test_updated_at_deep_file_beats_shallow_sibling() -> None: """D9: a folder's ``updated_at`` is the MAX over its WHOLE subtree — a deeper file's date wins over a shallower sibling's (the max recurses through every level, not just the direct files).""" rows = [ ("S", "root.md", "Root", 1, T0, C0), ("S", "a/top.md", "Top", 1, T0, C1), ("S", "a/b/shallow.md", "Shallow", 1, T0, C2), ("S", "a/b/c/deep.md", "Deep", 1, T0, C3), # the overall max ] (source,) = build_kb_tree(["S"], rows, {}) a = _folder_nodes(source)[0] a_b = _folder_nodes(a)[0] a_b_c = _folder_nodes(a_b)[0] # The deepest folder: its one file's date. assert a_b_c.updated_at == C3 # a/b: its own file (C2) vs its child's subtree max (C3) → C3. assert a_b.updated_at == C3 # a: its direct file (C1) vs the deeper subtree (C3) → the DEEPER # file's date wins over the shallow sibling's. assert a.updated_at == C3 # The source root: max over root.md (C0) + a's subtree (C3). assert source.updated_at == C3 # File nodes carry their own date verbatim — no ``updated_at`` key. deep = _file_nodes(a_b_c)[0] assert deep.created_at == C3 assert "updated_at" not in deep.model_dump() def test_updated_at_direct_file_wins_when_it_is_the_max() -> None: """The inverse: when a folder's OWN direct file holds the newest date, the max stays at the direct level (the recursion takes the max, it does not prefer depth).""" rows = [ ("S", "root.md", "Root", 1, T0, C0), ("S", "a/top.md", "Top", 1, T0, C3), # the overall max, DIRECT ("S", "a/b/shallow.md", "Shallow", 1, T0, C1), ("S", "a/b/c/deep.md", "Deep", 1, T0, C2), ] (source,) = build_kb_tree(["S"], rows, {}) a = _folder_nodes(source)[0] a_b = _folder_nodes(a)[0] a_b_c = _folder_nodes(a_b)[0] assert a_b_c.updated_at == C2 # its own file assert a_b.updated_at == C2 # max(C1, child C2) assert a.updated_at == C3 # the direct file (C3) beats the subtree (C2) assert source.updated_at == C3 # max(C0, a's C3) def test_updated_at_threads_through_folder_only_subtree() -> None: """A folder with NO direct files (only subfolders) still carries the date threaded up from its child subfolders — the max is over the children (files AND folders), so a pure directory chain never loses the dates below it.""" rows = [("S", "a/b/c/x.md", "X", 1, T0, C2)] (source,) = build_kb_tree(["S"], rows, {}) a = _folder_nodes(source)[0] # no direct files — only subfolder a/b a_b = _folder_nodes(a)[0] # no direct files — only subfolder a/b/c a_b_c = _folder_nodes(a_b)[0] assert a_b_c.updated_at == C2 assert a_b.updated_at == C2 assert a.updated_at == C2 assert source.updated_at == C2 def test_updated_at_is_none_only_for_nodes_without_documents() -> None: """``None`` is reserved for nodes with NO documents at all — a registered 0-document source; every node that has ≥ 1 document in its subtree carries a date (the 6th catalogue element is always present — ``created_at`` is NOT NULL, D1).""" full, empty = build_kb_tree( ["Full", "Empty"], [("Full", "only.md", "Only", 1, T0, C1)], {}, ) assert full.documents == 1 assert full.updated_at == C1 assert empty.documents == 0 assert empty.updated_at is None assert empty.children == [] def test_updated_at_does_not_leak_across_sources() -> None: """The max is per source subtree: one source's newest document never lifts another source's ``updated_at`` (the dates are computed inside :func:`build_kb_tree`'s per-source node, like ``documents`` and ``summary_pending``).""" rows = [ ("A", "a1.md", "A1", 1, T0, C0), ("B", "b1.md", "B1", 1, T0, C3), # B's date is the global max ] a, b = build_kb_tree(["A", "B"], rows, {}) assert a.updated_at == C0 assert b.updated_at == C3 # --------------------------------------------------------------------------- # Phase 122 (task 04) — the image-docs affordance on tree file nodes # --------------------------------------------------------------------------- DOC_ID = "11111111-2222-3333-4444-555555555555" def test_image_file_node_carries_the_affordance_and_text_node_unchanged() -> None: """The ``images`` map (``{(source, path): (doc_id, summary)}``) turns a file node into the image-docs node: ``is_image`` true, ``image_url`` = the bytes route's path built from the mapped id, ``summary`` verbatim (the RAG view's thumbnail ``alt``). A file node NOT in the map keeps the pre-phase wire shape byte-identically (the three image keys are OMITTED, not false/null).""" rows = [ ("S", "one/a.md", "A", 1, T0, C0), ("S", "one/pic.png", "pic", 2, T0, C0), ] images = {("S", "one/pic.png"): (DOC_ID, "A red square on a white background.")} (source,) = build_kb_tree(["S"], rows, {}, images) one = _folder_nodes(source)[0] # File nodes keep the FULL source-relative path (the phase-97 # shape — the folder prefix rides the node). files = {f.path: f for f in _file_nodes(one)} # Text node: the pre-phase wire shape, byte-identical (no image keys). assert set(files["one/a.md"].model_dump()) == { "kind", "path", "title", "chunks", "created_at", "indexed_at" } # Image node: the affordance rides the node. dumped = files["one/pic.png"].model_dump() assert dumped["kind"] == "file" assert dumped["is_image"] is True assert dumped["image_url"] == f"/api/documents/{DOC_ID}/image" assert dumped["summary"] == "A red square on a white background." # The catalogue fields still ride verbatim. assert (dumped["title"], dumped["chunks"]) == ("pic", 2) assert (dumped["created_at"], dumped["indexed_at"]) == (C0, T0) def test_image_file_node_null_summary_and_missing_map_entry() -> None: """A mapped image node with a NULL summary (the fail-soft backfill corner) keeps ``summary: null`` on the wire (meaningful — the alt falls back client-side). A map entry that points at a NON-existent (source, path) affects nothing (the builder only reads mapped keys it meets in the catalogue rows).""" rows = [ ("S", "one/ghost.png", "ghost", 1, T0, C0), ("S", "one/other.md", "Other", 1, T0, C0), ] images = { ("S", "one/ghost.png"): (DOC_ID, None), ("S", "one/absent.png"): (DOC_ID, "never matched"), } (source,) = build_kb_tree(["S"], rows, {}, images) one = _folder_nodes(source)[0] files = {f.path: f for f in _file_nodes(one)} ghost = files["one/ghost.png"].model_dump() assert ghost["is_image"] is True assert ghost["summary"] is None # null stays (the alt fallback corner) assert ghost["image_url"] == f"/api/documents/{DOC_ID}/image" assert set(files["one/other.md"].model_dump()) == { "kind", "path", "title", "chunks", "created_at", "indexed_at" } def test_image_affordance_absent_without_the_map() -> None: """No map (the default) → every file node is the pre-phase shape, even for ``.png`` paths: the fields are map-driven (the endpoint composes the map from the ``is_image`` rows), never path-guessed — a pre-phase KB serializes byte-identically.""" rows = [("S", "pic.png", "pic", 1, T0, C0)] (source,) = build_kb_tree(["S"], rows, {}) (file,) = _file_nodes(source) assert set(file.model_dump()) == { "kind", "path", "title", "chunks", "created_at", "indexed_at" } # --------------------------------------------------------------------------- # Phase 167 (task 02, D2/A2) — the depth budget: a node whose children were # NOT built says so (``truncated``) while keeping every field the Sources # view renders (recursive count, recursive updated_at — the D9 max via the # _folder_counts max-date map, summary, summary_pending), the untruncated # wire shape stays byte-identical, and the catalogue totals ride the # response (``_kb_totals`` — the stat cards cannot be summed client-side # off a truncated tree). # --------------------------------------------------------------------------- #: The depth fixture: one chain four levels deep, distinct created dates #: (C0 < C1 < C2 < C3 — the DEEPEST file holds the max, the D9 trap) and #: distinct indexed stamps. DEPTH_ROWS: list[TreeDocRow] = [ ("S", "root.md", "Root", 1, T0, C0), ("S", "a/top.md", "Top", 2, T1, C1), ("S", "a/b/mid.md", "Mid", 3, T1, C2), ("S", "a/b/c/deep.md", "Deep", 4, T2, C3), ] DEPTH_SUMMARIES = {("S", "a"): "A desc."} # a/b and a/b/c stay un-stored def _all_nodes(node): """The node plus every descendant node (folders and files).""" yield node for child in getattr(node, "children", []): if child.kind == "folder": yield from _all_nodes(child) else: yield child def _all_folder_nodes(node): """Every FOLDER descendant (the SOURCE node itself is excluded — it carries ``name``, not ``path``, and has no ``kind``).""" return [n for n in _all_nodes(node) if getattr(n, "kind", None) == "folder"] def _paths_under(node) -> set[str]: """Paths of every descendant (folders and files); the source node has no path of its own.""" out: set[str] = set() for child in getattr(node, "children", []): out.add(child.path) if child.kind == "folder": out |= _paths_under(child) return out def test_depth_none_is_byte_identical_to_the_unlimited_build() -> None: """``depth=None`` (the builder default) reproduces today's tree byte-for-byte: the default call, the explicit None, and an over-deep budget all serialize identically, and NO node carries the truncated key (the omission rule).""" default = [s.model_dump() for s in build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES)] explicit = [ s.model_dump() for s in build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES, depth=None) ] over_deep = [ s.model_dump() for s in build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES, depth=8) ] assert default == explicit == over_deep (source,) = build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES) assert all("truncated" not in n.model_dump() for n in _all_nodes(source)) # The full chain IS built: every level of the fixture is present. assert _paths_under(source) == { "root.md", "a", "a/top.md", "a/b", "a/b/mid.md", "a/b/c", "a/b/c/deep.md", } def test_depth_one_ships_sources_plus_one_level_with_truncated_flags() -> None: """``depth=1``: the source's direct children are built (direct file ``root.md`` included) and the subfolder with unloaded contents carries the FULL truncation field set — ``truncated is True``, ``children == []``, the RECURSIVE count, the RECURSIVE max date (the deep C3 — D9 survives without the children), the stored ``summary`` and the ``summary_pending`` flag (rule unchanged).""" (source,) = build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES, depth=1) assert [f.path for f in _file_nodes(source)] == ["root.md"] (a,) = _folder_nodes(source) assert a.path == "a" assert a.truncated is True assert a.model_dump()["truncated"] is True assert a.children == [] assert a.documents == 3 # recursive — the count rule is depth-proof assert a.updated_at == C3 # the DEEP max, no built children to derive from assert a.summary == "A desc." # the level row shows a subfolder's description assert a.summary_pending is False # the stored row covers it (phase 98) # Nothing deeper is built anywhere. assert _paths_under(source) == {"root.md", "a"} def test_depth_two_builds_two_levels_and_truncates_the_deeper_one() -> None: """``depth=2``: level 1 (``a``) is expanded UNTRUNCATED (its direct file ``a/top.md`` ships), level 2 (``a/b``) is the truncated node — the pending rule applies to it (2 docs, no stored row → ``summary_pending`` true with ``summary None``), and neither its own file (``a/b/mid.md``) nor the third level appear: the budget removes the LEVELS below it, level by level.""" (source,) = build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES, depth=2) (a,) = _folder_nodes(source) assert "truncated" not in a.model_dump() # untruncated: key absent assert [f.path for f in _file_nodes(a)] == ["a/top.md"] (a_b,) = _folder_nodes(a) assert a_b.truncated is True assert a_b.children == [] assert (a_b.documents, a_b.updated_at) == (2, C3) # mid + deep, max C3 assert a_b.summary is None assert a_b.summary_pending is True # the phase-98 rule rides truncated nodes assert _paths_under(source) == { "root.md", "a", "a/top.md", "a/b", } def test_source_node_is_never_truncated_at_any_depth() -> None: """The view's top level is source → level 1, so the budget never marks the SOURCE node truncated (at any depth, empty sources included) — its wire shape never carries the key.""" (ref,) = build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES) for depth in (1, 2, 3, 8): full, empty = build_kb_tree( ["S", "Empty"], DEPTH_ROWS, DEPTH_SUMMARIES, depth=depth ) assert full.truncated is False assert "truncated" not in full.model_dump() assert empty.truncated is False assert "truncated" not in empty.model_dump() assert empty.children == [] # Direct children always built: the source's level is complete. assert [c.path for c in full.children] == [c.path for c in ref.children] def test_truncated_key_never_touches_the_file_node_shape() -> None: """A FILE node has no children to truncate: the field does not even exist on the class, and no dump ever carries it (the phase-122 text wire shape stays byte-identical at every depth).""" assert "truncated" not in KbTreeFile.model_fields for depth in (1, 2, 8): (source,) = build_kb_tree(["S"], DEPTH_ROWS, {}, depth=depth) file_nodes = [n for n in _all_nodes(source) if getattr(n, "kind", None) == "file"] assert file_nodes # the fixture has files at every shipped level for node in file_nodes: assert "truncated" not in node.model_dump() def test_max_dates_map_agrees_with_subtree_max_at_every_depth() -> None: """One concept, two derivations, cross-pinned: the ``_folder_counts`` max-date map equals ``_subtree_max`` over the FULLY expanded children for every folder (the expanded builder path stays ``_subtree_max``, so a divergence fails loudly) — and every node present in a depth-limited tree carries the SAME ``updated_at`` as the full build, truncated or not (the phase-106 D9 column survives the payload diet).""" file_rows = [ (path, title, chunks, indexed, created) for _source, path, title, chunks, indexed, created in DEPTH_ROWS ] _folders, _counts, max_dates = _folder_counts(file_rows) (full,) = build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES) full_dates = {n.path: n.updated_at for n in _all_folder_nodes(full)} assert full_dates == max_dates # the cross-pin at full expansion for node in _all_folder_nodes(full): # The expanded node's derivation equals the aggregate map. assert _subtree_max(node.children) == max_dates[node.path] for depth in (1, 2, 3): (limited,) = build_kb_tree(["S"], DEPTH_ROWS, DEPTH_SUMMARIES, depth=depth) for node in _all_folder_nodes(limited): assert node.updated_at == max_dates[node.path] == full_dates[node.path] # The source max also spans the UNSENT levels. assert limited.updated_at == C3 def test_truncation_keeps_the_phase_94_and_98_invariants_at_every_depth() -> None: """Existence rule, ordering, counts, summaries, pending markers and dates: every node a limited tree DOES send is field-for-field the node the full tree sends (the depth budget removes depth only — it never changes what a shipped level says), and the top level is identical at every depth.""" (full,) = build_kb_tree(["S"], CROSS_ROWS, CROSS_SUMMARIES) full_map = { n.path: (n.documents, n.summary_pending, n.updated_at, n.summary) for n in _all_folder_nodes(full) } for depth in (1, 2, 3): (limited,) = build_kb_tree(["S"], CROSS_ROWS, CROSS_SUMMARIES, depth=depth) # Same level-1 rows, same order, at every depth. assert [c.path for c in limited.children] == [c.path for c in full.children] for node in _all_folder_nodes(limited): assert ( node.documents, node.summary_pending, node.updated_at, node.summary, ) == full_map[node.path] # Existence rule: a node is present ⟺ a catalogue prefix. assert node.path in full_map # Every truncated node is exactly the truncation contract. for node in _all_folder_nodes(limited): if node.truncated: assert node.children == [] else: assert node.model_dump().get("truncated") is None def test_truncated_nodes_keep_the_image_affordance_at_their_level() -> None: """Phase 122's affordance is row-driven, not depth-driven: a DIRECT image file of the source root still carries the thumbnail keys at ``depth=1``; the truncated folder below simply does not list its own rows (they arrive with the drill).""" rows = [ ("S", "pic.png", "pic", 1, T0, C0), ("S", "d/x.png", "x", 1, T0, C0), ] images = { ("S", "pic.png"): (DOC_ID, "A red square."), ("S", "d/x.png"): (DOC_ID, "A blue circle."), } (source,) = build_kb_tree(["S"], rows, {}, images, depth=1) (pic,) = _file_nodes(source) assert pic.is_image is True assert pic.model_dump()["image_url"] == f"/api/documents/{DOC_ID}/image" assert pic.model_dump()["summary"] == "A red square." (d,) = _folder_nodes(source) assert d.truncated is True assert d.children == [] # --------------------------------------------------------------------------- # _kb_totals (phase 167, task 02) — the stat-card totals the endpoint ships # beside any depth of tree (no extra query, empty-KB honest). # --------------------------------------------------------------------------- def test_kb_totals_math_over_the_catalogue_rows() -> None: """documents = the row count, chunks = the sum, last_indexed = the MAX indexed stamp (lexicographic over one ISO shape = chronological — the same rule as the D9 max).""" totals = _kb_totals(DEPTH_ROWS) assert totals.documents == 4 assert totals.chunks == 10 # 1 + 2 + 3 + 4 assert totals.last_indexed == T2 # the newest indexed_at def test_kb_totals_empty_kb_is_zero_and_none() -> None: """The empty KB: 0 documents, 0 chunks, ``last_indexed is None`` — the stat cards render the designed empty state, never a crash.""" totals = _kb_totals([]) assert totals == KbTreeTotals(documents=0, chunks=0, last_indexed=None) assert totals.model_dump() == { "documents": 0, "chunks": 0, "last_indexed": None, } def test_kb_tree_totals_default_and_floor_validation() -> None: """``KbTree.totals`` is an additive top-level key with an honest empty default (an older call shape still serializes), and the ge=0 bounds refuse negative totals loudly (the endpoint computes them — a negative is a bug, never data).""" assert KbTree(sources=[]).model_dump()["totals"] == { "documents": 0, "chunks": 0, "last_indexed": None, } with pytest.raises(ValidationError): KbTreeTotals(documents=-1, chunks=0) with pytest.raises(ValidationError): KbTreeTotals(documents=0, chunks=-1)