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`.
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""Unit tests: ``scripts/regen_folder_summaries.py`` wiring (phase 155, task 02).
|
|
|
|
The one-shot full-regeneration entrypoint is pinned by wiring, not by a
|
|
live LLM or DB: a fake ``LLMClient``, a fake short-lived session, and a
|
|
stubbed ``generate_folder_summaries`` that records its call. The pins:
|
|
|
|
* the FULL-mode call contract — the stub is called with NO kwargs
|
|
(``changed_docs=None`` / ``only_missing=False`` / ``skip=False`` are
|
|
the generator's defaults; the phase-127 change-gated sync path is
|
|
untouched, the script is a consumer of the full mode);
|
|
* the stats dict surfaces on the single cron-safe summary line
|
|
(``regen_folder_summaries: folders=… failed=… pruned=…
|
|
kept_manual=… limit=…``) on the module logger;
|
|
* the ``--limit`` debug passthrough (parity with ``import_docs``'s
|
|
parser) is named on the summary line without disturbing the
|
|
generator's call contract;
|
|
* the settings/boot failure exits non-zero with the NAMED stderr line
|
|
— the app validator idiom (``ensure_admin_configured`` names the
|
|
missing ``BOR_`` variable; a settings validation error names the
|
|
field); the LLM/session/generator never run on a boot failure;
|
|
* a DB/commit outage exits non-zero with the named stderr line (never
|
|
a silent partial — the session still closes, no summary line);
|
|
* ``main()`` calls ``configure_debugging()`` (the phase-8 idiom — the
|
|
``DEBUGPY=1`` listener contract itself is the phase-8 precedent,
|
|
``tests/unit/test_debugging.py``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from app.config import Settings
|
|
from scripts import regen_folder_summaries as regen
|
|
|
|
STATS = {"generated": 3, "failed": 1, "pruned": 2, "kept_manual": 1}
|
|
|
|
|
|
class _FakeLLM:
|
|
"""A duck-typed ``LLMClient`` stand-in (records construction)."""
|
|
|
|
|
|
class _FakeSession:
|
|
"""A short-lived session stub (phase-53 convention: the script's
|
|
own session commits; the generator only flushes)."""
|
|
|
|
def __init__(self, commit_error: Exception | None = None) -> None:
|
|
self.commit_error = commit_error
|
|
self.committed = False
|
|
self.closed = False
|
|
|
|
def commit(self) -> None:
|
|
if self.commit_error is not None:
|
|
raise self.commit_error
|
|
self.committed = True
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _GenerateStub:
|
|
"""The stubbed ``generate_folder_summaries``: records the call's
|
|
positional args and kwargs and returns the fixed stats dict."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
|
self.raised: Exception | None = None
|
|
|
|
async def __call__(
|
|
self, session: object, llm: object, **kwargs: object
|
|
) -> dict[str, int]:
|
|
self.calls.append(((session, llm), dict(kwargs)))
|
|
if self.raised is not None:
|
|
raise self.raised
|
|
return dict(STATS)
|
|
|
|
|
|
def _wire(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> tuple[_FakeSession, _GenerateStub, list[logging.LogRecord]]:
|
|
"""Boot the wiring with a fake LLM, fake session, and generator
|
|
stub; ``configure_logging``/``configure_debugging`` are spied (the
|
|
phase-8 idiom is pinned by the call, the listener contract by the
|
|
phase-8 precedent file)."""
|
|
session = _FakeSession()
|
|
stub = _GenerateStub()
|
|
llm = _FakeLLM()
|
|
|
|
monkeypatch.setattr(
|
|
regen,
|
|
"get_settings",
|
|
lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue]
|
|
)
|
|
monkeypatch.setattr(regen, "configure_logging", lambda level: None)
|
|
debugging_calls: list[int] = []
|
|
monkeypatch.setattr(
|
|
regen,
|
|
"configure_debugging",
|
|
lambda: debugging_calls.append(1) or False,
|
|
)
|
|
monkeypatch.setattr(regen, "LLMClient", lambda: llm)
|
|
monkeypatch.setattr(regen, "SessionLocal", lambda: session)
|
|
monkeypatch.setattr(regen, "generate_folder_summaries", stub)
|
|
|
|
records: list[logging.LogRecord] = []
|
|
|
|
class _Sink(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
records.append(record)
|
|
|
|
sink = _Sink()
|
|
regen_logger = logging.getLogger("scripts.regen_folder_summaries")
|
|
regen_logger.addHandler(sink)
|
|
regen_logger.setLevel(logging.INFO)
|
|
return session, stub, records
|
|
|
|
|
|
def _summary_lines(records: list[logging.LogRecord]) -> list[str]:
|
|
return [r.getMessage() for r in records if r.getMessage().startswith(
|
|
"regen_folder_summaries:"
|
|
)]
|
|
|
|
|
|
# ---------- the parser (the import_docs parity flag) ----------
|
|
|
|
|
|
def test_parser_defaults_to_no_limit() -> None:
|
|
args = regen.build_parser().parse_args([])
|
|
assert args.limit is None
|
|
|
|
|
|
def test_parser_limit_parity_flag() -> None:
|
|
args = regen.build_parser().parse_args(["--limit", "5"])
|
|
assert args.limit == 5
|
|
|
|
|
|
# ---------- the success path: the FULL-mode call contract ----------
|
|
|
|
|
|
def test_main_full_mode_defaults_and_stats_on_summary_line(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
session, stub, records = _wire(monkeypatch)
|
|
rc = regen.main([])
|
|
out = capsys.readouterr()
|
|
|
|
assert rc == 0
|
|
assert out.err == "" # success: no stderr noise
|
|
# The FULL-mode call contract: exactly one call, (session, llm)
|
|
# positional, NO kwargs — changed_docs=None / only_missing=False /
|
|
# skip=False are the generator's defaults (the phase-127 change-
|
|
# gated sync path is untouched — the script is a consumer of the
|
|
# default mode, not of changed_docs).
|
|
assert len(stub.calls) == 1
|
|
(pos, kwargs), = stub.calls
|
|
assert kwargs == {}
|
|
assert isinstance(pos[0], _FakeSession) # the short-lived session
|
|
assert isinstance(pos[1], _FakeLLM) # the LLMClient stand-in
|
|
# The phase-53 convention: the script's own session commits + closes.
|
|
assert session.committed is True
|
|
assert session.closed is True
|
|
# The stats dict surfaces on the single cron-safe summary line.
|
|
lines = _summary_lines(records)
|
|
assert len(lines) == 1
|
|
assert lines[0] == (
|
|
"regen_folder_summaries: folders=3 failed=1 pruned=2 "
|
|
"kept_manual=1 limit=none"
|
|
)
|
|
|
|
|
|
def test_main_debug_parsing_runs_full_mode_and_names_limit(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""The --limit debug passthrough (parity with import_docs): named
|
|
on the summary line WITHOUT disturbing the generator's call
|
|
contract — the full-mode defaults are still the no-kwargs call."""
|
|
_session, stub, records = _wire(monkeypatch)
|
|
rc = regen.main(["--limit", "7"])
|
|
assert rc == 0
|
|
assert len(stub.calls) == 1
|
|
assert stub.calls[0][1] == {} # the full-mode defaults, untouched
|
|
assert _summary_lines(records) == [
|
|
"regen_folder_summaries: folders=3 failed=1 pruned=2 "
|
|
"kept_manual=1 limit=7"
|
|
]
|
|
|
|
|
|
def test_main_calls_configure_debugging(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""The phase-8 wiring pin: main() calls configure_debugging()
|
|
(DEBUGPY=1 honored, default off — the listener contract itself is
|
|
the phase-8 precedent, tests/unit/test_debugging.py)."""
|
|
called: list[int] = []
|
|
session, _stub, _records = _wire(monkeypatch)
|
|
monkeypatch.setattr(
|
|
regen, "configure_debugging", lambda: called.append(1) or False
|
|
)
|
|
assert regen.main([]) == 0
|
|
assert called == [1]
|
|
assert session.committed is True
|
|
|
|
|
|
# ---------- the fail-loud boot (the app validator idiom) ----------
|
|
|
|
|
|
def test_main_boot_failure_missing_admin_secret_is_named(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""A missing BOR_ADMIN_PASSWORD: the app's fail-loud gate
|
|
(ensure_admin_configured) names the variable, main exits non-zero
|
|
with the named stderr line, and the LLM/session/generator NEVER
|
|
run (never a silent partial)."""
|
|
monkeypatch.setattr(
|
|
regen,
|
|
"get_settings",
|
|
lambda: Settings(_env_file=None, admin_password=""), # pyright: ignore[reportCallIssue]
|
|
)
|
|
monkeypatch.setattr(regen, "configure_logging", lambda level: None)
|
|
monkeypatch.setattr(regen, "configure_debugging", lambda: False)
|
|
|
|
llm_calls: list[int] = []
|
|
monkeypatch.setattr(
|
|
regen, "LLMClient", lambda: llm_calls.append(1) or _FakeLLM()
|
|
)
|
|
session = _FakeSession()
|
|
monkeypatch.setattr(regen, "SessionLocal", lambda: session)
|
|
stub = _GenerateStub()
|
|
monkeypatch.setattr(regen, "generate_folder_summaries", stub)
|
|
|
|
rc = regen.main([])
|
|
err = capsys.readouterr().err
|
|
|
|
assert rc == 1
|
|
assert "regen_folder_summaries: boot failed:" in err
|
|
assert "BOR_ADMIN_PASSWORD" in err # the gate names the variable
|
|
assert llm_calls == [] # the LLM never constructed
|
|
assert stub.calls == [] # the generator never ran
|
|
assert session.committed is False and session.closed is False
|
|
|
|
|
|
def test_main_settings_validation_error_is_named(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""A settings validation error (a bad env value — the settings
|
|
validator naming the offending field) names the failure the same
|
|
way: non-zero exit, the named line, nothing runs."""
|
|
def _bad_settings() -> Settings:
|
|
# The Settings validator rejects a negative recency_boost —
|
|
# the pydantic ValidationError names the field.
|
|
return Settings(_env_file=None, recency_boost=-1) # pyright: ignore[reportCallIssue]
|
|
|
|
monkeypatch.setattr(regen, "get_settings", _bad_settings)
|
|
monkeypatch.setattr(regen, "configure_logging", lambda level: None)
|
|
|
|
llm_calls: list[int] = []
|
|
monkeypatch.setattr(
|
|
regen, "LLMClient", lambda: llm_calls.append(1) or _FakeLLM()
|
|
)
|
|
stub = _GenerateStub()
|
|
monkeypatch.setattr(regen, "generate_folder_summaries", stub)
|
|
|
|
rc = regen.main([])
|
|
err = capsys.readouterr().err
|
|
|
|
assert rc == 1
|
|
assert "regen_folder_summaries: boot failed:" in err
|
|
assert "recency_boost" in err
|
|
assert llm_calls == []
|
|
assert stub.calls == []
|
|
|
|
|
|
# ---------- the fail-loud run (never a silent partial) ----------
|
|
|
|
|
|
def test_main_commit_outage_fails_loud(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""A DB/commit outage: the failure is NAMED on stderr, the exit
|
|
code is non-zero, the session still closes (the uncommitted
|
|
regeneration rolls back with it — never a silent partial), and NO
|
|
summary line is logged (a failed run reports nothing generated)."""
|
|
session, stub, records = _wire(monkeypatch)
|
|
session.commit_error = RuntimeError("db down (test sentinel)")
|
|
rc = regen.main([])
|
|
err = capsys.readouterr().err
|
|
|
|
assert rc == 1
|
|
assert "regen_folder_summaries: regeneration failed:" in err
|
|
assert "db down (test sentinel)" in err
|
|
assert len(stub.calls) == 1 # the generator ran…
|
|
assert session.committed is False # …but the commit failed
|
|
assert session.closed is True # …and the session closed (rollback)
|
|
assert _summary_lines(records) == [] # no success line
|
|
assert "regen_folder_summaries: folders=" not in err
|