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`.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user