cc-os/plugins/os-doc-hygiene/tests/test_cross_seam.py

145 lines
5.4 KiB
Python

"""
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