phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
This commit is contained in:
@@ -23,6 +23,13 @@ Phase 79 (API tokens): the anonymous pins moved to the gated contract —
|
||||
phase-16 "the viewer stays open" soft rule is SUPERSEDED, shared chats
|
||||
are the only open surface). The password sign-in / sign-out /
|
||||
wrong-password assertions are UNCHANGED.
|
||||
|
||||
Phase 97 adaptation: the admin catalog pin (test 4) is re-pointed at
|
||||
the DRILL-DOWN TREE — the top level lists the fixture's single
|
||||
indexed-only source (``docs``), the flat 13-row tbody no longer
|
||||
renders, so the total is re-asserted PER LEVEL (drill → count; the
|
||||
stat cards carry the KB total). The asserted document behavior is
|
||||
unchanged; navigation only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -81,6 +88,28 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 97: the catalog is the drill-down tree the agent's `ls` walks —
|
||||
# top level = the sources (the fixture's single indexed-only source,
|
||||
# `docs`), then one folder link per path segment. Small per-suite
|
||||
# drill helpers (the test_kb_tree house pattern).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Drill one level at a time: each name is the EXACT text of the
|
||||
source/folder link at the current level (client-side — no fetch,
|
||||
no URL change)."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _go_top(page: Page) -> None:
|
||||
"""Back to the top level: the breadcrumb's top-level link (call
|
||||
between drills only — the breadcrumb is hidden at the top)."""
|
||||
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
||||
|
||||
|
||||
def _ask(page: Page, question: str) -> None:
|
||||
"""Send one turn and wait until the grounded answer has fully landed."""
|
||||
page.fill("#message-input", question)
|
||||
@@ -247,8 +276,32 @@ def test_admin_login_unlocks_sources_and_tuning(
|
||||
expect(page.locator("#sources-gate")).to_be_hidden()
|
||||
expect(page.locator("#stat-docs")).to_have_text("13") # phase 47: +quadlet/j2
|
||||
expect(page.locator("#stat-chunks")).not_to_have_text("–")
|
||||
# Phase 97: the catalog is the drill-down tree — the top level
|
||||
# lists the source (the catalog-rendered signal), the file table
|
||||
# is hidden there; the flat 13-row total is re-asserted PER LEVEL
|
||||
# (drill → count; the sum is 3 + 1 + 2 + 1 + 3 + 1 + 1 + 1 = 13).
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
expect(page.locator("#docs-table")).to_be_hidden()
|
||||
_drill(page, "docs")
|
||||
expect(page.locator("#folders-tbody tr")).to_have_count(2)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0) # no root-level files
|
||||
_drill(page, "homelab")
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(13)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(3)
|
||||
_go_top(page)
|
||||
_drill(page, "docs", "deployments")
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(1)
|
||||
for folder, count in (
|
||||
("container_gitlab", 2),
|
||||
("networking", 1),
|
||||
("quadlet", 3),
|
||||
("scripts", 1),
|
||||
("ssh", 1),
|
||||
("templates", 1),
|
||||
):
|
||||
_go_top(page)
|
||||
_drill(page, "docs", "homelab", folder)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(count)
|
||||
|
||||
# Chat: the tuning UI is back — Sign out instead of Sign in, Tune
|
||||
# under the answer. The header toggle is NOT back: removed from the
|
||||
|
||||
@@ -524,7 +524,13 @@ def test_upload_registers_without_indexing(
|
||||
# Phase 90 A1: the upload indexes NOTHING — the KB is empty…
|
||||
assert _docs(page, app_url) == []
|
||||
# …and so is the RAG catalog (the scan is the Sync button's job).
|
||||
# Phase 97: the registered source renders its 0-document row
|
||||
# (the load-settled signal; #sources-empty is the zero-SOURCES
|
||||
# state only) and the file table stays empty.
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
row = page.locator("#folders-tbody tr", has_text=SOURCE_NAME)
|
||||
expect(row).to_have_count(1, timeout=30_000)
|
||||
expect(row.locator("td:nth-child(2)")).to_have_text("0")
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
|
||||
|
||||
|
||||
@@ -280,7 +280,10 @@ def test_persists_across_page_navigation(
|
||||
# bar at owner request, 2026-08-28 — pinned in
|
||||
# tests/e2e/test_shared_header.py).
|
||||
page.goto(f"{app_url}/sources.html")
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# Phase 97: the top level lists the sources (the file table is
|
||||
# per-level, hidden at the top) — the source row is the
|
||||
# catalog-rendered signal.
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
# Back to the chat: the conversation is exactly as left — both turns,
|
||||
|
||||
@@ -150,7 +150,10 @@ def test_back_from_sources_returns_to_sources(
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
login(page, app_url) # phase 16: the Sources table is admin-only
|
||||
|
||||
# Phase 97: the catalog is the drill-down tree — the kubernetes.md
|
||||
# row lives at the homelab level (the drill is the only change).
|
||||
for name in ("docs", "homelab"):
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
|
||||
expect(row).to_have_count(1)
|
||||
link = row.locator("td:nth-child(2) a.doc-link")
|
||||
@@ -174,7 +177,10 @@ def test_back_from_sources_returns_to_sources(
|
||||
|
||||
back.click()
|
||||
expect(page).to_have_url(f"{app_url}/sources.html")
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
# Phase 97: the re-mount lands on the tree's top level (the sources
|
||||
# list — the file table is per-level, hidden at the top); the
|
||||
# source row is the catalog-rendered signal.
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -107,6 +107,16 @@ def _assert_closed(page: Page) -> None:
|
||||
expect(page.locator(".doc-modal")).not_to_be_visible()
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Phase 97: the catalog is the drill-down tree the agent's `ls`
|
||||
sees — click through the source/folder rows (exact name match,
|
||||
one per name) to the level that holds the asserted file. The drill
|
||||
is the only change from the flat-table era; the row itself is
|
||||
unchanged."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Chat source chip → SAME-PAGE modal (no new tab)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,7 +182,9 @@ def test_sources_row_opens_modal(
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||
|
||||
# Phase 97: the row lives at its folder level (docs → homelab →
|
||||
# container_gitlab) — the drill is the only change.
|
||||
_drill(page, "docs", "homelab", "container_gitlab")
|
||||
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
|
||||
expect(row).to_have_count(1)
|
||||
link = row.locator("td:nth-child(2) a.doc-link")
|
||||
@@ -320,6 +332,9 @@ def test_modal_xss_safe(
|
||||
# The Sources table lists every indexed document — the admin entry
|
||||
# point into the modal for a doc the chat never cited.
|
||||
login(page, app_url)
|
||||
# Phase 97: the seeded doc's row lives at the notes/ level — the
|
||||
# drill is the only change.
|
||||
_drill(page, "docs", "notes")
|
||||
row = page.locator("#docs-tbody tr", has_text="xss-fixture.md")
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
|
||||
@@ -387,6 +387,11 @@ def test_admin_modal_surface_edit(page: Page, app_url: str) -> None:
|
||||
assert before["summary"] == expected
|
||||
|
||||
login(page, app_url) # lands on /sources.html (the catalog is admin-only)
|
||||
# Phase 97: the catalog is the drill-down tree — the row lives at
|
||||
# the quadlet level (the drill is the only change; the phase-57
|
||||
# summary-edit flow on the viewer itself is untouched).
|
||||
for name in (SOURCE, "quadlet"):
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
|
||||
expect(row).to_have_count(1)
|
||||
before_tabs = len(page.context.pages)
|
||||
|
||||
@@ -379,7 +379,13 @@ def test_tuning_page_a11y_and_no_cdn(
|
||||
|
||||
# Landmarks (PLAN §7.2).
|
||||
assert page.locator("header.app-header").count() == 1, "header missing"
|
||||
assert page.locator("nav[aria-label]").count() == 1, "labeled nav missing"
|
||||
# Phase 97: the shell carries a SECOND nav in the RAG view
|
||||
# (#kb-crumb, the catalog breadcrumb — hidden at the top level,
|
||||
# pinned in test_kb_tree), so the primary-nav landmark is pinned by
|
||||
# its label.
|
||||
assert page.locator("nav[aria-label='Primary']").count() == 1, (
|
||||
"labeled primary nav missing"
|
||||
)
|
||||
assert page.locator("main#main").count() == 1, "main#main missing"
|
||||
assert page.locator("footer.app-footer").count() == 1, "footer missing"
|
||||
|
||||
|
||||
@@ -177,13 +177,25 @@ def _open_menu(page: Page) -> None:
|
||||
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||||
|
||||
|
||||
def _drill_to_files(page: Page) -> None:
|
||||
"""Phase 97: the catalog is the drill-down tree — the top level
|
||||
lists the sources (the file table is per-level, hidden at the
|
||||
top). The 640px ``min-width`` pin targets the FILE table, so both
|
||||
RAG-view tests drill to a level that has files (the fixture
|
||||
source + its ``homelab`` folder) before pinning."""
|
||||
for name in ("docs", "homelab"):
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _wrap_handle(page: Page, view: str) -> JSHandle:
|
||||
"""The table card (``.table-wrap``) of ``view``: the id'd cards for
|
||||
history/tokens, the RAG card found through its ``.docs-table``
|
||||
(that card is the only one without an id)."""
|
||||
history/tokens, the RAG card found through its FILE table (phase 97:
|
||||
the RAG view carries TWO cards — ``#folders-wrap`` at the top level
|
||||
and the per-level file table's card; the 640px pin is the file
|
||||
table's, and the tests drill there first)."""
|
||||
if view == "sources":
|
||||
return page.evaluate_handle(
|
||||
"() => document.querySelector('.docs-table').closest('.table-wrap')"
|
||||
"() => document.querySelector('#docs-table').closest('.table-wrap')"
|
||||
)
|
||||
return page.evaluate_handle(f"() => document.querySelector('#{view}-table-wrap')")
|
||||
|
||||
@@ -381,14 +393,25 @@ def test_rag_view_regression(
|
||||
regress it — AND its table is still full-width inside the card:
|
||||
the 640px ``min-width`` still engages, so the card keeps its
|
||||
in-card scroll (the shared rule added a containing block, not a
|
||||
width)."""
|
||||
width).
|
||||
|
||||
Phase 97: the catalog is the drill-down tree — the top level lists
|
||||
the sources, so the FILE table (the 640px pin's subject) is reached
|
||||
by drilling ``docs`` → ``homelab`` first; the drill is the only
|
||||
change to this pin."""
|
||||
summary = _seed_kb(mock_llm)
|
||||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||||
page = _mobile_page(browser)
|
||||
try:
|
||||
login(page, app_url, next="/sources.html")
|
||||
_wait_settled_admin(page)
|
||||
expect(page.locator(".docs-table")).to_be_visible(timeout=15_000)
|
||||
# Phase 97: the top level lists the sources (the source row is
|
||||
# the catalog-rendered signal); the 640px in-card-scroll pin
|
||||
# targets the FILE table, so drill to a level that has files
|
||||
# first (the drill is the only change).
|
||||
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=15_000)
|
||||
_drill_to_files(page)
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
_assert_viewport_width(page, "/sources.html (direct)")
|
||||
_assert_in_card_scroll(page, _wrap_handle(page, "sources"), "/sources.html")
|
||||
@@ -411,7 +434,10 @@ def test_desktop_unchanged(
|
||||
than the mobile 346px card. The zero-offset
|
||||
``position: relative`` changed no layout, so desktop is
|
||||
byte-identical in behavior. The KB is seeded the house way for
|
||||
the RAG view's table (it hides itself on an empty KB).
|
||||
the RAG view's table (it hides itself on an empty KB). Phase 97:
|
||||
the RAG view's table is the FILE table — the top level lists the
|
||||
sources, so the sources pass drills ``docs`` → ``homelab`` first
|
||||
(the drill is the only change).
|
||||
|
||||
Data state: the desktop pin is enforced on the EMPTY-table state
|
||||
the module docstring declares ("the History/Tokens contract holds
|
||||
@@ -443,8 +469,15 @@ def test_desktop_unchanged(
|
||||
if i: # the first view is the login landing
|
||||
page.goto(app_url + path)
|
||||
_wait_settled_admin(page)
|
||||
marker = ".docs-table" if view == "sources" else f"#{view}-table-wrap"
|
||||
expect(page.locator(marker)).to_be_visible(timeout=15_000)
|
||||
if view == "sources":
|
||||
# Phase 97: the top level lists the sources — the
|
||||
# container-width pin is the FILE table's (the 640px
|
||||
# min-width's desktop counterpart), so drill to a level
|
||||
# that has files first.
|
||||
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=15_000)
|
||||
_drill_to_files(page)
|
||||
marker = ("#docs-tbody tr" if view == "sources" else f"#{view}-table-wrap")
|
||||
expect(page.locator(marker).first).to_be_visible(timeout=15_000)
|
||||
|
||||
_assert_viewport_width(page, f"{path} (desktop)")
|
||||
report = _wrap_handle(page, view).evaluate(
|
||||
|
||||
@@ -11,14 +11,33 @@ fixture, not the subject of the tests.
|
||||
|
||||
Phase 16 adaptation: the Sources catalog is admin-only — every test
|
||||
performs the real form login (``e2e.auth_helpers.login``) first.
|
||||
|
||||
Phase 97 adaptation: the catalog is the DRILL-DOWN TREE the agent's
|
||||
``ls`` walks — the top level lists the sources (this fixture's single
|
||||
indexed-only source, ``docs``), then one folder link per path segment;
|
||||
the flat all-documents table no longer renders. The asserted rows,
|
||||
links, and stat cards are UNCHANGED in intent — the drill is the only
|
||||
change (every row here is nested under a folder). The empty-state test
|
||||
now pins the zero-SOURCES state (a registered 0-document source renders
|
||||
its row instead) — for that state to be reachable at all, this suite
|
||||
runs its OWN module app (the conftest leak-guard pattern) with
|
||||
``BOR_GIT_SOURCES`` forced empty: the session app would inherit an
|
||||
operator's local ``.env`` fallback source, which would leak a
|
||||
0-document top-level row into the tree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
@@ -27,10 +46,86 @@ from app.db import SessionLocal
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||
# port in a combined run — this module app binds its own port instead
|
||||
# (a same-port second uvicorn dies on bind and would drive the wrong
|
||||
# server). Env-overridable.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_IMPORTDOCS", "8140"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int) -> Iterator[str]:
|
||||
"""The real app under test — per-module env (the conftest pattern):
|
||||
the leak guards force the code defaults, and ``BOR_GIT_SOURCES`` is
|
||||
forced EMPTY (phase 97 — the registered sources now render as
|
||||
top-level rows: the operator's ``.env`` fallback source would leak
|
||||
a 0-document source into the tree and break the empty-state test's
|
||||
zero-SOURCES state). The session app is never started in this
|
||||
isolated run, so no port clash."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# Phase 97: the registry is this suite's own concern (see the
|
||||
# fixture docstring) — the env fallback is git-only, empty here.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Leak guards (conftest pattern): an operator's local (gitignored)
|
||||
# .env cannot leak corpus-specific settings into the app under test.
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
EXPECTED_ROWS = (
|
||||
"homelab/kubernetes.md",
|
||||
"homelab/backups.md",
|
||||
@@ -78,15 +173,57 @@ def _run_in_thread(coro: Any) -> Any:
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
"""Truncate the KB (and query log), then optionally re-import fixtures."""
|
||||
"""Truncate the KB (and query log), then optionally re-import fixtures.
|
||||
|
||||
Phase 97: the registry (``git_sources``) and the stored folder
|
||||
descriptions (``folder_summaries``) are truncated too — they now
|
||||
RENDER in the RAG view (top-level source rows + descriptions), so a
|
||||
leftover row from another suite would show up as a 0-document
|
||||
source and break the empty-state test's zero-SOURCES state."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, git_sources, folder_summaries")
|
||||
)
|
||||
db.commit()
|
||||
if not seed:
|
||||
return None
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 97: the catalog is the drill-down tree the agent's `ls` walks —
|
||||
# the top level lists the SOURCES (this fixture's single indexed-only
|
||||
# source: `docs`, the fixtures dir's basename — import_sources seeded it
|
||||
# with no registry row), then one folder link per path segment. The
|
||||
# flat all-documents table is gone; the drill is the only change (the
|
||||
# asserted rows/links are the same).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
SOURCE_NAME = "docs" # the fixtures dir's basename (the indexed-only source)
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Drill one level at a time (client-side — no fetch, no URL
|
||||
change): each name is the EXACT text of the source/folder link at
|
||||
the current level (the test_kb_tree house pattern)."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _go_top(page: Page) -> None:
|
||||
"""Back to the top level (the sources list): the breadcrumb's
|
||||
top-level link (hidden AT the top — call between drills only)."""
|
||||
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
||||
|
||||
|
||||
def _wait_top_level(page: Page) -> None:
|
||||
"""The tree's single fetch settled: the source row is rendered
|
||||
(the catalog-rendered signal — the top-level file table is always
|
||||
hidden, so it is no longer a usable one)."""
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
|
||||
|
||||
def test_sources_page_lists_indexed_docs(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
@@ -111,11 +248,25 @@ def test_sources_page_lists_indexed_docs(
|
||||
expect(page.locator("#stat-last")).not_to_have_text("–")
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
|
||||
for row_path in EXPECTED_ROWS:
|
||||
_wait_top_level(page)
|
||||
# Phase 97: every row is nested under a folder — drill source →
|
||||
# folder(s) per path before asserting (the flat 13-row tbody no
|
||||
# longer exists; the stat cards above hold the KB total).
|
||||
for i, row_path in enumerate(EXPECTED_ROWS):
|
||||
if i > 0:
|
||||
_go_top(page)
|
||||
_drill(page, SOURCE_NAME, *row_path.rsplit("/", 1)[0].split("/"))
|
||||
expect(page.locator("#docs-tbody tr", has_text=row_path)).to_have_count(1)
|
||||
# The hidden junk was never indexed (A9 scope).
|
||||
# The hidden junk was never indexed (A9 scope) — scoped to the
|
||||
# level where it WOULD appear: no `.hidden` file row at the source
|
||||
# level AND no `.hidden` folder row there either (the stat cards
|
||||
# pin the 13 total, so the junk is counted nowhere).
|
||||
_go_top(page)
|
||||
_drill(page, SOURCE_NAME)
|
||||
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
||||
expect(page.locator("#folders-tbody .folder-link", has_text=".hidden")).to_have_count(0)
|
||||
# The path column carries the full path for hover (ellipsis is visual only).
|
||||
_drill(page, "homelab")
|
||||
expect(page.locator("#docs-tbody tr", has_text="homelab/kubernetes.md")
|
||||
.get_by_role("cell").nth(1)).to_have_attribute("title", "homelab/kubernetes.md")
|
||||
|
||||
@@ -125,11 +276,18 @@ def test_sources_table_layout(
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
login(page, app_url) # phase 16: the catalog is admin-only
|
||||
# Phase 97: the top level lists the sources (the file table is
|
||||
# hidden there) — wait for the source row, then drill to a file
|
||||
# level (docs → homelab) for the file table's layout asserts.
|
||||
_wait_top_level(page)
|
||||
_drill(page, SOURCE_NAME, "homelab")
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||
|
||||
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap —
|
||||
# scope to the RAG view.
|
||||
wrap = page.locator("#view-rag .table-wrap")
|
||||
# scope to the RAG view. Phase 97: the RAG view carries TWO
|
||||
# .table-wraps (#folders-wrap + the file table's) — :has() targets
|
||||
# the file table's (the one these layout pins were written for).
|
||||
wrap = page.locator("#view-rag .table-wrap:has(#docs-table)")
|
||||
expect(wrap).to_be_visible()
|
||||
expect(wrap).to_have_attribute("role", "region")
|
||||
expect(wrap).to_have_attribute("tabindex", "0")
|
||||
@@ -146,9 +304,13 @@ def test_sources_table_layout(
|
||||
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
||||
try:
|
||||
login(mobile, app_url) # phase 16: the catalog is admin-only
|
||||
_wait_top_level(mobile)
|
||||
_drill(mobile, SOURCE_NAME, "homelab")
|
||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||
# Phase 97: target the FILE table's wrap explicitly (a bare
|
||||
# .table-wrap query would hit #folders-wrap first now).
|
||||
scroll_width, client_width = mobile.evaluate(
|
||||
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||
"() => { const el = document.querySelector('#docs-table').parentElement;"
|
||||
" return [el.scrollWidth, el.clientWidth]; }"
|
||||
)
|
||||
assert scroll_width > client_width
|
||||
@@ -165,6 +327,9 @@ def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_re
|
||||
expect(page.locator("#sources-empty code")).to_have_text(
|
||||
"uv run python -m scripts.import_docs"
|
||||
)
|
||||
expect(page.locator("#view-rag .table-wrap")).to_be_hidden()
|
||||
# Phase 97: BOTH catalog tables ship in the RAG view — the empty
|
||||
# state hides both (the original single-wrap pin, extended).
|
||||
expect(page.locator("#view-rag .table-wrap:has(#docs-table)")).to_be_hidden()
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden()
|
||||
expect(page.locator("#stat-docs")).to_have_text("0")
|
||||
expect(page.locator("#stat-chunks")).to_have_text("0")
|
||||
|
||||
@@ -16,6 +16,11 @@ DB isolation: the fixture's source name (``extension_kb``) is
|
||||
distinctive — the suite never asserts on absolute row counts and
|
||||
deletes the rows it creates in a ``finally`` (other suites' documents
|
||||
stay untouched in the shared E2E database).
|
||||
|
||||
Phase 97 adaptation: the catalog is the DRILL-DOWN TREE — the rows
|
||||
live at their folder levels (``extension_kb`` → ``homelab`` →
|
||||
``scripts`` / ``notes``); the drill is the only change, the asserted
|
||||
rows/links/modal are unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -86,6 +91,20 @@ def _delete_source_rows() -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Drill one level at a time (phase 97 — client-side, no fetch,
|
||||
no URL change): each name is the EXACT text of the source/folder
|
||||
link at the current level."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _go_top(page: Page) -> None:
|
||||
"""Back to the top level: the breadcrumb's top-level link (call
|
||||
between drills only — the breadcrumb is hidden at the top)."""
|
||||
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def extension_kb(mock_llm: int, db_ready: None) -> Iterator[ImportSummary]:
|
||||
"""Seed the fixture with the NOVEL scope (``md,sh``) for one test
|
||||
@@ -110,14 +129,27 @@ def test_admin_sources_lists_the_novel_extension(
|
||||
login(page, app_url) # phase 16: the catalog is admin-only
|
||||
# The novel .sh document is listed; the path cell carries the full
|
||||
# path (the column is ellipsized — the title attribute is the pin).
|
||||
# Phase 97: the rows live at their folder levels — drill source →
|
||||
# homelab → scripts first.
|
||||
_drill(page, SOURCE, "homelab", "scripts")
|
||||
row = page.locator("#docs-tbody tr", has_text=SH_REL)
|
||||
expect(row).to_have_count(1)
|
||||
link = row.locator("td:nth-child(2) a.doc-link")
|
||||
expect(link).to_have_count(1)
|
||||
expect(link).to_have_attribute("title", SH_REL)
|
||||
# The markdown control doc is listed too (never asserted by count —
|
||||
# other suites' documents may share the shared E2E database).
|
||||
# The markdown control doc is listed too (its OWN level — never
|
||||
# asserted by count: other suites' documents may share the shared
|
||||
# E2E database).
|
||||
_go_top(page)
|
||||
_drill(page, SOURCE, "homelab", "notes")
|
||||
expect(page.locator("#docs-tbody tr", has_text=MD_REL)).to_have_count(1)
|
||||
# Back to the .sh row's level — the modal asserts below click its
|
||||
# path link.
|
||||
_go_top(page)
|
||||
_drill(page, SOURCE, "homelab", "scripts")
|
||||
link = page.locator("#docs-tbody tr", has_text=SH_REL).locator(
|
||||
"td:nth-child(2) a.doc-link"
|
||||
)
|
||||
|
||||
# Format badge: the row's path link opens the same-page modal and
|
||||
# its meta row shows the .sh format (house assertion style —
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
"""Phase 97 task 06 E2E (Playwright, mock-only): the RAG view's drill-down
|
||||
catalog tree + the editable folder descriptions.
|
||||
|
||||
The dedicated story suite for ``97_kb_tree_catalog`` (owner request,
|
||||
2026-09-11): the Knowledge base view lists the KB the way the agent's
|
||||
``ls`` sees it (the phase-94 concept, one end to end) — sources at the
|
||||
top, then per level the subfolders with their stored descriptions and
|
||||
the level's files — and the owner edits (or clears) any directory's
|
||||
description with the phase-57 inline affordance. Everything is pinned
|
||||
against the real app + the deterministic mock.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_kb_tree.py -v --no-cov
|
||||
|
||||
KB fixture — a host temp-dir tree (``tmp_path_factory``; the app runs on
|
||||
the same host) with TWO local sources registered through the
|
||||
authenticated API, then the real in-process ``POST /api/sync`` pipeline
|
||||
(the ``test_local_directory_sources.py`` / phase-94 registration +
|
||||
real-Sync pattern; no git anywhere):
|
||||
|
||||
* ``alpha/`` — ``root-note.md`` at the source root, ``one/`` (2 docs),
|
||||
``two/`` (2 docs);
|
||||
* ``beta/`` — ``gamma/`` (2 docs).
|
||||
|
||||
Total: 7 documents; alpha counts 5 (root + 2 + 2), beta counts 2. Every
|
||||
stored description is deterministic: the mock's EXISTING
|
||||
``FOLDER_SUMMARY_MODE`` branch (phase 94 — no mock changes needed)
|
||||
stores, per ≥ 2-doc folder, the canned one-liner naming the folder,
|
||||
``Fixture folder summary for <source>[/<folder>].`` — the ``synced_kb``
|
||||
module fixture pins those exact rows (all with
|
||||
``manually_edited = false``) after the sync, and the tests assert on
|
||||
that exact text.
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real
|
||||
``lite`` does whatever it does with the folder prompts, while this
|
||||
story's assertions key on the CANNED summaries (the phase-94
|
||||
convention): the stored rows are a pure function of the request, so the
|
||||
drill, the edit/clear, and the keep-through-sync observables are
|
||||
byte-exact only against the mock.
|
||||
|
||||
The view is admin-only (phase 16): the admin flows drive the REAL form
|
||||
login (``e2e.auth_helpers.login`` → ``/sources.html``); the anonymous
|
||||
test uses a fresh context and asserts the sign-in gate + zero catalog
|
||||
fetches.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_top_level_lists_sources_with_descriptions`` — the source rows:
|
||||
``alpha`` (5) + ``beta`` (2) in registry order with the canned
|
||||
source-root descriptions (the top-level rows ARE the sources — the
|
||||
``ls()`` equivalence); the top-level file table is HIDDEN (files are
|
||||
per-source); the stat cards read 7 documents + the computed chunks
|
||||
total (the tree walk = the former flat walk).
|
||||
2. ``test_drill_into_source`` — click the ``alpha`` row → breadcrumb
|
||||
``alpha`` (the ``aria-current`` segment); the level block shows
|
||||
alpha's root description (the canned text, title = the source name);
|
||||
the folder rows ``one`` / ``two`` each with count 2 + their canned
|
||||
summaries; the file row ``root-note.md`` (Source column, title,
|
||||
chunks column) in the level's file table.
|
||||
3. ``test_drill_into_folder`` — drill to ``alpha`` → ``two`` →
|
||||
breadcrumb ``alpha`` → ``two``; the level block shows ``two``'s
|
||||
description (title = the full source-relative path); the file rows
|
||||
``two-a`` / ``two-b`` (titles, the Source column ``alpha``);
|
||||
``#folders-wrap`` hidden (no subfolders); the breadcrumb link on
|
||||
``alpha`` goes back up to the source level.
|
||||
4. ``test_edit_folder_description`` — on the ``alpha`` level, Edit on
|
||||
the ``one/`` ROW (pinned: the row surface — the level-block surface
|
||||
is test 6's) → the textarea prefilled with the canned text → set the
|
||||
new text → Save → the new text renders in the row + status
|
||||
"Description updated."; a SQL assert (the ``SessionLocal`` house
|
||||
pattern) on ``folder_summaries``: the row's ``summary`` is the new
|
||||
text AND ``manually_edited`` is true.
|
||||
5. ``test_clear_folder_description`` — an empty save → the text is gone
|
||||
(the row cell emptied — the always-present Edit button stays) +
|
||||
status "Description cleared."; the SQL assert: no row for the folder
|
||||
(the next KB-changing sync regenerates an AI description — the
|
||||
reset path).
|
||||
6. ``test_manual_description_survives_a_changed_sync`` — the
|
||||
LEVEL-BLOCK edit on the ``beta`` source root (the static
|
||||
``#kb-level-edit`` button, ``folder_path ""``); a new file is added
|
||||
to the fixture dir; the real in-process sync (the mock regenerates
|
||||
the OTHER folders' summaries) → the catalog re-fetch renders the
|
||||
edited description UNCHANGED (the SQL row keeps the manual text +
|
||||
the flag — the ``kept_manual`` path, E2E-pinned) while the untouched
|
||||
folders show the canned regenerated text (flag back to false) and
|
||||
the new document lands in the tree (the ``one`` folder counts 3).
|
||||
7. ``test_reload_falls_back_to_top_level`` — drill to ``alpha/two``;
|
||||
delete ``two/``'s documents + chunks directly (the house DB
|
||||
pattern); re-show the RAG view (the nav link re-click — the
|
||||
``bor:view-refresh`` trigger) → the breadcrumb is hidden and the top
|
||||
level renders (the never-stale contract, PLAN §7.4).
|
||||
8. ``test_anonymous_sees_the_gate`` — the RAG view for an anonymous
|
||||
visitor: the sign-in gate visible, no stat cards, no folders/file
|
||||
table, no Edit affordance, and NO ``/api/docs*`` request (the
|
||||
phase-16 soft rule — asserted via the request log, the
|
||||
``test_admin_auth.py`` pattern).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings as _Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import FolderSummary
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||
# port in a combined run — this module app binds its own port instead
|
||||
# (a same-port second uvicorn dies on bind and would drive the wrong
|
||||
# server). Env-overridable.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_KBTREE", "8139"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture constants (deterministic, token-controlled)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
ALPHA = "alpha"
|
||||
BETA = "beta"
|
||||
|
||||
ROOT_NOTE = "root-note.md"
|
||||
ONE_A = "one/one-a.md"
|
||||
ONE_B = "one/one-b.md"
|
||||
ONE_C = "one/one-c.md" # test 6's KB-changing addition
|
||||
TWO_A = "two/two-a.md"
|
||||
TWO_B = "two/two-b.md"
|
||||
GAMMA_A = "gamma/gamma-a.md"
|
||||
GAMMA_B = "gamma/gamma-b.md"
|
||||
|
||||
ALPHA_COUNT = 5 # 1 root note + 2 one/ + 2 two/
|
||||
BETA_COUNT = 2
|
||||
TOTAL_DOCS = ALPHA_COUNT + BETA_COUNT
|
||||
|
||||
#: The sync-time folder descriptions the mock's canned
|
||||
#: ``FOLDER_SUMMARY_MODE`` branch stores (the phase-94 byte-stable
|
||||
#: template — the one-liner names the folder), in ``(source,
|
||||
#: folder_path)`` order: one row per ≥ 2-doc folder (the
|
||||
#: recursive-subtree rule) — the ``""`` rows are the source roots.
|
||||
SUMMARY_FOR = "Fixture folder summary for {}."
|
||||
ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA)
|
||||
ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one")
|
||||
TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/two")
|
||||
BETA_ROOT_SUM = SUMMARY_FOR.format(BETA)
|
||||
GAMMA_SUM = SUMMARY_FOR.format(f"{BETA}/gamma")
|
||||
|
||||
EXPECTED_SUMMARIES: list[tuple[str, str, str]] = [
|
||||
(ALPHA, "", ALPHA_ROOT_SUM),
|
||||
(ALPHA, "one", ONE_SUM),
|
||||
(ALPHA, "two", TWO_SUM),
|
||||
(BETA, "", BETA_ROOT_SUM),
|
||||
(BETA, "gamma", GAMMA_SUM),
|
||||
]
|
||||
assert [
|
||||
(source, folder) for source, folder, _s in EXPECTED_SUMMARIES
|
||||
] == sorted((source, folder) for source, folder, _s in EXPECTED_SUMMARIES)
|
||||
|
||||
#: The hand-edited descriptions (tests 4 and 6) — distinctive sentences
|
||||
#: no part of the fixture or the canned template contains, so the
|
||||
#: round-trip assertions can never pass against the old text.
|
||||
NEW_ALPHA_ONE = (
|
||||
"Owner override: alpha/one holds the one-a and one-b fixture notes — "
|
||||
"the drill catalog's first hand-written description. (RESE-KBTREE-01)"
|
||||
)
|
||||
NEW_BETA_ROOT = (
|
||||
"Owner override: beta is the second fixture source — gamma only. "
|
||||
"(RESE-KBTREE-02)"
|
||||
)
|
||||
|
||||
|
||||
def _md(title: str, body: str) -> str:
|
||||
return f"# {title}\n\n{body}\n"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kb_tree_dirs(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
|
||||
"""The two-source temp tree (see the module docstring): the app
|
||||
server runs on the same host, so the paths are visible to it. The
|
||||
directory NAMES are the source names (``kind=local`` → the
|
||||
directory's basename, phase 38)."""
|
||||
root = tmp_path_factory.mktemp("bor_kb_tree")
|
||||
alpha = root / ALPHA
|
||||
beta = root / BETA
|
||||
(alpha / "one").mkdir(parents=True)
|
||||
(alpha / "two").mkdir(parents=True)
|
||||
(beta / "gamma").mkdir(parents=True)
|
||||
|
||||
(alpha / ROOT_NOTE).write_text(
|
||||
_md(
|
||||
"Alpha Root Note",
|
||||
"This file sits directly under the alpha source, not in any "
|
||||
"folder.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / ONE_A).write_text(
|
||||
_md(
|
||||
"Alpha One A",
|
||||
"Alpha one fixture note A: covers topic A of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / ONE_B).write_text(
|
||||
_md(
|
||||
"Alpha One B",
|
||||
"Alpha one fixture note B: covers topic B of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / TWO_A).write_text(
|
||||
_md(
|
||||
"Alpha Two A",
|
||||
"Alpha two fixture note A: covers topic A of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / TWO_B).write_text(
|
||||
_md(
|
||||
"Alpha Two B",
|
||||
"Alpha two fixture note B: covers topic B of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(beta / GAMMA_A).write_text(
|
||||
_md(
|
||||
"Beta Gamma A",
|
||||
"Beta gamma fixture note A: covers topic A of the beta "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(beta / GAMMA_B).write_text(
|
||||
_md(
|
||||
"Beta Gamma B",
|
||||
"Beta gamma fixture note B: covers topic B of the beta "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert (alpha / TWO_A).is_file() and (beta / GAMMA_B).is_file()
|
||||
return alpha, beta
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, kb_tree_dirs: tuple[Path, Path]) -> Iterator[str]:
|
||||
"""The real app under test — per-module app (the conftest pattern,
|
||||
cf. ``test_local_directory_sources.py`` / ``test_ls_tree_drilldown.
|
||||
py``): NO ``BOR_GIT_SOURCES`` (the env fallback is git-only — the
|
||||
sources here are DB-registered local directories), the mock LLM, the
|
||||
mock-calibrated threshold, and the leak-guarded code defaults. The
|
||||
session app is never started in this isolated run, so no port clash.
|
||||
``kb_tree_dirs`` is a dependency only for the fixture ordering (the
|
||||
temp tree exists before the app boots — the sync reads it)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern): this suite never
|
||||
# asks the chat model anything — the gate is never on a path.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
# Phase 67: instant retry waits + the code-default budget (the
|
||||
# conftest leak-guard pattern).
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
|
||||
# reads it from cwd) — override it with an EMPTY value (the env var
|
||||
# beats the .env file): the registry must hold EXACTLY the two
|
||||
# local directories this suite registers (a leftover env git list
|
||||
# would pollute the top-level rows the whole story asserts on).
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Leak guards (conftest pattern): an operator's local (gitignored)
|
||||
# .env cannot leak corpus-specific settings into the app under test.
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
_Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
|
||||
share one Postgres, so a leftover git_sources row would pollute the
|
||||
top-level rows and a leftover document would show up in the level
|
||||
file tables and the stat cards the suite asserts on exactly."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||
|
||||
|
||||
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state (the test_local_directory_sources pattern,
|
||||
over plain httpx)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
return body
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def synced_kb(app_server: str, kb_tree_dirs: tuple[Path, Path]) -> None:
|
||||
"""The story's precondition: the folder-structured KB synced under
|
||||
the deterministic mock.
|
||||
|
||||
Registers the two temp directories through the authenticated API
|
||||
(the ``test_local_directory_sources.py`` pattern — ``alpha`` FIRST,
|
||||
committed separately, so the registry order — ``(added_at, id)`` —
|
||||
lists alpha before beta, the top-level row order the suite
|
||||
asserts), runs the REAL in-process sync (``POST /api/sync`` —
|
||||
walk → chunk → embed → overview → folder summaries → version
|
||||
bump), and pins the stored folder descriptions: the mock's canned
|
||||
``FOLDER_SUMMARY_MODE`` branch (phase 94) makes the sync store one
|
||||
deterministic row per ≥ 2-doc folder — the tests assert on that
|
||||
exact text (and on the phase-97 ``manually_edited`` flag: every
|
||||
stored row starts out AI-written).
|
||||
"""
|
||||
alpha, beta = kb_tree_dirs
|
||||
_truncate_all()
|
||||
with httpx.Client(base_url=app_server, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(alpha)}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
time.sleep(0.05) # distinct added_at: alpha before beta (registry order)
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(beta)}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
body = _wait_sync_done_http(client)
|
||||
assert body["state"] == "success", body
|
||||
detail = body["detail"]
|
||||
assert detail["added"] == TOTAL_DOCS, detail
|
||||
assert detail["pruned"] == 0, detail
|
||||
assert detail["overview"] is True, detail
|
||||
# The change-gated folder descriptions (phase 94) landed: one row
|
||||
# per ≥ 2-doc folder, the mock's byte-stable text — and every row
|
||||
# AI-written (the ``manually_edited`` flag starts false).
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
select(
|
||||
FolderSummary.source,
|
||||
FolderSummary.folder_path,
|
||||
FolderSummary.summary,
|
||||
FolderSummary.manually_edited,
|
||||
).order_by(FolderSummary.source, FolderSummary.folder_path)
|
||||
).all()
|
||||
assert [
|
||||
(source, folder, summary, False)
|
||||
for source, folder, summary in EXPECTED_SUMMARIES
|
||||
] == [(s, f, t, m) for s, f, t, m in rows], rows
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Page + DB helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kb_totals() -> tuple[int, int]:
|
||||
"""The KB-wide ``(documents, chunks)`` totals for the two sources,
|
||||
straight from the DB — the values the stat cards' tree walk must
|
||||
render (the former flat /api/docs walk, one level deeper)."""
|
||||
with SessionLocal() as db:
|
||||
docs = db.scalar(
|
||||
text("SELECT count(*) FROM documents WHERE source IN (:a, :b)"),
|
||||
{"a": ALPHA, "b": BETA},
|
||||
)
|
||||
chunks = db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM chunks c JOIN documents d "
|
||||
"ON c.document_id = d.id WHERE d.source IN (:a, :b)"
|
||||
),
|
||||
{"a": ALPHA, "b": BETA},
|
||||
)
|
||||
return int(docs), int(chunks)
|
||||
|
||||
|
||||
def _doc_chunks(source: str, path: str) -> int:
|
||||
"""One document's chunk count (the Chunks column value the file
|
||||
row must render — the count ``GET /api/docs`` returns for it)."""
|
||||
with SessionLocal() as db:
|
||||
n = db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM chunks c JOIN documents d "
|
||||
"ON c.document_id = d.id WHERE d.source = :s AND d.path = :p"
|
||||
),
|
||||
{"s": source, "p": path},
|
||||
)
|
||||
return int(n)
|
||||
|
||||
|
||||
def _folder_row(source: str, folder: str) -> tuple[str, bool] | None:
|
||||
"""The stored ``(source, folder)`` description as
|
||||
``(summary, manually_edited)`` (``None`` when no row exists) —
|
||||
read back through a fresh session (the ``SessionLocal`` house
|
||||
pattern)."""
|
||||
with SessionLocal() as db:
|
||||
row = db.scalar(
|
||||
select(FolderSummary).where(
|
||||
FolderSummary.source == source,
|
||||
FolderSummary.folder_path == folder,
|
||||
)
|
||||
)
|
||||
return (row.summary, row.manually_edited) if row is not None else None
|
||||
|
||||
|
||||
def _run_sync(app_url: str) -> dict[str, Any]:
|
||||
"""One admin sync through the API — the cookie-authenticated
|
||||
``POST /api/sync`` + the status poll (the ``synced_kb`` pattern,
|
||||
for test 6's mid-suite KB-changing sync)."""
|
||||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
return _wait_sync_done_http(client)
|
||||
|
||||
|
||||
def _wait_top_level(page: Page) -> None:
|
||||
"""The admin boot has rendered the top level: the two source rows
|
||||
are in the folders table (the tree's single fetch settled) and the
|
||||
top-level file table is empty (files are per-source — always hidden
|
||||
at the top)."""
|
||||
expect(page.locator("#folders-tbody tr")).to_have_count(2, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Drill one level at a time (client-side — no fetch, no URL
|
||||
change): each name is the EXACT text of the source/folder link at
|
||||
the current level (the row builders' link text: the source name,
|
||||
or the folder's last path segment)."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _expect_level(page: Page, title: str, summary: str) -> None:
|
||||
"""The level block shows the current directory's stored
|
||||
description: the full source-relative path as the title, the
|
||||
description as the text."""
|
||||
expect(page.locator("#kb-level")).to_be_visible()
|
||||
expect(page.locator("#kb-level-title")).to_have_text(title)
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(summary)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. The top level: the source rows (the ls() equivalence) + stat cards
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_top_level_lists_sources_with_descriptions(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
docs, chunks = _kb_totals()
|
||||
assert docs == TOTAL_DOCS # the fixture's precondition (7 documents)
|
||||
|
||||
login(page, app_url) # → /sources.html (the RAG view)
|
||||
_wait_top_level(page)
|
||||
|
||||
# The source rows: registry order (alpha registered first), the
|
||||
# recursive count, the stored (source, "") description — the
|
||||
# top-level rows ARE the sources (the ls() equivalence: name,
|
||||
# count, description).
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text(str(ALPHA_COUNT))
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(ALPHA_ROOT_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text(str(BETA_COUNT))
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(BETA_ROOT_SUM)
|
||||
# The edit affordance is ALWAYS present (a description can be
|
||||
# CREATED where none is stored) — the row surface, one per row.
|
||||
expect(page.locator("#folders-tbody .kb-summary-edit")).to_have_count(2)
|
||||
|
||||
# The top level carries no breadcrumb and no level block; the
|
||||
# empty state is off (a source IS listed).
|
||||
expect(page.locator("#kb-crumb")).to_be_hidden()
|
||||
expect(page.locator("#kb-level")).to_be_hidden()
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
|
||||
# The file table is HIDDEN at the top level (files are seen per
|
||||
# source, as with ls(source) — the ls() equivalence).
|
||||
expect(page.locator("#docs-table")).to_be_hidden()
|
||||
|
||||
# The stat cards: the KB-wide walk of the WHOLE tree — identical
|
||||
# values to the former flat /api/docs walk.
|
||||
expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS))
|
||||
expect(page.locator("#stat-chunks")).to_have_text(str(chunks))
|
||||
expect(page.locator("#stat-last")).not_to_have_text("–")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Drill into a source: breadcrumb, level block, folder rows, files
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_drill_into_source(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
root_chunks = _doc_chunks(ALPHA, ROOT_NOTE)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA)
|
||||
|
||||
# Breadcrumb: the top-level link + the current source segment.
|
||||
expect(page.locator("#kb-crumb")).to_be_visible()
|
||||
links = page.locator("#kb-crumb a.kb-crumb-link")
|
||||
expect(links).to_have_count(1)
|
||||
expect(links.nth(0)).to_have_text("Knowledge base")
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text(ALPHA)
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_attribute(
|
||||
"aria-current", "page"
|
||||
)
|
||||
|
||||
# The level block: the source's stored root description (title =
|
||||
# the source name — the source root is folder "") + the level's
|
||||
# own Edit button (task 05).
|
||||
_expect_level(page, ALPHA, ALPHA_ROOT_SUM)
|
||||
expect(page.locator("#kb-level-edit")).to_be_visible()
|
||||
|
||||
# The subfolder rows: path order, the recursive count, the stored
|
||||
# (AI) description.
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(ONE_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(TWO_SUM)
|
||||
|
||||
# The level's direct files: root-note.md — the UNCHANGED 5-column
|
||||
# contract (makeRow): Source | Path | Title | Chunks | Indexed.
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
frows = page.locator("#docs-tbody tr")
|
||||
expect(frows).to_have_count(1)
|
||||
expect(frows.nth(0).locator("td:nth-child(1)")).to_have_text(ALPHA)
|
||||
expect(frows.nth(0).locator("a.doc-link")).to_have_text(ROOT_NOTE)
|
||||
expect(frows.nth(0).locator("td:nth-child(3)")).to_have_text("Alpha Root Note")
|
||||
expect(frows.nth(0).locator("td:nth-child(4)")).to_have_text(str(root_chunks))
|
||||
expect(frows.nth(0).locator("td:nth-child(5)")).not_to_have_text("")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Drill into a folder: the file level + the breadcrumb going back up
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_drill_into_folder(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA, "two")
|
||||
|
||||
# Breadcrumb: the top-level link + the source link + the current
|
||||
# folder segment.
|
||||
links = page.locator("#kb-crumb a.kb-crumb-link")
|
||||
expect(links).to_have_count(2)
|
||||
expect(links.nth(0)).to_have_text("Knowledge base")
|
||||
expect(links.nth(1)).to_have_text(ALPHA)
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text("two")
|
||||
|
||||
# The level block: two's stored description, the FULL
|
||||
# source-relative path as the title.
|
||||
_expect_level(page, f"{ALPHA}/two", TWO_SUM)
|
||||
|
||||
# The file rows: two-a / two-b (path order), the Source column
|
||||
# carries the source (the file nodes are source-scoped in the tree
|
||||
# — the row object restores the flat shape makeRow reads).
|
||||
frows = page.locator("#docs-tbody tr")
|
||||
expect(frows).to_have_count(2)
|
||||
expect(frows.nth(0).locator("td:nth-child(1)")).to_have_text(ALPHA)
|
||||
expect(frows.nth(0).locator("a.doc-link")).to_have_text(TWO_A)
|
||||
expect(frows.nth(0).locator("td:nth-child(3)")).to_have_text("Alpha Two A")
|
||||
expect(frows.nth(1).locator("td:nth-child(1)")).to_have_text(ALPHA)
|
||||
expect(frows.nth(1).locator("a.doc-link")).to_have_text(TWO_B)
|
||||
expect(frows.nth(1).locator("td:nth-child(3)")).to_have_text("Alpha Two B")
|
||||
|
||||
# No subfolders under two: the folders table is hidden.
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden()
|
||||
|
||||
# The breadcrumb link on the source goes back UP to the source
|
||||
# level (client-side — no fetch, no URL change).
|
||||
page.click(f'#kb-crumb a.kb-crumb-link:text-is("{ALPHA}")')
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text(ALPHA)
|
||||
expect(page.locator("#kb-crumb a.kb-crumb-link")).to_have_count(1)
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Edit a folder description (the row surface) → manually_edited
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_folder_description(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA)
|
||||
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
row = rows.nth(0)
|
||||
expect(row.locator("a.folder-link")).to_have_text("one")
|
||||
|
||||
# Edit → the inline editor: textarea PREFILLED with the stored
|
||||
# (canned) text, Save / Cancel, and the role=status live region.
|
||||
row.locator(".kb-summary-edit").click()
|
||||
editor = page.locator(".kb-summary-editor")
|
||||
expect(editor).to_have_count(1)
|
||||
expect(editor).to_be_visible()
|
||||
expect(editor).to_have_value(ONE_SUM)
|
||||
expect(page.locator(".kb-summary-save")).to_be_visible()
|
||||
expect(page.locator(".kb-summary-cancel")).to_be_visible()
|
||||
status = row.locator(".kb-summary-status")
|
||||
expect(status).to_have_attribute("role", "status")
|
||||
expect(status).to_have_attribute("aria-live", "polite")
|
||||
|
||||
# Replace the text with the distinctive hand-edit and Save.
|
||||
page.fill(".kb-summary-editor", NEW_ALPHA_ONE)
|
||||
page.click(".kb-summary-save")
|
||||
|
||||
# The live-region confirmation; the new text renders in the row
|
||||
# (the textContent re-render — the canned text is gone); the
|
||||
# always-present Edit button is back.
|
||||
expect(status).to_have_text("Description updated.")
|
||||
expect(row.locator("td:nth-child(3) span")).to_have_text(NEW_ALPHA_ONE)
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The server row: the new text AND the manually_edited flag (the
|
||||
# task-01 keep/keep-out rules apply from this save on — the
|
||||
# sync-time generator must never rewrite or prune the row).
|
||||
stored = _folder_row(ALPHA, "one")
|
||||
assert stored == (NEW_ALPHA_ONE, True), stored
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. An empty save CLEARS — the row is deleted (the reset path)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_folder_description(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA)
|
||||
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
row = rows.nth(0)
|
||||
expect(row.locator("a.folder-link")).to_have_text("one")
|
||||
# The row holds a description (the module's canned row — or test
|
||||
# 4's manual edit when the tests run in order); the editor opens
|
||||
# either way (the button is always present).
|
||||
row.locator(".kb-summary-edit").click()
|
||||
expect(page.locator(".kb-summary-editor")).to_be_visible()
|
||||
|
||||
# Select-all + delete — clear the prefilled editor — then Save.
|
||||
page.fill(".kb-summary-editor", "")
|
||||
page.click(".kb-summary-save")
|
||||
|
||||
status = row.locator(".kb-summary-status")
|
||||
expect(status).to_have_text("Description cleared.")
|
||||
# The text is gone: the cell empties (the always-present Edit
|
||||
# button stays — a description can be re-created from the empty
|
||||
# cell).
|
||||
expect(row.locator("td:nth-child(3) span")).to_have_text("")
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The SQL assert: no row for the folder (the next KB-changing
|
||||
# sync regenerates an AI description — the reset path).
|
||||
assert _folder_row(ALPHA, "one") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. The manual description survives a KB-changing sync (kept_manual)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_description_survives_a_changed_sync(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
synced_kb: None,
|
||||
kb_tree_dirs: tuple[Path, Path],
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
alpha, _beta = kb_tree_dirs
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, BETA)
|
||||
|
||||
# The beta level: the stored root description + the gamma row
|
||||
# (both canned — AI-written at the module sync).
|
||||
_expect_level(page, BETA, BETA_ROOT_SUM)
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(1)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("gamma")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The LEVEL-BLOCK Edit (the level surface — test 4 pins the row
|
||||
# surface): the static #kb-level-edit button, the source root
|
||||
# (folder_path ""). The textarea is prefilled with the stored text.
|
||||
page.click("#kb-level-edit")
|
||||
editor = page.locator(".kb-summary-editor")
|
||||
expect(editor).to_have_count(1)
|
||||
expect(editor).to_have_value(BETA_ROOT_SUM)
|
||||
page.fill(".kb-summary-editor", NEW_BETA_ROOT)
|
||||
page.click(".kb-summary-save")
|
||||
expect(page.locator("#kb-level .kb-summary-status")).to_have_text(
|
||||
"Description updated."
|
||||
)
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(NEW_BETA_ROOT)
|
||||
|
||||
# The KB-changing sync: a new file lands in alpha/one (the fixture
|
||||
# dir — the app runs on the same host), then the real in-process
|
||||
# sync (the mock's FOLDER_SUMMARY_MODE branch regenerates the
|
||||
# non-manual folders' summaries).
|
||||
(alpha / ONE_C).write_text(
|
||||
_md(
|
||||
"Alpha One C",
|
||||
"Alpha one fixture note C: added for the changed-sync test.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
body = _run_sync(app_url)
|
||||
assert body["state"] == "success", body
|
||||
assert body["detail"]["added"] == 1, body["detail"]
|
||||
|
||||
# The catalog re-fetch (the nav re-click — the bor:view-refresh
|
||||
# trigger, no document load): the current position (the beta root)
|
||||
# still exists, so the view stays put and re-renders from the NEW
|
||||
# tree — the EDITED description is UNCHANGED (kept_manual: the
|
||||
# generator skipped the owner's row, no lite burn on it)…
|
||||
page.click("#nav-sources")
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(NEW_BETA_ROOT)
|
||||
# …while the untouched folder shows the canned REGENERATED text.
|
||||
expect(
|
||||
page.locator("#folders-tbody tr")
|
||||
.nth(0)
|
||||
.locator("td:nth-child(3) span")
|
||||
).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The new document landed in the tree: back to the top (the
|
||||
# breadcrumb's top-level link), then into alpha — the one folder
|
||||
# now counts 3 with its regenerated canned description.
|
||||
page.locator("#kb-crumb a.kb-crumb-link").nth(0).click()
|
||||
_drill(page, ALPHA)
|
||||
one_row = page.locator("#folders-tbody tr").nth(0)
|
||||
expect(one_row.locator("a.folder-link")).to_have_text("one")
|
||||
expect(one_row.locator("td:nth-child(2)")).to_have_text("3")
|
||||
expect(one_row.locator("td:nth-child(3) span")).to_have_text(ONE_SUM)
|
||||
|
||||
# The SQL asserts: the manual row survived (text + flag), the
|
||||
# untouched rows were regenerated (the canned text, the flag back
|
||||
# to false — the rows are AI-written again).
|
||||
assert _folder_row(BETA, "") == (NEW_BETA_ROOT, True)
|
||||
assert _folder_row(BETA, "gamma") == (GAMMA_SUM, False)
|
||||
assert _folder_row(ALPHA, "one") == (ONE_SUM, False)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 7. A vanished location resets the view to the top level (never stale)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reload_falls_back_to_top_level(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA, "two")
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text("two")
|
||||
|
||||
# The folder vanishes: its documents + chunks deleted directly (the
|
||||
# house DB pattern — no sync, no API).
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM chunks WHERE document_id IN "
|
||||
"(SELECT id FROM documents WHERE source = :s AND path LIKE 'two/%')"
|
||||
),
|
||||
{"s": ALPHA},
|
||||
)
|
||||
db.execute(
|
||||
text("DELETE FROM documents WHERE source = :s AND path LIKE 'two/%'"),
|
||||
{"s": ALPHA},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Re-show the RAG view: the nav-link RE-CLICK is the
|
||||
# bor:view-refresh trigger (the router's re-fetch on the mounted
|
||||
# view — no document load).
|
||||
page.click("#nav-sources")
|
||||
|
||||
# The vanished location resets the view to the top level BEFORE
|
||||
# rendering (PLAN §7.4 — no stale breadcrumb, no stale block): the
|
||||
# breadcrumb and the level block are hidden, the source rows
|
||||
# re-render.
|
||||
expect(page.locator("#kb-crumb")).to_be_hidden()
|
||||
expect(page.locator("#kb-level")).to_be_hidden()
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 8. Anonymous: the sign-in gate — and zero catalog fetches
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_sees_the_gate(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
docs_calls: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: docs_calls.append(r.url) if "/api/docs" in r.url else None,
|
||||
)
|
||||
|
||||
# Fresh context (the function-scoped page fixture — no login).
|
||||
page.goto(app_url + "/sources.html")
|
||||
gate = page.locator("#sources-gate")
|
||||
expect(gate).to_be_visible(timeout=30_000)
|
||||
expect(gate).to_contain_text("Sign in to view the full catalog")
|
||||
|
||||
# The catalog surfaces stay hidden (the tree ships hidden and never
|
||||
# fills for anonymous)…
|
||||
expect(page.locator("#stat-cards")).to_be_hidden()
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden()
|
||||
expect(page.locator("#docs-table")).to_be_hidden()
|
||||
expect(page.locator("#kb-crumb")).to_be_hidden()
|
||||
expect(page.locator("#kb-level")).to_be_hidden()
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
expect(page.locator("#folders-tbody tr")).to_have_count(0)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
# …and there is no Edit affordance anywhere (the row buttons are
|
||||
# never built; the static level button sits in the hidden block).
|
||||
expect(page.locator(".kb-summary-edit:visible")).to_have_count(0)
|
||||
expect(page.locator("#kb-level-edit")).to_be_hidden()
|
||||
|
||||
# The soft rule's wire-level proof (the phase-16 pattern, the
|
||||
# tree edition): not one catalog request — the anonymous gate
|
||||
# branch never fetches /api/docs/tree.
|
||||
assert docs_calls == [], f"anonymous RAG view fetched the catalog: {docs_calls}"
|
||||
@@ -393,6 +393,7 @@ def _truncate_all() -> None:
|
||||
"kb_overview, git_sources, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -133,8 +133,14 @@ def _ask_table_answer(page: Page, app_url: str) -> Any:
|
||||
|
||||
|
||||
def _open_tables_doc_modal(page: Page, app_url: str) -> None:
|
||||
"""Admin → Sources → the tables.md row → same-page document modal."""
|
||||
"""Admin → Sources → the tables.md row → same-page document modal.
|
||||
|
||||
Phase 97: the catalog is the drill-down tree — the row lives at
|
||||
its folder level (``docs`` → ``homelab``); the drill is the only
|
||||
change."""
|
||||
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||
for name in ("docs", "homelab"): # the fixture's indexed-only source + folder
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
row = page.locator("#docs-tbody tr", has_text=TABLES_PATH)
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
|
||||
@@ -608,15 +608,16 @@ def test_viewer_nav_click_full_loads_the_shell_rag_view(
|
||||
|
||||
# The arrival is the SHELL at the RAG view's URL: the document loaded
|
||||
# for real (the sentinel is gone), the RAG view is rendered (first
|
||||
# table row visible), the Chat view is hidden AND inert in the same
|
||||
# document, and the RAG link carries the router's single-writer
|
||||
# active stamp.
|
||||
# catalog row visible — phase 97: the top level lists the sources;
|
||||
# the file table is per-level and hidden at the top), the Chat view
|
||||
# is hidden AND inert in the same document, and the RAG link carries
|
||||
# the router's single-writer active stamp.
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
assert page.evaluate("() => window.__phase76_viewer") is None, (
|
||||
"a surviving document's nav click must be a real departure "
|
||||
"(a fresh document load wipes window globals)"
|
||||
)
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
|
||||
"the chat view ships hidden AND inert in the shell"
|
||||
|
||||
@@ -106,7 +106,9 @@ ANSWER = ".msg.brain .bubble:not(.typing)"
|
||||
#: The other three navbar views (test 2): the nav link, the view's
|
||||
#: URL (pushState target), and an admin-visible content marker inside
|
||||
#: the view (proof the view actually showed — the RAG view gets the
|
||||
#: same treatment with ``#docs-tbody tr`` in test 1).
|
||||
#: same treatment with ``#folders-tbody tr`` in test 1: phase 97's
|
||||
#: top level lists the sources, the file table is per-level and
|
||||
#: hidden at the top).
|
||||
OTHER_VIEWS: tuple[tuple[str, str, str], ...] = (
|
||||
("#nav-git-sources", "/git-sources.html", "#git-sources-content"),
|
||||
("#nav-tuning", "/tuning.html", "#tune-save"),
|
||||
@@ -329,10 +331,11 @@ def test_rag_switch_mid_stream_completes(
|
||||
"a real navigation would have wiped the window sentinel — "
|
||||
"the switch must be same-document"
|
||||
)
|
||||
# The RAG view actually showed (the fixture docs' rows are listed)
|
||||
# and the chat view is hidden (the stream fills it in the
|
||||
# background — that persistence IS the fix).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# The RAG view actually showed (the source rows are listed — phase
|
||||
# 97: the top level lists the sources; the file table is per-level
|
||||
# and hidden at the top) and the chat view is hidden (the stream
|
||||
# fills it in the background — that persistence IS the fix).
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
|
||||
# Stay on the RAG view while the stream keeps running (the switch
|
||||
|
||||
@@ -109,7 +109,10 @@ TITLE_E = "Phase77 popstate E"
|
||||
# pollers hit OTHER paths and only run while a job is in flight).
|
||||
DATA_VIEWS: tuple[tuple[str, str, str], ...] = (
|
||||
("#nav-history", "/history.html", "/api/chats"),
|
||||
("#nav-sources", "/sources.html", "/api/docs"),
|
||||
# Phase 97: the RAG view's list endpoint is the drill-down tree
|
||||
# (GET /api/docs/tree — the flat /api/docs is the unchanged API
|
||||
# surface, no longer the view's fetch).
|
||||
("#nav-sources", "/sources.html", "/api/docs/tree"),
|
||||
("#nav-git-sources", "/git-sources.html", "/api/git-sources"),
|
||||
("#nav-tuning", "/tuning.html", "/api/steering"),
|
||||
)
|
||||
@@ -568,10 +571,11 @@ def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_rea
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase77", (
|
||||
"a real navigation would have wiped the window sentinel"
|
||||
)
|
||||
# The RAG view actually showed (the fixture docs' rows are listed)
|
||||
# and the chat view is hidden (the stream fills it in the
|
||||
# background — that persistence IS the phase-76 fix).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# The RAG view actually showed (the source rows are listed — phase
|
||||
# 97: the top level lists the sources; the file table is per-level
|
||||
# and hidden at the top) and the chat view is hidden (the stream
|
||||
# fills it in the background — that persistence IS the phase-76 fix).
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
|
||||
# Stay on RAG while the stream keeps running, then return to Chat.
|
||||
|
||||
@@ -26,6 +26,11 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
below threshold **and** zero FTS hits) → honest-positive: the answer
|
||||
bubble is not ``.is-deflected`` and a source chip names
|
||||
``templates/deploy.j2``.
|
||||
|
||||
Phase 97 adaptation: the Sources table is the DRILL-DOWN TREE — the
|
||||
rows live at their folder levels (``docs`` → ``homelab`` → ``quadlet``
|
||||
/ ``templates``); the drill is the only change, the asserted
|
||||
rows/links are unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -99,6 +104,20 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Drill one level at a time (phase 97 — client-side, no fetch,
|
||||
no URL change): each name is the EXACT text of the source/folder
|
||||
link at the current level."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _go_top(page: Page) -> None:
|
||||
"""Back to the top level: the breadcrumb's top-level link (call
|
||||
between drills only — the breadcrumb is hidden at the top)."""
|
||||
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Default-extensions import indexes the new formats (API view)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -133,7 +152,12 @@ def test_quadlet_and_jinja_indexed(page: Page, app_url: str, mock_llm: int, db_r
|
||||
def test_sources_table_shows_them(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||
for path in NEW_FORMAT_DOCS:
|
||||
for i, path in enumerate(NEW_FORMAT_DOCS):
|
||||
# Phase 97: the rows live at their folder levels — drill
|
||||
# source → folder(s) per path (the drill is the only change).
|
||||
if i > 0:
|
||||
_go_top(page)
|
||||
_drill(page, "docs", *path.rsplit("/", 1)[0].split("/"))
|
||||
row = page.locator("#docs-tbody tr", has_text=path)
|
||||
expect(row).to_have_count(1)
|
||||
link = row.locator("td:nth-child(2) a.doc-link")
|
||||
@@ -154,6 +178,8 @@ def test_container_content_viewable(
|
||||
_reset_db(mock_llm, seed=True)
|
||||
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||
|
||||
# Phase 97: the row lives at its folder level — drill first.
|
||||
_drill(page, "docs", "homelab", "quadlet")
|
||||
row = page.locator("#docs-tbody tr", has_text="homelab/quadlet/compose.container")
|
||||
expect(row).to_have_count(1)
|
||||
link = row.locator("td:nth-child(2) a.doc-link")
|
||||
|
||||
@@ -213,7 +213,10 @@ def test_no_horizontal_overflow_at_viewports(
|
||||
_assert_no_doc_overflow(page, f"chat @ {width}px")
|
||||
|
||||
page.goto(f"{app_url}/sources.html") # phase 16: admin-only
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
# Phase 97: the top level lists the sources (the file table
|
||||
# is per-level, always hidden at the top) — the source row
|
||||
# is the catalog-rendered signal.
|
||||
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
_assert_no_doc_overflow(page, f"sources @ {width}px")
|
||||
finally:
|
||||
page.close()
|
||||
@@ -276,10 +279,14 @@ def test_sources_table_full_width(
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
try:
|
||||
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
|
||||
# (the git-sources one ships hidden) — scope to the RAG view.
|
||||
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
|
||||
# Phase 97: the top level lists the sources (the file table is
|
||||
# per-level, hidden at the top) — the source row is the
|
||||
# catalog-rendered signal, and the visible catalog card is
|
||||
# #folders-wrap (the ONE .table-wrap without an id in the RAG
|
||||
# view is no longer unique — the shell carries the git-sources
|
||||
# one too, so scope by the id).
|
||||
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
wrap_box = page.locator("#folders-wrap").bounding_box()
|
||||
shell_box = page.locator(".sources-shell").bounding_box()
|
||||
assert wrap_box is not None and shell_box is not None
|
||||
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
|
||||
@@ -292,9 +299,11 @@ def test_sources_table_full_width(
|
||||
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
||||
try:
|
||||
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
|
||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
# Phase 97: the top-level catalog card is #folders-wrap (the
|
||||
# .kb-folders-table keeps its min-width and scrolls in-card).
|
||||
mobile.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
scroll, client = mobile.evaluate(
|
||||
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||
"() => { const el = document.querySelector('#folders-wrap');"
|
||||
" return [el.scrollWidth, el.clientWidth]; }"
|
||||
)
|
||||
assert scroll > client, (
|
||||
@@ -366,9 +375,14 @@ def test_a11y_landmarks_and_labels(
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(page.locator("#auth-gate")).to_be_hidden()
|
||||
|
||||
# Landmarks (PLAN §7.2).
|
||||
# Landmarks (PLAN §7.2). Phase 97: the shell carries a SECOND
|
||||
# nav in the RAG view (#kb-crumb, the catalog breadcrumb —
|
||||
# hidden at the top level, pinned in test_kb_tree), so the
|
||||
# primary-nav landmark is pinned by its label.
|
||||
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
|
||||
assert page.locator("nav[aria-label]").count() == 1, f"labeled nav missing on {path}"
|
||||
assert page.locator("nav[aria-label='Primary']").count() == 1, (
|
||||
f"labeled primary nav missing on {path}"
|
||||
)
|
||||
assert page.locator("main#main").count() == 1, f"main#main missing on {path}"
|
||||
assert page.locator("footer.app-footer").count() == 1, f"footer missing on {path}"
|
||||
|
||||
@@ -577,6 +591,11 @@ def test_long_content_wraps_without_overflow(
|
||||
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
||||
try:
|
||||
login(phone, app_url, next="/sources.html") # phase 16: admin-only
|
||||
# Phase 97: the catalog is the drill-down tree — the row lives
|
||||
# at the deep level of the longkb source (the drill is the only
|
||||
# change).
|
||||
for name in ("longkb", "deep"):
|
||||
phone.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
|
||||
row.wait_for(state="visible", timeout=10_000)
|
||||
cell = row.get_by_role("cell").nth(1)
|
||||
|
||||
@@ -118,8 +118,15 @@ def test_multi_format_import_hidden_doc_excluded(
|
||||
}
|
||||
|
||||
# The Sources page (we're already on it, signed in) reflects the set.
|
||||
# Phase 97: the file table is hidden at the top level (the source
|
||||
# row is the tree's top) — drill into the source, where a
|
||||
# `.hidden` junk row WOULD appear, and scope the absence there
|
||||
# (the stat card pins the 13 total: the junk is counted nowhere).
|
||||
expect(page.locator("#stat-docs")).to_have_text("13")
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
page.click('#folders-tbody a.folder-link:text-is("docs")')
|
||||
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
||||
expect(page.locator("#folders-tbody .folder-link", has_text=".hidden")).to_have_count(0)
|
||||
|
||||
|
||||
def test_gitlab_question_is_grounded_with_gitlab_chip(
|
||||
|
||||
@@ -266,7 +266,10 @@ def test_admin_bar_on_all_pages(
|
||||
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sources-gate")).to_be_hidden()
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# Phase 97: the top level lists the sources (the file table is
|
||||
# per-level, hidden at the top) — the source row is the
|
||||
# catalog-rendered signal.
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
assert_shared_bar(page, admin=True, page_kind="sources")
|
||||
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
|
||||
@@ -305,8 +305,10 @@ def test_full_answer_completes_after_rag_nav_midstream(
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||||
# The RAG view actually mounted (the fixture docs' rows are listed).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# The RAG view actually mounted (the source rows are listed — phase
|
||||
# 97: the top level lists the sources; the file table is per-level
|
||||
# and hidden at the top).
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
# Stay on the RAG view while the stream keeps running in the
|
||||
# background (the switch is ~t+2s; the full answer needs ~9s).
|
||||
@@ -381,7 +383,9 @@ def test_nav_switch_before_first_token_completes(
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# Phase 97: the source rows are the top-level catalog-rendered
|
||||
# signal (the file table is per-level, hidden at the top).
|
||||
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
|
||||
# first content frames land while the chat view is still hidden —
|
||||
|
||||
@@ -214,7 +214,14 @@ def test_app_header_stuck_at_top_on_sources(
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url) # → /sources.html (the table is admin-only)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS)
|
||||
# Phase 97: the catalog is the drill-down tree — the top level
|
||||
# lists the two sources (the KB total rides the stat cards; the
|
||||
# flat 55-row tbody no longer exists). The LONG page the story
|
||||
# needs is a file level: drill into `gen` (41 direct docs).
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS))
|
||||
page.click('#folders-tbody a.folder-link:text-is("gen")')
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS - 13)
|
||||
|
||||
# The precondition the story needs: the page must actually scroll —
|
||||
# fail loudly if the table ever stops being long enough.
|
||||
|
||||
@@ -222,6 +222,10 @@ def test_modal_shows_panel_and_full_page_agrees(
|
||||
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||
|
||||
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
|
||||
# Phase 97: the catalog is the drill-down tree — the row lives at
|
||||
# the quadlet level (the drill is the only change).
|
||||
for name in (SOURCE, "quadlet"):
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
row = page.locator("#docs-tbody tr", has_text=YAML_PATH)
|
||||
expect(row).to_have_count(1)
|
||||
before = len(page.context.pages)
|
||||
@@ -301,6 +305,11 @@ def test_markdown_doc_has_no_summary_panel(
|
||||
# Modal: same story — no panel, .doc-md is the sole content child.
|
||||
# (the session is already signed in — the form login above)
|
||||
page.goto(f"{app_url}/sources.html")
|
||||
# Phase 97: the re-mount lands on the tree's top level — drill to
|
||||
# the notes level where the row lives (the drill is the only
|
||||
# change).
|
||||
for name in (SOURCE, "notes"):
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
row = page.locator("#docs-tbody tr", has_text=MD_PATH)
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
|
||||
@@ -185,6 +185,29 @@ def _overview_row() -> KbOverview | None:
|
||||
return db.get(KbOverview, 1)
|
||||
|
||||
|
||||
# The git fixture repo's directory name — the source name the import
|
||||
# records for it (the repo_name basename rule, phase 28).
|
||||
GIT_SOURCE_NAME = "homelab-notes"
|
||||
|
||||
|
||||
def _go_top(page: Page) -> None:
|
||||
"""Back to the top level (no-op when already there — the
|
||||
breadcrumb is hidden at the top)."""
|
||||
if not page.locator("#kb-crumb").is_hidden():
|
||||
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
||||
|
||||
|
||||
def _expect_fixture_row(page: Page) -> None:
|
||||
"""The REAL clone was imported — the fixture path is listed. Phase
|
||||
97: the catalog is the drill-down tree, so drill source → folder
|
||||
``notes`` first (the flat all-docs row is the same row, one level
|
||||
deeper)."""
|
||||
_go_top(page)
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{GIT_SOURCE_NAME}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("notes")')
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
|
||||
|
||||
def _wait_sync_done(page: Page, app_url: str, timeout_s: float = 60.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state — exactly what the UI's 2 s poll loop
|
||||
@@ -244,8 +267,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
expect(page.locator("#sync-result")).to_have_text("1 added")
|
||||
|
||||
# The REAL clone was imported: the fixture path is in the Sources
|
||||
# table (the sentinel lives inside it).
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
# table (the sentinel lives inside it) — via the drill-down tree
|
||||
# (phase 97).
|
||||
_expect_fixture_row(page)
|
||||
|
||||
# Phase-31 regeneration ran (the import changed the KB): the single
|
||||
# kb_overview row is fresh and non-empty (DB check — truncated
|
||||
@@ -264,8 +288,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
||||
# unchanged count instead of an empty live region.
|
||||
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
|
||||
# The doc survived the prune re-import (its file is still in the repo).
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
# The doc survived the prune re-import (its file is still in the
|
||||
# repo) — via the drill-down tree (phase 97).
|
||||
_expect_fixture_row(page)
|
||||
|
||||
|
||||
# --- 3. Concurrency: one sync at a time ------------------------------------
|
||||
|
||||
@@ -267,6 +267,29 @@ def _overview_row() -> KbOverview | None:
|
||||
return db.get(KbOverview, 1)
|
||||
|
||||
|
||||
# The git fixture repo's directory name — the source name the import
|
||||
# records for it (the repo_name basename rule, phase 28).
|
||||
GIT_SOURCE_NAME = "homelab-notes"
|
||||
|
||||
|
||||
def _go_top(page: Page) -> None:
|
||||
"""Back to the top level (no-op when already there — the
|
||||
breadcrumb is hidden at the top)."""
|
||||
if not page.locator("#kb-crumb").is_hidden():
|
||||
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
||||
|
||||
|
||||
def _expect_fixture_row(page: Page) -> None:
|
||||
"""The REAL clone was imported — the fixture path is listed. Phase
|
||||
97: the catalog is the drill-down tree, so drill source → folder
|
||||
``notes`` first (the flat all-docs row is the same row, one level
|
||||
deeper)."""
|
||||
_go_top(page)
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{GIT_SOURCE_NAME}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("notes")')
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
|
||||
|
||||
def _dismiss_reattached_modal(page: Page) -> None:
|
||||
"""Close the modal the page load RE-ATTACHED to (phase 32/41
|
||||
behavior): the dead app's status is still ``failed`` from an
|
||||
@@ -433,8 +456,9 @@ def test_healthy_sync_still_succeeds(
|
||||
expect(page.locator("#sync-result")).to_have_text("1 added")
|
||||
|
||||
# The REAL clone was imported: the fixture path is in the Sources
|
||||
# table (the sentinel lives inside it).
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
# table (the sentinel lives inside it) — via the drill-down tree
|
||||
# (phase 97).
|
||||
_expect_fixture_row(page)
|
||||
|
||||
# Phase-31 regeneration ran (the import changed the KB): the single
|
||||
# kb_overview row is fresh and non-empty (DB check — truncated
|
||||
@@ -451,7 +475,9 @@ def test_healthy_sync_still_succeeds(
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
# The doc survived the prune re-import — via the drill-down tree
|
||||
# (phase 97).
|
||||
_expect_fixture_row(page)
|
||||
|
||||
# And no modal ever opened (no failure on the healthy app).
|
||||
expect(page.locator(".sync-modal")).to_have_count(0)
|
||||
|
||||
@@ -568,7 +568,12 @@ def test_upload_toast_then_navigate_away(
|
||||
# page).
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
|
||||
# Phase 90 A1: the upload indexed NOTHING — the catalog is empty…
|
||||
# Phase 90 A1: the upload indexed NOTHING — the catalog shows the
|
||||
# registered source with 0 documents (phase 97: the source row IS
|
||||
# the load-settled signal; the file table stays hidden/empty)…
|
||||
row = page.locator("#folders-tbody tr", has_text=UPLOAD_NAME)
|
||||
expect(row).to_have_count(1, timeout=30_000)
|
||||
expect(row.locator("td:nth-child(2)")).to_have_text("0")
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
# …and the sync button settles idle with no error UI (the
|
||||
# sub-second upload run is over by the time this page's 2 s poll
|
||||
@@ -623,6 +628,11 @@ def test_upload_toast_then_navigate_away(
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
|
||||
# Phase 97: the catalog is the drill-down tree — drill source →
|
||||
# folder `docs` before the per-level row asserts (the flat total
|
||||
# no longer exists in one tbody).
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{UPLOAD_NAME}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("docs")')
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr", has_text="docs/00.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text="docs/24.md")).to_have_count(1)
|
||||
@@ -738,7 +748,17 @@ def test_sync_live_file_label(
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
# The button is retry-ready at boot: idle ("Sync sources") — or the
|
||||
# RE-ATTACHED terminal from a prior test's sync (the module app is
|
||||
# shared, its in-memory sync state survives the test boundary, and
|
||||
# the phase-32 boot re-attach renders the last result — "Synced
|
||||
# HH:MM" after test 1's successful run). Either way the button is
|
||||
# enabled and a click starts THIS test's run (the assertions below
|
||||
# are the run's own).
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
|
||||
recorder = _TickRecorder(app_url, "/api/sync/status")
|
||||
recorder.start()
|
||||
@@ -778,6 +798,11 @@ def test_sync_live_file_label(
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
|
||||
# Phase 97: the catalog is the drill-down tree — drill source →
|
||||
# folder `notes` before the per-level row asserts (the flat total
|
||||
# no longer exists in one tbody).
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{SYNC_SOURCE_DIR}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("notes")')
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr", has_text="notes/00.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text=SYNC_SOURCE_DIR)).to_have_count(N_FILES)
|
||||
|
||||
@@ -122,7 +122,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings
|
||||
from app.core.theming import COLOR_FIELDS
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document, SavedChat, UiSettings
|
||||
from app.models import Chunk, Document, GitSource, SavedChat, UiSettings
|
||||
from app.rag.sources_meta import bump_sources_version
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
@@ -823,7 +823,22 @@ def _seed_tool_docs(db: Session) -> None:
|
||||
"""The two-document pair (see the constants above): the retrievable
|
||||
grounding document (one chunk carrying the mock's own bag-of-words
|
||||
embedding) + the catalog-only read target (no chunks — retrieval
|
||||
never puts it in context, so the agent's read tool accepts it)."""
|
||||
never puts it in context, so the agent's read tool accepts it).
|
||||
|
||||
Phase 94: the drill-down ``ls`` top level reads the registry — the
|
||||
seed registers BOTH sources (TRUNCATEd in
|
||||
``_reset_kb_with_tool_docs``), ``Checklist`` FIRST: registry order
|
||||
is ``(added_at, id)``, so the mock's drill (first source of the
|
||||
listing) — and therefore the read — lands on ``read-me.md``
|
||||
deterministically, independent of the shared dev DB's leftover
|
||||
registry rows."""
|
||||
# COMMIT between the inserts (not flush): ``added_at`` is
|
||||
# ``server_default now()`` — the transaction timestamp — and the
|
||||
# tie-break is the random uuid ``id``, so one-transaction rows order
|
||||
# nondeterministically.
|
||||
db.add(GitSource(url=READ_SOURCE, kind="local"))
|
||||
db.commit()
|
||||
db.add(GitSource(url=TOOL_DOC_SOURCE, kind="local"))
|
||||
db.add(
|
||||
Document(
|
||||
source=TOOL_DOC_SOURCE,
|
||||
@@ -866,11 +881,18 @@ def _seed_tool_docs(db: Session) -> None:
|
||||
def _reset_kb_with_tool_docs() -> None:
|
||||
"""Truncate the KB tables (the house reset) and seed the pair — a
|
||||
direct DB seed, so the turn is genuinely grounded and the mock's
|
||||
single-read flow runs to completion: ls → read on the first catalog
|
||||
line (the catalog-only document) → the quoted answer."""
|
||||
single-read flow runs to completion: ls → drill ls (phase 94 — the
|
||||
top level lists sources only) → read on the first file line (the
|
||||
catalog-only document) → the quoted answer. Phase 94: ``git_sources``
|
||||
joins the TRUNCATE — the top-level listing reads the registry, so
|
||||
the leftover rows of other suites would change what the drill
|
||||
targets."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_seed_tool_docs(db)
|
||||
@@ -904,20 +926,25 @@ def test_tool_call_lines_gray(page: Page, app_url: str, db_ready: None) -> None:
|
||||
|
||||
# Screenshot 1: the yellow "Listing documents" / "Reading" lines —
|
||||
# now gray accent-ink text with the gray accent-line left border,
|
||||
# both lines' text intact.
|
||||
# the lines' text intact. Phase 94: the drill-down ls adds a THIRD
|
||||
# line between them — the drill ls scoped to the first source of
|
||||
# the top level (registry order: Checklist — the read target's
|
||||
# source).
|
||||
lines = page.locator(".msg.brain .tool-call")
|
||||
expect(lines).to_have_count(2)
|
||||
expect(lines).to_have_count(3)
|
||||
expect(lines.nth(0)).to_contain_text("Listing documents")
|
||||
expect(lines.nth(1)).to_contain_text("Reading")
|
||||
expect(lines.nth(1)).to_contain_text(READ_SP)
|
||||
expect(lines.nth(1)).to_contain_text("Listing documents in")
|
||||
expect(lines.nth(1)).to_contain_text(READ_SOURCE)
|
||||
expect(lines.nth(2)).to_contain_text("Reading")
|
||||
expect(lines.nth(2)).to_contain_text(READ_SP)
|
||||
_assert_gray(lines.nth(0), "color", GRAY["accent_ink"], label="ls line text")
|
||||
_assert_gray(
|
||||
lines.nth(0), "borderLeftColor", GRAY["accent_line"], label="ls line border"
|
||||
)
|
||||
_assert_gray(lines.nth(1), "color", GRAY["accent_ink"], label="read line text")
|
||||
_assert_gray(lines.nth(2), "color", GRAY["accent_ink"], label="read line text")
|
||||
# The path chip on the Reading line: gray brand-soft background +
|
||||
# gray ink (the screenshot's code chip — still gray under the ramp).
|
||||
code = lines.nth(1).locator("code")
|
||||
code = lines.nth(2).locator("code")
|
||||
_assert_gray(code, "backgroundColor", GRAY["brand_soft"], label="read chip bg")
|
||||
_assert_gray(code, "color", GRAY["ink"], label="read chip text")
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ Contract under test:
|
||||
control) and its folder exists on the host under ``BOR_UPLOAD_DIR``
|
||||
with all three files — but **zero documents are indexed**:
|
||||
``GET /api/docs`` is empty and the RAG catalog (``/sources.html``)
|
||||
settles on its empty state;
|
||||
settles on the source's 0-document row (phase 97: ``#sources-empty``
|
||||
is the zero-SOURCES state only — a registered 0-document source
|
||||
renders its ``0`` count instead);
|
||||
* **test 2 — ignore list, then Sync scans**: fresh state → upload →
|
||||
success line → the row's phase-89 "Ignore paths" editor: type
|
||||
``notes``, Save → the row shows the "1 ignored" count tag (the A5
|
||||
@@ -465,12 +467,17 @@ def test_upload_does_not_scan(
|
||||
assert _folder_files(folder) == {"alpha.md", "beta.md", "notes/skipme.md"}, (
|
||||
f"unexpected unpacked files: {_folder_files(folder)}"
|
||||
)
|
||||
# …and so is the RAG catalog: it settles on its empty state (the
|
||||
# visible #sources-empty is the deterministic "the load finished
|
||||
# with zero documents" signal — a count-0 check alone would race
|
||||
# the boot loadDocs fetch).
|
||||
# …and so is the RAG catalog: the registered source renders its
|
||||
# 0-document row (phase 97: the "zero documents" signal moved from
|
||||
# #sources-empty — the zero-SOURCES state only — to the source
|
||||
# row's own `0` count, which is ALSO the deterministic load-settled
|
||||
# signal; a count-0 check alone would race the boot loadTree
|
||||
# fetch).
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sources-empty")).to_be_visible(timeout=30_000)
|
||||
row = page.locator("#folders-tbody tr", has_text=SOURCE_NAME)
|
||||
expect(row).to_have_count(1, timeout=30_000)
|
||||
expect(row.locator("td:nth-child(2)")).to_have_text("0")
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
expect(page.locator("#stat-docs")).to_have_text("0")
|
||||
|
||||
@@ -533,10 +540,13 @@ def test_ignore_list_then_sync_scans(
|
||||
("local", str(upload_dir / SOURCE_NAME), [IGNORE_ENTRY])
|
||||
]
|
||||
|
||||
# The RAG page — the catalog is still empty before the scan…
|
||||
# The RAG page — the catalog is still empty before the scan (the
|
||||
# source row reads 0 — phase 97's load-settled zero signal)…
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sources-empty")).to_be_visible(timeout=30_000)
|
||||
row = page.locator("#folders-tbody tr", has_text=SOURCE_NAME)
|
||||
expect(row).to_have_count(1, timeout=30_000)
|
||||
expect(row.locator("td:nth-child(2)")).to_have_text("0")
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
@@ -566,7 +576,12 @@ def test_ignore_list_then_sync_scans(
|
||||
assert status["files_done"] == 2 and status["files_total"] == 2
|
||||
|
||||
# The catalog: exactly the two non-ignored docs, for the source —
|
||||
# and the ignored one is NOT there.
|
||||
# and the ignored one is NOT there. Phase 97: files are seen per
|
||||
# source — drill into the source row first (the two docs sit at
|
||||
# its root; the ignored notes/ folder is not even listed — the
|
||||
# existence rule runs over INDEXED paths).
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")')
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden() # no subfolders
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(2, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr", has_text=SOURCE_NAME)).to_have_count(2)
|
||||
expect(page.locator("#docs-tbody tr", has_text="alpha.md")).to_have_count(1)
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
"""Integration tests: GET /api/docs — empty shape + populated shape.
|
||||
"""Integration tests: GET /api/docs — empty shape + populated shape —
|
||||
and GET /api/docs/tree (phase 97, task 02 — the full recursive KB tree
|
||||
the RAG view renders: shape, ordering, counts, summaries, the 403 gate,
|
||||
the superset rule, and the stat-walk equivalence with ``GET /api/docs``).
|
||||
|
||||
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import itertools
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import text
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from app.models import Chunk, Document
|
||||
import app.api.docs as docs_api
|
||||
from app.config import Settings
|
||||
from app.core import tokens as token_service
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document, FolderSummary, GitSource
|
||||
from app.rag import git_sources as rag_git_sources
|
||||
|
||||
_TREE_TABLES = "chunks, documents, folder_summaries, git_sources"
|
||||
|
||||
|
||||
def _truncate_tree_tables(db) -> None:
|
||||
db.execute(text(f"TRUNCATE {_TREE_TABLES}"))
|
||||
db.commit()
|
||||
|
||||
|
||||
_counter = itertools.count()
|
||||
|
||||
|
||||
def _seed_doc(db, source: str, path: str, title: str, n_chunks: int, indexed_at: datetime) -> None:
|
||||
"""One indexed document with *n_chunks* content chunks (unique hash)."""
|
||||
doc = Document(
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{source}/{path}",
|
||||
title=title,
|
||||
content=f"# {title}\n\nBody.",
|
||||
content_hash=f"{next(_counter):064d}",
|
||||
indexed_at=indexed_at,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
|
||||
for i in range(n_chunks)
|
||||
)
|
||||
|
||||
|
||||
def _tree_file_nodes(sources) -> list[dict]:
|
||||
"""Every file node of a tree response, walked recursively."""
|
||||
files: list[dict] = []
|
||||
for source in sources:
|
||||
for child in source["children"]:
|
||||
if child["kind"] == "file":
|
||||
files.append(child)
|
||||
else:
|
||||
files.extend(_tree_file_nodes([child]))
|
||||
return files
|
||||
|
||||
|
||||
def test_docs_empty_shape(admin_client, db) -> None:
|
||||
@@ -77,3 +130,521 @@ def test_docs_response_matches_schema_shape(admin_client, db) -> None:
|
||||
for d in body["documents"]:
|
||||
assert set(d) == {"id", "source", "path", "title", "chunks", "indexed_at"}
|
||||
assert isinstance(d["chunks"], int) and d["chunks"] >= 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# GET /api/docs/tree (phase 97, task 02).
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_docs_tree_populated_shape_order_counts_summaries(admin_client, db) -> None:
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
# Registry order (added_at) is Homelab → Deployments — deliberately
|
||||
# NOT alphabetical (the registry order leads, the phase-97 rule).
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
db.add(
|
||||
GitSource(
|
||||
url="https://github.com/reese/Deployments.git",
|
||||
kind="git",
|
||||
added_at=base + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
_seed_doc(db, "Homelab", "k8s/talos.md", "Talos", 3, base)
|
||||
_seed_doc(db, "Homelab", "k8s/cluster.md", "Cluster", 2, base)
|
||||
_seed_doc(db, "Homelab", "k8s/helm/charts.md", "Charts", 1, base)
|
||||
_seed_doc(db, "Homelab", "root-note.md", "Root note", 4, base)
|
||||
_seed_doc(db, "Deployments", "deploy-a.md", "Deploy A", 1, base)
|
||||
_seed_doc(db, "Deployments", "deploy-b.md", "Deploy B", 0, base)
|
||||
db.add_all(
|
||||
[
|
||||
FolderSummary(source="Homelab", folder_path="", summary="Homelab docs."),
|
||||
FolderSummary(source="Homelab", folder_path="k8s", summary="K8s stuff."),
|
||||
# A stored row for a folder with NO indexed descendants —
|
||||
# e.g. a manual row surviving the prune below the 2-doc
|
||||
# minimum (phase 97, task 01): the tree's folders come from
|
||||
# INDEXED paths (the existence rule), not from summary rows.
|
||||
FolderSummary(source="Deployments", folder_path="orphan", summary="Ghost."),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"sources"}
|
||||
sources = body["sources"]
|
||||
assert [s["name"] for s in sources] == ["Homelab", "Deployments"]
|
||||
|
||||
homelab, deployments = sources
|
||||
assert set(homelab) == {"name", "documents", "summary", "children"}
|
||||
assert homelab["documents"] == 4 # the whole recursive count
|
||||
assert homelab["summary"] == "Homelab docs." # the (source, "") row
|
||||
assert deployments["summary"] is None # no stored root row
|
||||
assert deployments["documents"] == 2
|
||||
|
||||
# Homelab: subfolder first (path order among folders), then the
|
||||
# root files; the nested folder k8s/helm recurses one level deeper.
|
||||
k8s, root_note = homelab["children"]
|
||||
assert k8s["kind"] == "folder"
|
||||
assert k8s["path"] == "k8s"
|
||||
assert k8s["documents"] == 3 # talos + cluster + charts (subtree)
|
||||
assert k8s["summary"] == "K8s stuff."
|
||||
helm, cluster, talos = k8s["children"]
|
||||
assert (helm["kind"], helm["path"], helm["documents"], helm["summary"]) == (
|
||||
"folder",
|
||||
"k8s/helm",
|
||||
1,
|
||||
None,
|
||||
)
|
||||
assert [
|
||||
(c["kind"], c["path"], c["title"], c["chunks"])
|
||||
for c in (cluster, talos)
|
||||
] == [("file", "k8s/cluster.md", "Cluster", 2), ("file", "k8s/talos.md", "Talos", 3)]
|
||||
chart = helm["children"][0]
|
||||
assert (chart["kind"], chart["path"], chart["title"], chart["chunks"]) == (
|
||||
"file",
|
||||
"k8s/helm/charts.md",
|
||||
"Charts",
|
||||
1,
|
||||
)
|
||||
datetime.fromisoformat(chart["indexed_at"]) # valid ISO-8601
|
||||
assert (root_note["kind"], root_note["path"], root_note["title"], root_note["chunks"]) == (
|
||||
"file",
|
||||
"root-note.md",
|
||||
"Root note",
|
||||
4,
|
||||
)
|
||||
|
||||
# Deployments: flat — direct files in catalog order, and the
|
||||
# ``orphan`` summary row does NOT create a folder node.
|
||||
assert [(c["kind"], c["path"]) for c in deployments["children"]] == [
|
||||
("file", "deploy-a.md"),
|
||||
("file", "deploy-b.md"),
|
||||
]
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_docs_tree_403_anonymous(client, db) -> None:
|
||||
"""Admin-only, like ``GET /api/docs``: anonymous → 403
|
||||
``admin only`` (the RAG view's anonymous gate never fetches the
|
||||
tree)."""
|
||||
_truncate_tree_tables(db)
|
||||
r = client.get("/api/docs/tree")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_docs_tree_empty_registry_and_catalog(
|
||||
admin_client, db, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Nothing registered, nothing indexed → ``{"sources": []}``.
|
||||
|
||||
"Empty registry" means the ``git_sources`` table AND the
|
||||
``BOR_GIT_SOURCES`` env fallback are both empty — the dev ``.env``
|
||||
names a source, so the resolver's ``get_settings`` is patched with
|
||||
a fresh ``Settings(_env_file=None, git_sources="")`` (the
|
||||
``test_sync_api`` / ``test_git_sources_api`` pattern: the dev
|
||||
``.env`` never leaks in)."""
|
||||
monkeypatch.setattr(
|
||||
rag_git_sources,
|
||||
"get_settings",
|
||||
lambda: Settings(_env_file=None, git_sources=""), # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
_truncate_tree_tables(db)
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"sources": []}
|
||||
|
||||
|
||||
def test_docs_tree_indexed_only_source_after_registered(admin_client, db) -> None:
|
||||
"""The superset rule: a registered 0-document source still lists
|
||||
(first — the registry leads), and indexed-only sources (documents
|
||||
whose source is not in ``git_sources`` — ad-hoc imports, removed
|
||||
but not-yet-pruned sources) trail in alphabetical order."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Alpha.git", kind="git", added_at=base))
|
||||
_seed_doc(db, "Zeta", "z1.md", "Z1", 1, base)
|
||||
_seed_doc(db, "Midx", "m1.md", "M1", 2, base)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
sources = r.json()["sources"]
|
||||
assert [s["name"] for s in sources] == ["Alpha", "Midx", "Zeta"]
|
||||
alpha, midx, zeta = sources
|
||||
assert (alpha["documents"], alpha["children"], alpha["summary"]) == (0, [], None)
|
||||
assert midx["documents"] == 1
|
||||
assert zeta["documents"] == 1
|
||||
assert [c["path"] for c in midx["children"]] == ["m1.md"]
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# PATCH /api/folders/summary (phase 97, task 03) — the admin
|
||||
# folder-description editor: update / create / source-root / clear /
|
||||
# double-clear, the 404s, the 403s, and the round-trip through
|
||||
# ``GET /api/docs/tree`` (task 02).
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
#: A deliberately old stamp: a save must ADVANCE ``updated_at`` past it.
|
||||
OLDER_STAMP = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def _seed_folder_pair(db, source: str, folder: str, base: datetime) -> None:
|
||||
"""Two documents under ``source/folder`` — the ≥ 2-document
|
||||
minimum a folder must hold to be summarizable."""
|
||||
_seed_doc(db, source, f"{folder}/one.md", f"{folder} one", 1, base)
|
||||
_seed_doc(db, source, f"{folder}/two.md", f"{folder} two", 2, base)
|
||||
|
||||
|
||||
def _get_folder_row(db, source: str, folder: str) -> FolderSummary | None:
|
||||
return db.scalar(
|
||||
select(FolderSummary).where(
|
||||
FolderSummary.source == source, FolderSummary.folder_path == folder
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_folder_summary_update_ai_row_flips_manual_and_advances_stamp(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""Update an AI-written row: the text is replaced, ``manually_edited``
|
||||
flips to true, ``updated_at`` advances past the seeded stamp — and a
|
||||
second save with different text updates IN PLACE (same PK, one
|
||||
row). The round-trip: ``GET /api/docs/tree`` shows the new text on
|
||||
the folder node."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_folder_pair(db, "Homelab", "k8s", base)
|
||||
db.add(
|
||||
FolderSummary(
|
||||
source="Homelab",
|
||||
folder_path="k8s",
|
||||
summary="AI text.",
|
||||
manually_edited=False,
|
||||
updated_at=OLDER_STAMP,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "k8s", "summary": " Owner words. "},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert set(r.json()) == {"source", "folder_path", "summary"}
|
||||
assert r.json() == {"source": "Homelab", "folder_path": "k8s", "summary": "Owner words."}
|
||||
|
||||
db.expire_all()
|
||||
row = _get_folder_row(db, "Homelab", "k8s")
|
||||
assert row is not None
|
||||
assert row.summary == "Owner words." # stripped before storing
|
||||
assert row.manually_edited is True # a manual row from this save on
|
||||
assert row.updated_at > OLDER_STAMP # fresh stamp, not the seeded one
|
||||
|
||||
# The task-02 tree shows the owner's text on the folder node.
|
||||
tree = admin_client.get("/api/docs/tree").json()
|
||||
k8s = next(c for c in tree["sources"][0]["children"] if c["kind"] == "folder")
|
||||
assert (k8s["path"], k8s["summary"]) == ("k8s", "Owner words.")
|
||||
|
||||
# A second save updates in place — same row, no second PK.
|
||||
r2 = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "k8s", "summary": "Owner words v2."},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["summary"] == "Owner words v2."
|
||||
db.expire_all()
|
||||
assert db.scalar(select(func.count()).select_from(FolderSummary)) == 1 # one row
|
||||
row = _get_folder_row(db, "Homelab", "k8s")
|
||||
assert row is not None
|
||||
assert row.summary == "Owner words v2."
|
||||
assert row.manually_edited is True
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_create_where_no_row_exists(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""A manual description can be CREATED where no row exists — a
|
||||
< 2-document folder the generator never wrote (or its fail-soft
|
||||
miss): insert, not update, with ``manually_edited = true``."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_doc(db, "Homelab", "solo/only.md", "Only", 1, base) # 1-doc folder
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
assert _get_folder_row(db, "Homelab", "solo") is None # no AI row for < 2 docs
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "solo", "summary": "One-off scripts."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"source": "Homelab",
|
||||
"folder_path": "solo",
|
||||
"summary": "One-off scripts.",
|
||||
}
|
||||
db.expire_all()
|
||||
row = _get_folder_row(db, "Homelab", "solo")
|
||||
assert row is not None
|
||||
assert row.summary == "One-off scripts."
|
||||
assert row.manually_edited is True
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_root_save_on_registered_zero_doc_source(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""``folder_path = ""`` is valid for ANY allowed source — a registered
|
||||
0-document source (nothing indexed yet) still accepts a root
|
||||
description: the source check is registered-OR-indexed."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Empty.git", kind="git", added_at=base))
|
||||
db.commit()
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Empty", "folder_path": "", "summary": "Docs incoming."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"source": "Empty", "folder_path": "", "summary": "Docs incoming."}
|
||||
db.expire_all()
|
||||
row = _get_folder_row(db, "Empty", "")
|
||||
assert row is not None
|
||||
assert row.manually_edited is True
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_source_root_round_trips_through_tree(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""The source root (``folder_path: ""``) round-trips through the
|
||||
task-02 tree endpoint: save → the source node carries the text;
|
||||
clear → it is null again."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_doc(db, "Homelab", "a/b.md", "B", 1, base)
|
||||
_seed_doc(db, "Homelab", "c.md", "C", 1, base)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "", "summary": "All the homelab docs."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"source": "Homelab",
|
||||
"folder_path": "",
|
||||
"summary": "All the homelab docs.",
|
||||
}
|
||||
tree = admin_client.get("/api/docs/tree").json()
|
||||
assert tree["sources"][0]["summary"] == "All the homelab docs."
|
||||
db.expire_all()
|
||||
assert _get_folder_row(db, "Homelab", "") is not None
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "", "summary": " "},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"source": "Homelab", "folder_path": "", "summary": None}
|
||||
tree = admin_client.get("/api/docs/tree").json()
|
||||
assert tree["sources"][0]["summary"] is None
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_clear_deletes_row_and_double_clear_is_noop(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""Empty/whitespace clears: the row is deleted — AI-written OR
|
||||
manual, either way it is gone (the next KB-changing sync regenerates
|
||||
an AI row: the reset path) — and the response ``summary`` is null.
|
||||
A second clear with no row is a 200 no-op. The task-02 tree shows
|
||||
null on both folder nodes after the clears."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_folder_pair(db, "Homelab", "k8s", base)
|
||||
_seed_folder_pair(db, "Homelab", "manual", base)
|
||||
db.add(
|
||||
FolderSummary(
|
||||
source="Homelab",
|
||||
folder_path="k8s",
|
||||
summary="AI text.",
|
||||
manually_edited=False,
|
||||
updated_at=OLDER_STAMP,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
FolderSummary(
|
||||
source="Homelab",
|
||||
folder_path="manual",
|
||||
summary="Owner text.",
|
||||
manually_edited=True,
|
||||
updated_at=OLDER_STAMP,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
for folder, blank in (("k8s", " "), ("manual", "")): # whitespace, then empty
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": folder, "summary": blank},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"source": "Homelab", "folder_path": folder, "summary": None}
|
||||
db.expire_all()
|
||||
assert db.scalar(select(func.count()).select_from(FolderSummary)) == 0 # both rows gone
|
||||
|
||||
# A second clear (no row) is a 200 no-op.
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "k8s", "summary": ""},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"source": "Homelab", "folder_path": "k8s", "summary": None}
|
||||
db.expire_all()
|
||||
assert db.scalar(select(func.count()).select_from(FolderSummary)) == 0 # still nothing
|
||||
|
||||
# The task-02 tree shows null on both folder nodes after the clears.
|
||||
tree = admin_client.get("/api/docs/tree").json()
|
||||
nodes = {
|
||||
c["path"]: c for c in tree["sources"][0]["children"] if c["kind"] == "folder"
|
||||
}
|
||||
assert nodes["k8s"]["summary"] is None
|
||||
assert nodes["manual"]["summary"] is None
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_404_unknown_source_folder_and_traversal(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""404s, in check order: an unknown source (neither registered nor
|
||||
indexed) wins over the folder check — including for a folder that
|
||||
EXISTS under another source; an unknown folder, a traversal folder
|
||||
path, and a file merely sharing a folder's name (no indexed
|
||||
descendant) are all ``folder not found``. Nothing is written."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_folder_pair(db, "Homelab", "real", base)
|
||||
_seed_doc(db, "Homelab", "note", "A file named like a folder", 1, base)
|
||||
db.commit()
|
||||
|
||||
cases = (
|
||||
# (source, folder_path, detail)
|
||||
("Ghost", "", "source not found"), # unknown source, root
|
||||
("Ghost", "real", "source not found"), # folder exists ELSEWHERE — source wins
|
||||
("Homelab", "nope", "folder not found"), # unknown folder
|
||||
("Homelab", "../../etc", "folder not found"), # traversal: no prefix match
|
||||
("Homelab", "note", "folder not found"), # file named like a folder, no descendants
|
||||
("Homelab", "real/", "folder not found"), # trailing slash: strict prefix rule
|
||||
)
|
||||
for source, folder, detail in cases:
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": source, "folder_path": folder, "summary": "whatever"},
|
||||
)
|
||||
assert r.status_code == 404, (source, folder, r.status_code)
|
||||
assert r.json() == {"detail": detail}, (source, folder)
|
||||
|
||||
db.expire_all()
|
||||
assert db.scalar(select(func.count()).select_from(FolderSummary)) == 0 # nothing written
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_403_anonymous_and_token_user(
|
||||
client: TestClient, db
|
||||
) -> None:
|
||||
"""The ``require_admin`` gate: an anonymous caller AND a live
|
||||
access-token user who is not the admin (phase 79 token users exist)
|
||||
both get 403 ``admin only`` — the endpoint gate is the API-level
|
||||
defense in depth, not the RAG view's render gate."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_folder_pair(db, "Homelab", "k8s", base)
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
body = {"source": "Homelab", "folder_path": "k8s", "summary": "x"}
|
||||
try:
|
||||
# Anonymous (the shared ``client`` is unsigned in this module).
|
||||
r = client.patch("/api/folders/summary", json=body)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
# A live access-token user who is not the admin (phase 79):
|
||||
# signed in via the public token login, the row is active.
|
||||
_row, plaintext = token_service.create_token(db, "pin-holder")
|
||||
db.commit()
|
||||
holder = TestClient(fastapi_app)
|
||||
s = holder.post("/api/token-auth", json={"token": plaintext})
|
||||
assert s.status_code == 204, s.text
|
||||
r = holder.patch("/api/folders/summary", json=body)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
finally:
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_folder_summary_patch_never_constructs_an_llm_client() -> None:
|
||||
"""Source pin (the house pattern): a folder description is NEVER
|
||||
embedded — no chunk, no retrieval role beyond the ``ls`` line — so
|
||||
the PATCH handler must never touch the LLM client (the deliberate
|
||||
contrast with the phase-57 document-summary re-embed)."""
|
||||
src = inspect.getsource(docs_api.update_folder_summary)
|
||||
assert "LLMClient" not in src
|
||||
|
||||
|
||||
def test_docs_tree_stat_walk_equivalence_with_flat_list(admin_client, db) -> None:
|
||||
"""The RAG view computes its KB-wide stat cards by walking the
|
||||
in-memory tree — the walk must yield EXACTLY what
|
||||
``GET /api/docs`` reports for the same data (the stat-card values
|
||||
are unchanged by the redesign)."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_doc(db, "Homelab", "a/b/c.md", "C", 3, base)
|
||||
_seed_doc(db, "Homelab", "a/d.md", "D", 2, base)
|
||||
_seed_doc(db, "Homelab", "top.md", "Top", 5, base)
|
||||
_seed_doc(db, "Homelab", "solo.md", "Solo", 0, base)
|
||||
db.commit()
|
||||
|
||||
tree = admin_client.get("/api/docs/tree").json()
|
||||
flat = admin_client.get("/api/docs").json()
|
||||
|
||||
files = _tree_file_nodes(tree["sources"])
|
||||
assert len(files) == len(flat["documents"]) # document count
|
||||
assert sum(f["chunks"] for f in files) == sum(d["chunks"] for d in flat["documents"])
|
||||
# The tree's per-source counts match the flat list's per-source counts.
|
||||
for source in tree["sources"]:
|
||||
flat_count = sum(
|
||||
1 for d in flat["documents"] if d["source"] == source["name"]
|
||||
)
|
||||
assert source["documents"] == flat_count
|
||||
source_files = _tree_file_nodes([source])
|
||||
assert sum(f["chunks"] for f in source_files) == sum(
|
||||
d["chunks"] for d in flat["documents"] if d["source"] == source["name"]
|
||||
)
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Integration: migration 0018 (folder_summaries.manually_edited)
|
||||
schema contract (phase 97, task 01).
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0017.py`` (information_schema assertions on the state
|
||||
the migration must leave). The tests target the 0017 → 0018 step
|
||||
explicitly so later migrations cannot break them:
|
||||
|
||||
* upgrade 0017 → 0018 → the ``manually_edited`` column exists with the
|
||||
full contract — BOOLEAN NOT NULL, server default ``false`` — while
|
||||
the 0017 ``folder_summaries`` schema (PK, summary, updated_at)
|
||||
survives;
|
||||
* pre-0018 rows backfill ``false`` (an AI-written row stays an AI row)
|
||||
and a row written without the column takes the default;
|
||||
* a row written with ``manually_edited = true`` round-trips the flag;
|
||||
* downgrade to 0017 → the column is GONE (A13 — reversible) while the
|
||||
rows + their summaries survive;
|
||||
* upgrade back to 0018 → the column is back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
|
||||
# on ``folder_summaries``, which would deadlock the repair's
|
||||
# ``ALTER TABLE`` (0018) forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default, character_maximum_length)
|
||||
for one table column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _flag(db: Session, source: str, folder_path: str) -> Any:
|
||||
return db.execute(
|
||||
text(
|
||||
"SELECT manually_edited FROM folder_summaries"
|
||||
" WHERE source = :s AND folder_path = :f"
|
||||
),
|
||||
{"s": source, "f": folder_path},
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _clear_rows(db: Session) -> None:
|
||||
db.execute(text("DELETE FROM folder_summaries"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0018_adds_manually_edited(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0017 → 0018: the column exists with the full contract
|
||||
(BOOLEAN NOT NULL, server default ``false``), is ABSENT at 0017,
|
||||
pre-0018 rows backfill ``false`` (an AI row stays an AI row), a new
|
||||
row without the column takes the default, and an explicit ``true``
|
||||
round-trips — while the 0017 table contract survives."""
|
||||
command.downgrade(alembic, "0017") # start from the pre-0018 state
|
||||
assert _version(db) == "0017"
|
||||
assert _column(db, "folder_summaries", "manually_edited") is None, (
|
||||
"the flag must be absent at 0017"
|
||||
)
|
||||
|
||||
try:
|
||||
# A pre-0018 AI-written row — must backfill ``false``.
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO folder_summaries (source, folder_path, summary)"
|
||||
" VALUES ('OldSource', 'old/folder', 'pre-0018 summary')"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
command.upgrade(alembic, "0018")
|
||||
assert _version(db) == "0018", "alembic_version must be at 0018"
|
||||
|
||||
flag = _column(db, "folder_summaries", "manually_edited")
|
||||
assert flag is not None, "folder_summaries.manually_edited is missing"
|
||||
assert flag[0] == "boolean", "manually_edited must be BOOLEAN"
|
||||
assert flag[1] == "NO", "manually_edited must be NOT NULL"
|
||||
assert flag[2] is not None and "false" in str(flag[2]), (
|
||||
"manually_edited must carry the `false` server default"
|
||||
)
|
||||
|
||||
# The pre-0018 row backfilled ``false`` — an AI row stays an AI row.
|
||||
assert _flag(db, "OldSource", "old/folder") is False
|
||||
|
||||
# A row written without the column takes the server default.
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO folder_summaries (source, folder_path, summary)"
|
||||
" VALUES ('NewSource', '', 'root summary')"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert _flag(db, "NewSource", "") is False, (
|
||||
"an omitted flag takes the `false` server default"
|
||||
)
|
||||
|
||||
# The flag round-trips through an explicit ``true``.
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE folder_summaries SET manually_edited = true"
|
||||
" WHERE source = 'NewSource'"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert _flag(db, "NewSource", "") is True, (
|
||||
"manually_edited = true must round-trip"
|
||||
)
|
||||
|
||||
# The 0017 schema survives the additive upgrade.
|
||||
summary = _column(db, "folder_summaries", "summary")
|
||||
assert summary is not None and summary[0] == "text" and summary[1] == "NO", (
|
||||
"folder_summaries.summary (0017) must survive the upgrade"
|
||||
)
|
||||
folder = _column(db, "folder_summaries", "folder_path")
|
||||
assert folder is not None and folder[3] == 1000, (
|
||||
"folder_summaries.folder_path (0017) must survive the upgrade"
|
||||
)
|
||||
finally:
|
||||
_clear_rows(db)
|
||||
|
||||
|
||||
def test_downgrade_to_0017_drops_the_column(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade 0018 → 0017: the column is gone (A13 — fully
|
||||
reversible) while the rows + their summaries survive, and the rest
|
||||
of the schema (the 0017 table contract, ``documents``) is intact."""
|
||||
command.upgrade(alembic, "head")
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO folder_summaries"
|
||||
" (source, folder_path, summary, manually_edited)"
|
||||
" VALUES ('ManualSrc', '', 'owner text', true)"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
command.downgrade(alembic, "0017")
|
||||
assert _version(db) == "0017"
|
||||
assert _column(db, "folder_summaries", "manually_edited") is None, (
|
||||
"the flag must be dropped"
|
||||
)
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT source, folder_path, summary FROM folder_summaries"
|
||||
" WHERE source = 'ManualSrc'"
|
||||
)
|
||||
).fetchone()
|
||||
assert row is not None and row[2] == "owner text", (
|
||||
"the row and its summary must survive the column drop"
|
||||
)
|
||||
|
||||
summary = _column(db, "folder_summaries", "summary")
|
||||
assert summary is not None and summary[0] == "text", (
|
||||
"the 0017 table contract must survive the downgrade"
|
||||
)
|
||||
doc_path = _column(db, "documents", "path")
|
||||
assert doc_path is not None and doc_path[3] == 1000, (
|
||||
"documents.path must survive the downgrade"
|
||||
)
|
||||
finally:
|
||||
_clear_rows(db)
|
||||
# Repair: the fixture teardown re-upgrades to head.
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_flag(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0017, then upgrade back to 0018: the column is back
|
||||
with the full contract (BOOLEAN NOT NULL, the `false` default)."""
|
||||
command.downgrade(alembic, "0017")
|
||||
command.upgrade(alembic, "0018")
|
||||
assert _version(db) == "0018", "round-trip upgrade must land at 0018"
|
||||
|
||||
flag = _column(db, "folder_summaries", "manually_edited")
|
||||
assert flag is not None, "folder_summaries.manually_edited must be back"
|
||||
assert flag[0] == "boolean", "manually_edited must be BOOLEAN after the round-trip"
|
||||
assert flag[1] == "NO", "manually_edited must be NOT NULL after the round-trip"
|
||||
assert flag[2] is not None and "false" in str(flag[2]), (
|
||||
"the `false` server default must survive the round-trip"
|
||||
)
|
||||
@@ -275,7 +275,8 @@ def test_changed_import_generates_folder_rows(
|
||||
assert _updated_at(db, "MyDocs", "a") is not None
|
||||
# The stats log line (PLAN §9 ample logging).
|
||||
assert any(
|
||||
"folder_summaries: generated=2 failed=0 pruned=0" in r.getMessage()
|
||||
"folder_summaries: generated=2 failed=0 pruned=0 kept_manual=0"
|
||||
in r.getMessage()
|
||||
for r in records
|
||||
)
|
||||
|
||||
@@ -470,6 +471,96 @@ def test_folder_lite_failure_keeps_previous_row_and_stays_green(
|
||||
assert root_stamp_after is not None and root_stamp_after > root_stamp_before
|
||||
|
||||
|
||||
def test_changed_import_never_overwrites_a_manual_row(
|
||||
db: Session,
|
||||
src: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Phase 97 (task 01): a manually-edited folder description survives
|
||||
a KB-changing sync — the generator SKIPS it (zero
|
||||
``FOLDER_SUMMARY_MODE`` calls for it), the summary-line token
|
||||
STAYS 3 fields (``folder_summaries=<generated>/<failed>/<pruned>``
|
||||
— ``kept_manual`` is a stat, not a token), the manual row's text,
|
||||
stamp, and flag are untouched, and the other folders regenerate
|
||||
(the ``kept_manual`` stat lands on the generator's log line)."""
|
||||
# First sync: full generation — root + a/ (b/ holds 1 doc: none).
|
||||
llm1 = FakeEmbedder()
|
||||
rc, out = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys)
|
||||
assert rc == 0
|
||||
assert out.rstrip().endswith(
|
||||
"overview=updated sources_version=1 folder_summaries=2/0/0"
|
||||
)
|
||||
assert set(_rows(db)) == {("MyDocs", ""), ("MyDocs", "a")}
|
||||
|
||||
# The owner edits the source-root description (task 03's PATCH is
|
||||
# the writer; task 01 pins the generator's behavior, so the row is
|
||||
# inserted directly — the ``test_import_docs_overview.py`` pattern).
|
||||
manual_text = "Owner's own words about MyDocs."
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE folder_summaries SET summary = :s, manually_edited = true"
|
||||
" WHERE source = 'MyDocs' AND folder_path = ''"
|
||||
),
|
||||
{"s": manual_text},
|
||||
)
|
||||
db.commit()
|
||||
root_stamp_before = _updated_at(db, "MyDocs", "")
|
||||
assert root_stamp_before is not None
|
||||
|
||||
records: list[logging.LogRecord] = []
|
||||
|
||||
class _Sink(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
records.append(record)
|
||||
|
||||
fs_logger = logging.getLogger("app.rag.folder_summaries")
|
||||
sink = _Sink()
|
||||
fs_logger.addHandler(sink)
|
||||
fs_logger.setLevel(logging.INFO)
|
||||
try:
|
||||
# A KB-changing re-sync (a new doc under a/) — the gate fires a
|
||||
# full regeneration ...
|
||||
(src / "a" / "three.md").write_text(
|
||||
"# A Three\nAnother folder document.\n", encoding="utf-8"
|
||||
)
|
||||
llm2 = FakeEmbedder()
|
||||
rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys)
|
||||
finally:
|
||||
fs_logger.removeHandler(sink)
|
||||
|
||||
assert rc == 0
|
||||
assert "added=1" in out
|
||||
# The token STAYS 3 fields — kept_manual is a stat, not a token.
|
||||
assert out.rstrip().endswith(
|
||||
"overview=updated sources_version=2 folder_summaries=1/0/0"
|
||||
)
|
||||
# Zero folder calls for the owner's folder — only a/ (now 3 docs).
|
||||
calls = _folder_calls(llm2)
|
||||
assert [c[1]["content"].splitlines()[0] for c in calls] == ["Folder: MyDocs/a"]
|
||||
# The owner's text, stamp, and flag are untouched ...
|
||||
rows_after = _rows(db)
|
||||
assert rows_after[("MyDocs", "")] == manual_text
|
||||
assert _updated_at(db, "MyDocs", "") == root_stamp_before, (
|
||||
"the manual row is never re-stamped"
|
||||
)
|
||||
flag = db.execute(
|
||||
text(
|
||||
"SELECT manually_edited FROM folder_summaries"
|
||||
" WHERE source = 'MyDocs' AND folder_path = ''"
|
||||
)
|
||||
).scalar_one()
|
||||
assert flag is True, "the generator never clears the flag"
|
||||
# ... while the other folder regenerates.
|
||||
assert rows_after[("MyDocs", "a")] is not None
|
||||
# The 4-field stats line carries the skip (PLAN §9 ample logging).
|
||||
assert any(
|
||||
"folder_summaries: generated=1 failed=0 pruned=0 kept_manual=1"
|
||||
in r.getMessage()
|
||||
for r in records
|
||||
)
|
||||
|
||||
|
||||
def test_limit_run_skips_folder_generation(
|
||||
db: Session,
|
||||
src: Path,
|
||||
|
||||
@@ -441,7 +441,7 @@ def test_generate_happy_path_upserts_every_candidate_folder(
|
||||
llm = _FakeLLM()
|
||||
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats == {"generated": 3, "failed": 0, "pruned": 0}
|
||||
assert stats == {"generated": 3, "failed": 0, "pruned": 0, "kept_manual": 0}
|
||||
assert llm.calls == 3, "one lite call per candidate folder (the solo folder: none)"
|
||||
|
||||
stored = _rows(db)
|
||||
@@ -472,7 +472,8 @@ def test_generate_happy_path_upserts_every_candidate_folder(
|
||||
assert "root.md — Root" not in a_prompt
|
||||
|
||||
assert (
|
||||
"folder_summaries: generated=3 failed=0 pruned=0" in caplog.text
|
||||
"folder_summaries: generated=3 failed=0 pruned=0 kept_manual=0"
|
||||
in caplog.text
|
||||
), "the stats line must be greppable (PLAN §9 ample logging)"
|
||||
|
||||
|
||||
@@ -488,7 +489,7 @@ def test_generate_per_folder_fail_soft_keeps_previous_and_lands_others(
|
||||
llm = _FakeLLM(fail_folders=("FSU/a/b",))
|
||||
with caplog.at_level(logging.ERROR, logger="app.rag.folder_summaries"):
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats == {"generated": 2, "failed": 1, "pruned": 0}
|
||||
assert stats == {"generated": 2, "failed": 1, "pruned": 0, "kept_manual": 0}
|
||||
assert llm.calls == 3 # the failing folder was attempted too
|
||||
|
||||
stored = _rows(db)
|
||||
@@ -547,7 +548,7 @@ def test_generate_skip_is_a_full_noop(db: Session, clean_tables) -> None:
|
||||
db.commit()
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
|
||||
assert llm.calls == 0
|
||||
assert _rows(db) == {("FSU", ""): "existing"}
|
||||
|
||||
@@ -560,7 +561,7 @@ def test_generate_empty_kb_prunes_every_row(db: Session, clean_tables) -> None:
|
||||
db.commit()
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 2}
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 2, "kept_manual": 0}
|
||||
assert llm.calls == 0
|
||||
assert _rows(db) == {}
|
||||
|
||||
@@ -613,6 +614,113 @@ def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None
|
||||
assert MIN_DOCS_PER_FOLDER == 2 # the ≥ 2 scope rule, pinned by name
|
||||
|
||||
|
||||
# ---------- manually_edited (phase 97, task 01) ----------
|
||||
|
||||
|
||||
def test_manual_row_survives_regeneration(
|
||||
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An owner-edited row is SKIPPED on regeneration (phase 97, task
|
||||
01): the fake LLM is never called for it (no ``lite`` burn on owner
|
||||
text — not even a prompt is built), its text AND ``updated_at``
|
||||
stay byte-identical, ``kept_manual`` counts it, the flag is never
|
||||
cleared, and the 4-field log line carries it (PLAN §9)."""
|
||||
_seed_catalogue(db)
|
||||
manual_text = "Owner's own words about a/."
|
||||
db.add(
|
||||
FolderSummary(
|
||||
source="FSU", folder_path="a", summary=manual_text,
|
||||
manually_edited=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
stamp_before = _updated_at(db, "FSU", "a")
|
||||
assert stamp_before is not None
|
||||
|
||||
llm = _FakeLLM()
|
||||
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 0, "kept_manual": 1}
|
||||
assert llm.calls == 2, "the manual folder burns zero lite calls"
|
||||
assert [user.splitlines()[0] for _s, user in llm.requests] == [
|
||||
"Folder: FSU",
|
||||
"Folder: FSU/a/b",
|
||||
], "no prompt is ever built for the owner's folder"
|
||||
|
||||
stored = _rows(db)
|
||||
assert stored[("FSU", "a")] == manual_text, "the owner's text survives"
|
||||
assert _updated_at(db, "FSU", "a") == stamp_before, ("never re-stamped")
|
||||
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY, (
|
||||
"the non-manual candidates still regenerate (the flag is the difference)"
|
||||
)
|
||||
row = db.get(FolderSummary, ("FSU", "a"))
|
||||
assert row is not None and row.manually_edited is True, (
|
||||
"the generator never clears the flag"
|
||||
)
|
||||
assert (
|
||||
"folder_summaries: generated=2 failed=0 pruned=0 kept_manual=1"
|
||||
in caplog.text
|
||||
), "the 4-field stats line must be greppable (PLAN §9 ample logging)"
|
||||
|
||||
|
||||
def test_manual_row_survives_the_prune(db: Session, clean_tables) -> None:
|
||||
"""A manual row is NEVER pruned (phase 97, task 01): two folders
|
||||
drop below 2 documents — the MANUAL one keeps its row (owner
|
||||
content persists until cleared — the clear deletes it, so the next
|
||||
KB-changing sync regenerates an AI description) while the
|
||||
NON-manual twin loses its now-stale row; the flag is the only
|
||||
difference. A vanished folder's manual row is kept too, and its
|
||||
non-manual twin is pruned."""
|
||||
_add_doc(db, "FSU", "a/one.md", "One")
|
||||
_add_doc(db, "FSU", "a/two.md", "Two")
|
||||
_add_doc(db, "FSU", "b/one.md", "B One")
|
||||
_add_doc(db, "FSU", "b/two.md", "B Two")
|
||||
manual_text = "Owner's words about a/."
|
||||
db.add(
|
||||
FolderSummary(
|
||||
source="FSU", folder_path="a", summary=manual_text,
|
||||
manually_edited=True,
|
||||
)
|
||||
)
|
||||
db.add(FolderSummary(source="FSU", folder_path="b", summary="ai words"))
|
||||
db.add(
|
||||
FolderSummary(
|
||||
source="FSU", folder_path="gone/manual", summary="owner kept",
|
||||
manually_edited=True,
|
||||
)
|
||||
)
|
||||
db.add(FolderSummary(source="FSU", folder_path="gone/ai", summary="stale ai"))
|
||||
db.commit()
|
||||
|
||||
# a/ and b/ each drop below the minimum (2 -> 1 recursive doc).
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM documents WHERE source = 'FSU'"
|
||||
" AND path IN ('a/two.md', 'b/two.md')"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm))
|
||||
|
||||
assert stats == {"generated": 1, "failed": 0, "pruned": 2, "kept_manual": 0}
|
||||
assert llm.calls == 1, "only the surviving candidate (the root) regenerates"
|
||||
stored = _rows(db)
|
||||
assert stored[("FSU", "a")] == manual_text, (
|
||||
"the manual row survives its folder dropping below the minimum"
|
||||
)
|
||||
assert ("FSU", "b") not in stored, (
|
||||
"the non-manual twin loses its stale row (the flag is the difference)"
|
||||
)
|
||||
assert stored[("FSU", "gone/manual")] == "owner kept", (
|
||||
"a vanished folder's manual row is kept — owner content until cleared"
|
||||
)
|
||||
assert ("FSU", "gone/ai") not in stored, ("the non-manual twin is pruned")
|
||||
assert stored[("FSU", "")] == REPLY # the root (2 docs) still regenerates
|
||||
|
||||
|
||||
# ---------- missing_folder_summaries (phase 96, task 02) ----------
|
||||
|
||||
|
||||
@@ -715,7 +823,7 @@ def test_only_missing_fills_exactly_the_missing_keys(
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 0}
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 0, "kept_manual": 0}
|
||||
assert llm.calls == 2, "one call per MISSING key — zero for present rows"
|
||||
assert [user.splitlines()[0] for _s, user in llm.requests] == [
|
||||
"Folder: FSU",
|
||||
@@ -738,7 +846,7 @@ def test_only_missing_no_gap_burns_zero_calls(db: Session, clean_tables) -> None
|
||||
stamps = {f: _updated_at(db, "FSU", f) for f in ("", "a", "a/b")}
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
|
||||
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
|
||||
assert llm.calls == 0, "zero-burn: no gap, no lite call"
|
||||
assert _rows(db) == before
|
||||
for folder, stamp in stamps.items():
|
||||
@@ -758,7 +866,7 @@ def test_only_missing_still_prunes_stale_rows(db: Session, clean_tables) -> None
|
||||
|
||||
llm = _FakeLLM()
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 1}
|
||||
assert stats == {"generated": 2, "failed": 0, "pruned": 1, "kept_manual": 0}
|
||||
assert llm.calls == 2
|
||||
|
||||
stored = _rows(db)
|
||||
@@ -781,7 +889,7 @@ def test_only_missing_fail_soft_keeps_prior_and_lands_others(
|
||||
db.commit()
|
||||
llm = _FakeLLM(fail_folders=("FSU/a/b",))
|
||||
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
|
||||
assert stats == {"generated": 1, "failed": 1, "pruned": 0}
|
||||
assert stats == {"generated": 1, "failed": 1, "pruned": 0, "kept_manual": 0}
|
||||
assert llm.calls == 2 # both missing folders were attempted
|
||||
|
||||
stored = _rows(db)
|
||||
|
||||
@@ -51,9 +51,11 @@ Phase 77 task 02 (the other data views join the refresh): RAG
|
||||
(``tuning.js``) each listen for ``bor:view-refresh`` on their root and
|
||||
re-run their existing load (armed only in the admin branch, after the
|
||||
whoami gate — the same gate guard as History). ``sources.js``'s
|
||||
``loadDocs`` clears the tbody's rows at the TOP (before the fetch —
|
||||
the History pattern), so a refresh from a populated list into an empty
|
||||
result leaves no ghost rows. The Chat view (``app.js``) does NOT
|
||||
catalog load is re-entrant: the phase-97 ``loadTree`` (one fetch of
|
||||
``GET /api/docs/tree``) renders through ``renderLevel``, which clears
|
||||
BOTH row containers at the top before filling them, so a refresh from
|
||||
a populated level into a sparser (or empty) one leaves no ghost rows. The Chat
|
||||
view (``app.js``) does NOT
|
||||
listen — the negative pin: the in-flight SSE stream and the local
|
||||
conversation must survive every switch (the phase-76 contract), so
|
||||
the exclusion is a contract, not an oversight.
|
||||
@@ -559,25 +561,34 @@ def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) ->
|
||||
|
||||
|
||||
def test_rag_view_refetches_on_reshow() -> None:
|
||||
"""Phase 77 task 02: the RAG (knowledge base) view re-fetches on a
|
||||
user-initiated re-show — sources.js listens and re-runs
|
||||
``loadDocs()``. ``loadDocs`` is now re-entrant: the tbody's rows
|
||||
are cleared at the TOP, before the fetch (the History pattern from
|
||||
task 01), so a refresh from a populated list into an empty result
|
||||
replaces the list instead of leaving ghost rows."""
|
||||
"""Phase 77 task 02 (+ phase 97 task 04): the RAG (knowledge base)
|
||||
view re-fetches on a user-initiated re-show — sources.js listens
|
||||
and re-runs ``loadTree()`` (the phase-97 catalog load: ONE fetch of
|
||||
``GET /api/docs/tree``). The load is race-tokened (phase 79) and
|
||||
the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers
|
||||
at the TOP before filling them (the History pattern from task 01,
|
||||
extended to the folders table) — so a refresh from a populated
|
||||
level into a sparser (or empty) result replaces the rows instead
|
||||
of leaving ghost rows."""
|
||||
js = _asset("sources.js")
|
||||
_pin_refresh_listener(
|
||||
js, "const admin = await fetchIsAdmin();", "() => loadDocs()", "sources.js"
|
||||
js, "const admin = await fetchIsAdmin();", "() => loadTree()", "sources.js"
|
||||
)
|
||||
load = js.find("async function loadDocs()")
|
||||
assert load != -1, "loadDocs must exist"
|
||||
load = js.find("async function loadTree()")
|
||||
assert load != -1, "loadTree must exist"
|
||||
body = js[load : js.find("\n }", load)]
|
||||
clear_i = body.find("tbody.replaceChildren()")
|
||||
fetch_i = body.find('fetch("/api/docs")')
|
||||
assert 0 <= clear_i < fetch_i, (
|
||||
"the row clearing must precede the fetch (a populated → empty refresh "
|
||||
"must not leave ghost rows)"
|
||||
)
|
||||
assert 'fetch("/api/docs/tree")' in body, "the load must fetch the tree endpoint"
|
||||
assert "++loadSeq" in body, "the race token stays (phase 79)"
|
||||
render = js.find("function renderLevel()")
|
||||
assert render != -1, "renderLevel must exist"
|
||||
render_body = js[render : js.find("\n }", render)]
|
||||
for container in ("foldersTbody", "tbody"):
|
||||
clear_i = render_body.find(f"{container}.replaceChildren()")
|
||||
append_i = render_body.find(f"{container}.appendChild")
|
||||
assert 0 <= clear_i < append_i, (
|
||||
f"{container}: the clear must precede the fill (a populated → "
|
||||
"sparser refresh must not leave ghost rows)"
|
||||
)
|
||||
|
||||
|
||||
def test_git_sources_view_refetches_on_reshow() -> None:
|
||||
|
||||
@@ -285,7 +285,7 @@ def test_tick_decision_tree_order_and_branches() -> None:
|
||||
'syncResult.textContent = ""',
|
||||
"hideSyncError()",
|
||||
'emitSyncStatus({ state: "idle" })',
|
||||
"loadDocs()",
|
||||
"loadTree()", # phase 97: the catalog load is the tree fetch
|
||||
):
|
||||
assert line in up_ok, f"the upload-success settle must carry {line!r}"
|
||||
assert "fmtSyncResult" not in up_ok, "no upload counts in #sync-result (A3)"
|
||||
@@ -300,7 +300,7 @@ def test_tick_decision_tree_order_and_branches() -> None:
|
||||
'emitSyncStatus({ state: "idle" })',
|
||||
):
|
||||
assert line in up_fail, f"the upload-failed settle must carry {line!r}"
|
||||
assert "loadDocs()" not in up_fail, "no KB change on a failed upload"
|
||||
assert "loadTree()" not in up_fail, "no KB change on a failed upload"
|
||||
assert "showSyncError" not in up_fail, "no banner on this page (A3)"
|
||||
assert "applySyncFailure" not in up_fail and "showSyncModal" not in up_fail
|
||||
# 7. both idle: the unchanged idle settle.
|
||||
@@ -333,7 +333,7 @@ def test_reattach_adopts_a_running_upload_only() -> None:
|
||||
upload run re-attaches (running state, upload kind — phase 90: no
|
||||
live file, the label stays bare "Importing…" — the synthetic
|
||||
running frame, the poll starts); a terminal upload is a no-op —
|
||||
the fall-through is the plain idle settle (the boot-time loadDocs()
|
||||
the fall-through is the plain idle settle (the boot-time loadTree()
|
||||
already shows the current catalog)."""
|
||||
body = _fn(_js(), "initSyncButton")
|
||||
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
|
||||
@@ -370,7 +370,7 @@ def test_section_header_documents_the_two_job_contract() -> None:
|
||||
header = js[marker : js.find("const syncBtn")]
|
||||
assert "Phase 64 (task 04)" in header
|
||||
assert "/api/git-sources/upload/status" in header, "the second job's endpoint"
|
||||
assert "loadDocs" in header, "the A3 catalog refresh"
|
||||
assert "loadTree" in header, "the A3 catalog refresh (phase 97 rename)"
|
||||
assert "A3" in header and "A4" in header
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""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"),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -505,7 +505,9 @@ def test_sources_js_renders_result_line_and_banner_directly() -> None:
|
||||
assert "syncResult.textContent = fmtSyncResult(status.detail)" in success, (
|
||||
"the counts live in status.detail — a bare status renders all zeros"
|
||||
)
|
||||
assert "loadDocs()" in success, "the catalog re-fetches live on a successful sync"
|
||||
assert "loadTree()" in success, (
|
||||
"the catalog re-fetches live on a successful sync (phase 97: the tree)"
|
||||
)
|
||||
failure = _body(js, "applySyncFailure")
|
||||
assert "showSyncError(error)" in failure
|
||||
assert "emitSyncStatus" in _body(js, "applySyncIdle")
|
||||
|
||||
Reference in New Issue
Block a user