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`.
620 lines
27 KiB
Python
620 lines
27 KiB
Python
"""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],
|
||
)
|