""" Cross-seam integration tests for the deterministic core (Group 5, task 5.1/5.2). Exercises scanner + state_store + reminder together on a small fixture doc tree built in a tmp dir, verifying the end-to-end deterministic path: - the scanner produces a shortlist (and self-excludes .dochygiene/), - the state store records timestamps and rolls reports over (keeps one pair), - the reminder's snooze behaves across simulated sessions with an injected clock (silent when fresh; banner when stale; snoozed same calendar day; re-banners the next day), all with NO model and NO network — the deterministic-first invariant (#6). sys.path is patched here (no shared conftest) so the file is self-contained. """ from __future__ import annotations import json import sys from datetime import datetime, timezone from io import StringIO from pathlib import Path import pytest _SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from scanner import Scanner # noqa: E402 from state_store import StateStore, resolve_project_root # noqa: E402 from reminder import Decision, ReminderRunner # noqa: E402 def _utc(y, m, d, hh=0, mm=0): return datetime(y, m, d, hh, mm, tzinfo=timezone.utc) class FrozenClock: def __init__(self, now): self._now = now def now(self): return self._now def _build_doc_tree(root: Path) -> None: """A minimal project: a git marker, two docs, plus a frozen + ignored doc.""" (root / ".git").mkdir() (root / "CLAUDE.md").write_text("# Project\nSee scripts/missing.py for details.\n") (root / "docs").mkdir() (root / "docs" / "plan.md").write_text("# Plan\nstuff\n") # frozen doc — must NOT appear in shortlist (invariant #9) (root / "docs" / "frozen.md").write_text("---\nhygiene: frozen\n---\n# Frozen\n") # ignored doc (root / ".dochygiene-ignore").write_text("docs/secret.md\n") (root / "docs" / "secret.md").write_text("# Secret\n") class TestScannerStateSeam: def test_scan_shortlist_and_self_exclusion(self, tmp_path): _build_doc_tree(tmp_path) # Resolve root exactly as production would (git root). root = resolve_project_root(tmp_path / "docs") assert root == tmp_path # The state store writes its dir first; the scanner must self-exclude it. store = StateStore(root) store.set_timestamp("last_check", _utc(2026, 6, 1)) assert (root / ".dochygiene" / "state.json").exists() artifact = Scanner(root=root, git_log_fn=lambda p: []).run() shortlist = set(artifact["shortlist"]) assert "CLAUDE.md" in shortlist assert "docs/plan.md" in shortlist # Exclusions honoured. assert "docs/frozen.md" not in shortlist assert "docs/secret.md" not in shortlist # .dochygiene/ never scanned (self-exclusion). assert not any(p.startswith(".dochygiene") for p in shortlist) def test_report_rollover_keeps_one_pair(self, tmp_path): _build_doc_tree(tmp_path) root = resolve_project_root(tmp_path) store = StateStore(root) store.write_report('{"v":1}', "# v1") store.write_report('{"v":2}', "# v2") state_dir = root / ".dochygiene" jsons = list(state_dir.glob("report*.json")) mds = list(state_dir.glob("report*.md")) assert len(jsons) == 1 assert len(mds) == 1 blob = store.read_report() assert blob is not None assert json.loads(blob[0]) == {"v": 2} class TestReminderAcrossSessions: """Drive the reminder over a sequence of simulated SessionStart events.""" def _session(self, store, now) -> Decision: out = StringIO() decision = ReminderRunner(store, clock=FrozenClock(now), out=out).run() # Banner ⇒ systemMessage on stdout; silent ⇒ nothing. if decision is Decision.BANNER: assert "systemMessage" in json.loads(out.getvalue()) else: assert out.getvalue() == "" return decision def test_full_lifecycle(self, tmp_path): _build_doc_tree(tmp_path) root = resolve_project_root(tmp_path) store = StateStore(root) # 1. Fresh check today → reminder is SILENT. store.set_timestamp("last_check", _utc(2026, 6, 24)) assert self._session(store, _utc(2026, 6, 24, 10)) is Decision.SILENT # 2. Simulate the check ageing past the threshold (set last_check far back). store.set_timestamp("last_check", _utc(2026, 5, 1)) # 3. First stale session → BANNER, records last_reminded. assert self._session(store, _utc(2026, 6, 24, 9)) is Decision.BANNER first_reminded = store.get_timestamp("last_reminded") assert first_reminded == _utc(2026, 6, 24, 9) # 4. Second session SAME calendar day (e.g. resume) → SILENT (snooze). assert self._session(store, _utc(2026, 6, 24, 18)) is Decision.SILENT assert store.get_timestamp("last_reminded") == first_reminded # not bumped # 5. Next calendar day, still stale → BANNER again, bumps last_reminded. assert self._session(store, _utc(2026, 6, 25, 8)) is Decision.BANNER assert store.get_timestamp("last_reminded") == _utc(2026, 6, 25, 8) # 6. A fresh check clears staleness → SILENT regardless of snooze. store.set_timestamp("last_check", _utc(2026, 6, 25, 12)) assert self._session(store, _utc(2026, 6, 25, 13)) is Decision.SILENT