All gates green. Final report: **Verified (phase 167 final pass — all 6 tasks already in `complete/`)** - Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte. - Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`. **Gates (all 100% green)** - `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line). - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line). **Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines): - Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed. - AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree). - Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere. **Notable decisions / deviations** - `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's. - A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened. - Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run. **Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
927 lines
39 KiB
Python
927 lines
39 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
|
|
|
|
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)
|