Files
brain-of-reese/tests/e2e/test_incremental_folder_summaries.py
ducoterra 742eef955b
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 155_folder_summary_voice
All verification is complete — the whole project is 100% green. Final report:

**Phase 155 final verification pass — complete.**

**Verified (all tasks 01–04 done in `complete/`):**
- Instruction nudge in `app/rag/folder_summaries.py` (forbidden openers as the pin, 4 constraints preserved, docstring cites the phase)
- `scripts/regen_folder_summaries.py` (import_docs idiom, full mode, cron-safe summary line, fail-loud boot)
- Unit pins, integration suite, mock voice update, dedicated E2E suite

**Test / lint / coverage:**
- `uv run pytest --cov=app -q` → 100% passed (exit 0, zero failures); coverage **99%** on `app/` (>90% ✓)
- `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` → **3 passed** in isolation
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:**
1. Marker + header prefix byte-identical — ✅ (git vs HEAD + unit pins verbatim, green)
2. Regen script full regeneration — ✅ (live smoke: exit 0, `folders=… limit=7` summary line; misconfig boot → named stderr, exit 1; `updated_at`-advance/no-prune/canned-text semantics proven by the integration suite, green)
3. Sync path untouched — ✅ (0 diffs to `app/api/sync.py` / `app/rag/importer.py`; phase-127 pins green)
4. E2E stored + rendered without forbidden openers — ✅ (3/3, exact canned-string assertions + phase-127 regression guard)
5. Full suite green, E2E isolated green, ruff + pyright clean, coverage >90% — ✅
6. Atomic commit — deferred to the harness (working tree left intact per protocol)

**Documentation:** README.md and AGENTS.md reviewed in full — verified consistent, no changes needed (README already documents the new script in the update workflow; all `.env.example` vars documented; pages table matches routes; 17 colors = 9+8; no stale mock-voice references).

**Decisions:** No live-infrastructure changes (dev-DB read + a 0-row script smoke only). The `TODO.md` working-tree edit is the owner's own, left untouched.

**Next pending phase:** `156_table_ux_polish`.
2026-10-04 16:32:50 -04:00

620 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 127 task 04 E2E (Playwright, mock-only): a one-file change
rewrites ONLY that folder's summary chain.
The dedicated story suite for ``127_sync_incremental_folder_summaries``
(A16 — one Playwright file per phase, run in isolation): the owner's
report was that "on sync brain of reese rewrites all the summaries for
every directory — so even if just one file changed this can result in
700+ directory summary rewrites in production which can take a very
long time" (``TODO.md`` L3). The fix under test: a KB-changing sync
passes the import's changed-document set
(``ImportSummary.changed_paths``) into
``generate_folder_summaries(changed_docs=…)``, so ONLY the folders a
changed document touches — each document's folder chain up to its
source root (``affected_folder_keys``) — are re-stamped; every other
stored row keeps its summary text AND ``updated_at`` byte-identical.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_incremental_folder_summaries.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic mock (the phase-94 ``FOLDER_SUMMARY_MODE`` canned
``Documentation for <folder>.`` lines): the stored rows are a
pure function of the request, so the byte-identical assertions are
exact only against the mock.
No slow-LLM proxy: the assertions are on the POST-terminal DB state
(the rows' ``summary`` AND ``updated_at``) plus the terminal status
(the keep-final-counts convention — the terminal keeps
``summaries_done == summaries_total == <affected count>``), never on
in-flight sampling, so the fast mock is dense enough by construction.
KB fixture — one host temp dir (``tmp_path_factory``; the app runs on
the same host) registered as a ``kind=local`` source (the
``test_local_directory_sources.py`` / phase-94 API-registration
pattern; no git anywhere): ``incsum/`` with FOUR ≥ 2-doc folders —
``alpha/``, ``bravo/``, ``charlie/``, ``delta/`` (2 docs each) — plus
ONE root-level doc (9 documents total). The generator's candidate set
is therefore exactly 5: the source root (``folder_path ""``) + the
four folders (the recursive-subtree ≥ 1 rule).
Autouse cleanup (the phase-96 pattern): before each test wait for no
running sync on the module app, then truncate the shared registry + KB
tables (documents/chunks/query_log/steering_notes/kb_overview/
git_sources/folder_summaries) — each test seeds its own source and
runs its own sync(s); after each test the same truncate. (The temp
tree itself persists for the module; the tests' disk mutations are
ordered by test definition and each test re-syncs the state it finds.)
Test → observable mapping (Playwright Mapping Rule):
1. ``test_first_sync_generates_all_rows`` — a first (full) sync settles
``success``; the ``folder_summaries`` table holds EXACTLY the 5
rows (source root + the four folders) with the mock's canned text
naming each folder, each ``updated_at`` non-null, and the terminal
keeps ``summaries_done == summaries_total == 5`` (the full
regeneration — a changed KB with no prior rows, the pre-127
behavior preserved end to end).
2. ``test_one_file_change_restamps_only_that_folders_chain`` — re-seed
(autouse truncated) + sync #1, capture every row's
``(summary, updated_at)``; change ONE doc inside ``alpha/`` and
sync #2 settles ``success``: the ``alpha`` AND the source-root rows
carry a NEW ``updated_at`` (strictly after the captured stamp) with
the canned text, while the ``bravo``/``charlie``/``delta`` rows are
BYTE-IDENTICAL (same ``summary`` AND same ``updated_at``); the
terminal keeps ``summaries_done == summaries_total == 2`` (the
AFFECTED count — alpha + root, NOT the table's candidate count —
the keep-final-counts convention) and the detail shows the
single-file change (1 updated, 8 unchanged, 0 added/pruned).
3. ``test_pruned_file_restamps_only_its_chain_and_prunes_the_vanished_
folder`` — re-seed + sync #1, capture; delete ``bravo/``'s last
docs (the folder VANISHES — 0 documents, no longer a candidate) and
change one doc in ``charlie/``; the sync settles ``success``: the
``bravo`` row is PRUNED (gone — the prune pass owns vanished
folders' rows), the source-root AND ``charlie`` rows are re-stamped
(new ``updated_at`` + canned text), the ``alpha``/``delta`` rows
are byte-identical, and the terminal keeps
``summaries_done == summaries_total == 2`` (the affected CANDIDATE
count — charlie + root; bravo is no longer a candidate).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
import pytest
from sqlalchemy import select, text
from app.config import Settings as _Settings
from app.db import SessionLocal
from app.models import FolderSummary
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's 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_INCSUM", "8147"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# --------------------------------------------------------------------------
# Fixture constants (deterministic, token-controlled)
# --------------------------------------------------------------------------
#: The local source — the temp directory's basename (``kind=local`` →
#: the directory's basename is the source name, phase 38).
SOURCE = "incsum"
#: The four ≥ 2-doc folders (path order).
FOLDER_A = "alpha"
FOLDER_B = "bravo"
FOLDER_C = "charlie"
FOLDER_D = "delta"
FOLDERS = (FOLDER_A, FOLDER_B, FOLDER_C, FOLDER_D)
ROOT_NOTE = "root-note.md" # the one root-level doc
N_FILES = 9 # four folders × 2 docs + the root-level doc
N_CANDIDATES = 5 # the source root + the four folders (the ≥ 1-doc rule)
#: The mock's byte-stable ``FOLDER_SUMMARY_MODE`` lines for this fixture
#: (the phase-94 template — the label is the ``FOLDER_HEADER_PREFIX``
#: tail: ``<source>`` for the root, ``<source>/<folder>`` for a folder).
SUMMARY_FOR = "Documentation for {}."
EXPECTED: dict[str, str] = {
"": SUMMARY_FOR.format(SOURCE),
FOLDER_A: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_A}"),
FOLDER_B: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_B}"),
FOLDER_C: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_C}"),
FOLDER_D: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_D}"),
}
#: The single-file change (test 2) — a revised body whose hash differs
#: from the fixture's (the importer's sha256 delta reports ``updated``)
#: and which no other fixture text contains.
ALPHA_A_REVISED = (
"Incsum alpha fixture note A, REVISED: the only file test 2 changed "
"— the incremental regeneration must touch exactly this file's chain "
"(alpha + the source root), and nothing else. (RESE- INCSUM-01)"
)
#: The charlie change (test 3) — the same shape on the other side.
CHARLIE_A_REVISED = (
"Incsum charlie fixture note A, REVISED: the one changed file of "
"test 3's sync, while bravo vanishes entirely. (RESE- INCSUM-02)"
)
def _md(title: str, body: str) -> str:
return f"# {title}\n\n{body}\n"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def incsum_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The one-source temp tree (see the module docstring): the app
server runs on the same host, so the paths are visible to it. The
directory NAME is the source name (``kind=local``, phase 38)."""
root = tmp_path_factory.mktemp("bor_incsum")
src = root / SOURCE
for folder in FOLDERS:
(src / folder).mkdir(parents=True)
for letter in ("a", "b"):
(src / folder / f"{folder}-{letter}.md").write_text(
_md(
f"Incsum {folder.title()} {letter.upper()}",
f"Incsum {folder} fixture note {letter.upper()}: covers "
f"topic {letter.upper()} of the {SOURCE} source tree.",
),
encoding="utf-8",
)
(src / ROOT_NOTE).write_text(
_md(
"Incsum Root Note",
f"This file sits directly under the {SOURCE} source, not in "
"any folder.",
),
encoding="utf-8",
)
assert len(list(src.rglob("*.md"))) == N_FILES
return src
def _app_env(
base_url: str, tmp_path_factory: pytest.TempPathFactory
) -> dict[str, str]:
"""The per-module app env (the conftest pattern, cf.
``test_kb_tree.py`` / ``test_sync_summary_visibility.py``): the
``base_url`` is the mock LLM directly (the fast leg is the only
leg — see the module docstring), ``BOR_GIT_SOURCES`` forced empty
(the sync source is this suite's own DB-registered local row), and
the leak-guarded code defaults."""
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 base_url
)
# 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 local
# directory this suite registers.
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
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
return env
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
incsum_dir: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The app under test (all three tests): ``BOR_LLM_BASE_URL`` is
the mock directly — the canned ``FOLDER_SUMMARY_MODE`` lines are
byte-stable there, and the byte-identity assertions key on that
exact text (no slow proxy: the assertions are post-terminal, see
the module docstring). ``incsum_dir`` is a dependency only for the
fixture ordering (the temp tree exists before the app boots — the
sync reads it)."""
env = _app_env(f"http://127.0.0.1:{mock_llm}/v1", tmp_path_factory)
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 or document would
pollute the candidate count and the counts the syncs and the
folder-rows assertions pin 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_settled(base_url: str, timeout_s: float = 180.0) -> None:
"""No background sync may leak across tests (the run state lives in
the app's memory, and a still-running run would keep importing into
the NEXT test's truncated KB): wait for the app's sync status to
be non-running BEFORE the truncate. Own admin session (the
endpoint is admin-only) — usually a no-op: the tests settle only
after their run's terminal state."""
with httpx.Client(base_url=base_url, timeout=5.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
if r.json()["state"] != "running":
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
@pytest.fixture(autouse=True)
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
"""Per-test KB isolation (the phase-96 cleanup pattern): the
shared E2E Postgres is truncated before AND after every test —
each test seeds its own source and runs its own sync(s), so the
KB rows + folder_summaries rows of the temp source are exactly
this test's own. The pre-wait keeps a leaked run from the
previous test (the module app is shared) from racing the
truncate."""
_wait_sync_settled(app_url)
_truncate_all()
yield
_truncate_all()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _register_source(base_url: str, path: str) -> None:
"""Register the temp directory through the authenticated API (the
``test_local_directory_sources.py`` / phase-94 pattern — a plain
directory is an API/DB-only operation)."""
with httpx.Client(base_url=base_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/git-sources", json={"kind": "local", "path": path}
)
assert r.status_code == 201, r.text
def _run_sync(base_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
"""Login + ``POST /api/sync`` + poll the status endpoint until THIS
run reaches a terminal state (the ``test_local_directory_sources``
pattern, over plain httpx — with the stale-terminal freshness gate:
the module app is shared, so the PREVIOUS run's terminal lives in
the app's memory until this run supersedes it; a terminal is
accepted only after a running tick was observed or its
``started_at`` post-dates this POST)."""
with httpx.Client(base_url=base_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
t0 = time.time()
r = client.post("/api/sync")
assert r.status_code == 202, r.text
saw_running = False
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()
state = body["state"]
if state == "running":
saw_running = True
elif state in ("success", "failed") and (
saw_running
or _started_at(body) >= t0 - 1.0 # same-host clock; 1 s tolerance
):
return body
time.sleep(0.1)
raise AssertionError(f"sync did not reach a terminal state: {body}")
def _started_at(body: dict[str, Any]) -> float:
"""The status body's ``started_at`` as a wall-clock epoch (the
server stamps it in UTC; this process runs on the same host —
same clock, the phase-64 recorder's freshness convention)."""
started = body.get("started_at")
if not started:
return 0.0
try:
return datetime.fromisoformat(str(started)).timestamp()
except ValueError:
return 0.0
def _folder_rows() -> dict[str, tuple[str, bool, datetime]]:
"""The source's stored folder summaries
``{folder_path: (summary, manually_edited, updated_at)}``
(``""`` = the source root) — the test process's direct DB access
(the E2E's other established lens)."""
with SessionLocal() as db:
rows = db.execute(
select(
FolderSummary.folder_path,
FolderSummary.summary,
FolderSummary.manually_edited,
FolderSummary.updated_at,
).where(FolderSummary.source == SOURCE)
).all()
return {
folder: (summary, manually, updated)
for folder, summary, manually, updated in rows
}
# ---------------------------------------------------------------------------
# 1. The first sync (a changed KB with no prior rows) is the FULL
# regeneration: exactly the 5 candidate rows, canned text, the
# terminal keeps the full candidate count
# ---------------------------------------------------------------------------
def test_first_sync_generates_all_rows(
app_url: str, incsum_dir: Path, db_ready: None
) -> None:
"""A first sync of the temp source settles ``success`` with all 9
docs added; the ``folder_summaries`` table holds EXACTLY the 5
candidate rows (the source root + the four folders — the
recursive-subtree ≥ 1 rule) with the mock's canned text naming
each folder, every row AI-written and freshly stamped, and the
terminal keeps ``summaries_done == summaries_total == 5`` (the
full-regeneration count — a first sync passes no prior rows, so
this is the pre-127 behavior preserved end to end)."""
_register_source(app_url, str(incsum_dir))
body = _run_sync(app_url)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == N_FILES, detail
assert detail["updated"] == 0 and detail["unchanged"] == 0, detail
assert detail["pruned"] == 0, detail
assert detail["overview"] is True, detail
# Terminal (the keep-final-counts convention): the FULL candidate
# count — the pre-127 shape a first sync still reports.
assert body["summaries_done"] == N_CANDIDATES, body
assert body["summaries_total"] == N_CANDIDATES, body
assert body["phase"] is None, body
assert body["current_summary"] is None, body
# The DB pins the full regeneration: exactly one AI row per
# candidate, the deterministic canned line, a fresh stamp.
rows = _folder_rows()
assert set(rows) == {""} | set(FOLDERS), rows
for folder, (summary, manually, updated_at) in rows.items():
assert summary == EXPECTED[folder], (folder, summary)
assert manually is False, (folder, manually)
assert updated_at is not None, (folder, updated_at)
# ---------------------------------------------------------------------------
# 2. ONE changed file in alpha/ → exactly alpha's chain (alpha + the
# source root) is re-stamped; every other row is byte-identical
# (text AND updated_at); the terminal keeps the AFFECTED count
# ---------------------------------------------------------------------------
def test_one_file_change_restamps_only_that_folders_chain(
app_url: str, incsum_dir: Path, db_ready: None
) -> None:
"""Re-seed (the autouse truncate) + sync #1 (full), capturing every
row's ``(summary, updated_at)``; then ONE doc inside ``alpha/``
changes content and sync #2 settles ``success``: the ``alpha`` AND
the source-root rows carry a NEW ``updated_at`` (strictly after
the captured stamp) with the canned text, while the
``bravo``/``charlie``/``delta`` rows are byte-identical — same
``summary`` AND same ``updated_at`` (an unaffected folder's
documents, and hence its summary, are untouched — no re-stamp).
The terminal keeps ``summaries_done == summaries_total == 2`` (the
AFFECTED count — alpha + root, NOT the table's candidate count —
the keep-final-counts convention), and the detail shows the
single-file change."""
_register_source(app_url, str(incsum_dir))
body = _run_sync(app_url)
assert body["state"] == "success", body
assert body["detail"]["added"] == N_FILES, body["detail"]
before = _folder_rows()
assert set(before) == {""} | set(FOLDERS), before
# The single-file change: ONE alpha doc gets new content (the
# importer's sha256 delta reports ``updated`` — no add, no prune).
target = incsum_dir / FOLDER_A / f"{FOLDER_A}-a.md"
target.write_text(
_md("Incsum Alpha A (revised)", ALPHA_A_REVISED), encoding="utf-8"
)
body = _run_sync(app_url)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == 0, detail
assert detail["updated"] == 1, detail
assert detail["unchanged"] == N_FILES - 1, detail
assert detail["pruned"] == 0, detail
assert detail["overview"] is True, detail
# Terminal (the keep-final-counts convention): the AFFECTED count —
# alpha + the source root — the phase-127 observable at the wire.
assert body["summaries_done"] == 2, body
assert body["summaries_total"] == 2, body
assert body["phase"] is None, body
assert body["current_summary"] is None, body
after = _folder_rows()
assert set(after) == {""} | set(FOLDERS), after
# The chain (alpha + the source root): RE-STAMPED — a new stamp
# strictly after the captured one, the canned text, still
# AI-written (the mock labels from the folder header only, so the
# text is the same deterministic line).
for folder in ("", FOLDER_A):
summary, manually, updated_at = after[folder]
assert summary == EXPECTED[folder], (folder, summary)
assert manually is False, (folder, manually)
assert updated_at > before[folder][2], (
folder,
before[folder],
after[folder],
)
# The siblings: BYTE-IDENTICAL — same summary text AND same
# updated_at (never re-stamped, never even re-fetched for
# generation).
for folder in FOLDERS:
if folder == FOLDER_A:
continue
assert after[folder] == before[folder], (
folder,
before[folder],
after[folder],
)
# ---------------------------------------------------------------------------
# 3. A vanished folder (bravo/ loses its last docs) has its row PRUNED
# by the prune pass; the changed sibling's chain (charlie + the
# source root) is re-stamped; the other rows are byte-identical
# ---------------------------------------------------------------------------
def test_pruned_file_restamps_only_its_chain_and_prunes_the_vanished_folder(
app_url: str, incsum_dir: Path, db_ready: None
) -> None:
"""Re-seed + sync #1 (full), capturing every row; then ``bravo/``
loses its last docs (the folder VANISHES — 0 documents, no longer
a candidate) and ONE doc in ``charlie/`` changes content; the sync
settles ``success``: the ``bravo`` row is PRUNED (gone — the
prune pass owns vanished folders' rows, independent of the
generation's affected set), the source-root AND ``charlie`` rows
are re-stamped (new ``updated_at`` + canned text), the
``alpha``/``delta`` rows are byte-identical (text AND
``updated_at``), and the terminal keeps
``summaries_done == summaries_total == 2`` (the affected
CANDIDATE count — charlie + root; bravo is no longer a candidate,
so it is neither generated nor counted)."""
_register_source(app_url, str(incsum_dir))
body = _run_sync(app_url)
assert body["state"] == "success", body
assert body["detail"]["added"] == N_FILES, body["detail"]
before = _folder_rows()
assert set(before) == {""} | set(FOLDERS), before
# bravo/ loses its last docs — the folder vanishes from the
# catalogue (0 documents → below the ≥ 1 minimum → not a
# candidate → the prune pass owns its row).
for letter in ("a", "b"):
(incsum_dir / FOLDER_B / f"{FOLDER_B}-{letter}.md").unlink()
# One charlie doc gets new content (``updated``).
target = incsum_dir / FOLDER_C / f"{FOLDER_C}-a.md"
target.write_text(
_md("Incsum Charlie A (revised)", CHARLIE_A_REVISED), encoding="utf-8"
)
body = _run_sync(app_url)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == 0, detail
assert detail["updated"] == 1, detail
assert detail["unchanged"] == N_FILES - 3, detail
assert detail["pruned"] == 2, detail
assert detail["overview"] is True, detail
# Terminal (the keep-final-counts convention): the affected
# CANDIDATE count — charlie + the source root (bravo is gone from
# the candidates, so it is not in the affected set's intersection).
assert body["summaries_done"] == 2, body
assert body["summaries_total"] == 2, body
assert body["phase"] is None, body
assert body["current_summary"] is None, body
after = _folder_rows()
# The vanished folder's row is PRUNED (the prune pass — it runs in
# both modes, manual rows exempt — bravo's row is AI-written).
assert FOLDER_B not in after, after
assert set(after) == ({""} | set(FOLDERS)) - {FOLDER_B}, after
# charlie + the source root: RE-STAMPED — a new stamp strictly
# after the captured one, the canned text, still AI-written.
for folder in ("", FOLDER_C):
summary, manually, updated_at = after[folder]
assert summary == EXPECTED[folder], (folder, summary)
assert manually is False, (folder, manually)
assert updated_at > before[folder][2], (
folder,
before[folder],
after[folder],
)
# alpha + delta: BYTE-IDENTICAL — same summary text AND same
# updated_at (neither folder is in the changed set's chains).
for folder in (FOLDER_A, FOLDER_D):
assert after[folder] == before[folder], (
folder,
before[folder],
after[folder],
)