2026-07-03 15:12:31 +00:00
|
|
|
"""
|
|
|
|
|
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:
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
- the scanner produces a shortlist (and self-excludes .cc-os/dochygiene/),
|
2026-07-03 15:12:31 +00:00
|
|
|
- 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))
|
2026-07-12 21:47:01 +00:00
|
|
|
assert (root / ".cc-os" / "dochygiene" / "state.json").exists()
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
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
|
2026-07-12 21:47:01 +00:00
|
|
|
# .cc-os/dochygiene/ never scanned (self-exclusion).
|
|
|
|
|
assert not any(p.startswith(".cc-os") for p in shortlist)
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
state_dir = root / ".cc-os" / "dochygiene"
|
2026-07-03 15:12:31 +00:00
|
|
|
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
|