""" Tests for scripts/reminder.py Covers tasks 4.1 and 4.4 of the add-deterministic-core change. Pins invariants: #1 Hook only reminds — no mutation except `last_reminded`, only on banner. #2 Snooze ≤ once per calendar day while stale, keyed on `last_reminded`. sys.path is patched here (no shared conftest) so the test file is self-contained, as required by the file-ownership constraints. """ from __future__ import annotations import json import sys from datetime import datetime, timezone from io import StringIO from pathlib import Path import pytest # --------------------------------------------------------------------------- # Path bootstrap — inject scripts/ so we can import reminder + state_store. # --------------------------------------------------------------------------- _SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) import reminder as reminder_mod # noqa: E402 from reminder import ( # noqa: E402 Decision, Reminder, ReminderRunner, build_banner, main as reminder_main, ) from state_store import StateStore # noqa: E402 def _utc(y, m, d, hh=0, mm=0): return datetime(y, m, d, hh, mm, tzinfo=timezone.utc) class FrozenClock: """Injected clock returning a fixed `now`.""" def __init__(self, now: datetime): self._now = now def now(self) -> datetime: return self._now # =========================================================================== # Task 4.1 — pure decision function # =========================================================================== class TestReminderDecision: THRESHOLD = 21 def _r(self): return Reminder(threshold_days=self.THRESHOLD) def test_fresh_docs_are_silent(self): """last_check within threshold → SILENT (no banner).""" now = _utc(2026, 6, 24) last_check = _utc(2026, 6, 20) # 4 days ago, < 21 assert self._r().decide(last_check, None, now) is Decision.SILENT def test_never_checked_is_stale_and_banners(self): """last_check unset → stale → BANNER (no prior reminder).""" now = _utc(2026, 6, 24) assert self._r().decide(None, None, now) is Decision.BANNER def test_stale_never_reminded_banners(self): """last_check older than threshold, never reminded → BANNER.""" now = _utc(2026, 6, 24) last_check = _utc(2026, 5, 1) # ~54 days ago assert self._r().decide(last_check, None, now) is Decision.BANNER def test_stale_reminded_same_day_is_snoozed(self): """Stale + reminded earlier today → SILENT (invariant #2 snooze).""" now = _utc(2026, 6, 24, 17, 0) last_check = _utc(2026, 5, 1) last_reminded = _utc(2026, 6, 24, 9, 0) # earlier same calendar day assert self._r().decide(last_check, last_reminded, now) is Decision.SILENT def test_stale_reminded_prior_day_rebanners(self): """Stale + reminded a prior calendar day → BANNER again.""" now = _utc(2026, 6, 24, 9, 0) last_check = _utc(2026, 5, 1) last_reminded = _utc(2026, 6, 23, 23, 0) # yesterday assert self._r().decide(last_check, last_reminded, now) is Decision.BANNER def test_calendar_day_boundary_across_midnight(self): """ Discriminating case for calendar-day (not 24h-sliding) snooze: reminded 23:50 day1, now 00:10 day2 — only 20 minutes apart but a DIFFERENT calendar day → must BANNER. A naive 24h window would snooze. """ last_reminded = _utc(2026, 6, 23, 23, 50) now = _utc(2026, 6, 24, 0, 10) last_check = _utc(2026, 5, 1) assert self._r().decide(last_check, last_reminded, now) is Decision.BANNER def test_threshold_boundary_just_inside_is_silent(self): """Exactly at the threshold (not strictly greater) → fresh → SILENT.""" r = self._r() now = _utc(2026, 6, 22) last_check = _utc(2026, 6, 1) # exactly 21 days assert r.is_stale(last_check, now) is False assert r.decide(last_check, None, now) is Decision.SILENT def test_days_stale_reporting(self): r = self._r() assert r.days_stale(None, _utc(2026, 6, 24)) is None assert r.days_stale(_utc(2026, 6, 20), _utc(2026, 6, 24)) == 4 # =========================================================================== # Task 4.4 — runner: mutation discipline + I/O (invariants #1, #2) # =========================================================================== class TestReminderRunner: def _store(self, tmp_path) -> StateStore: # No clock injected on the store; the runner owns the clock. return StateStore(tmp_path) def test_fresh_run_is_silent_and_mutates_nothing(self, tmp_path): now = _utc(2026, 6, 24) store = self._store(tmp_path) store.set_timestamp("last_check", _utc(2026, 6, 23)) # fresh before = store.get_timestamp("last_reminded") out = StringIO() runner = ReminderRunner(store, clock=FrozenClock(now), out=out) decision = runner.run() assert decision is Decision.SILENT assert out.getvalue() == "" # no banner emitted assert store.get_timestamp("last_reminded") == before # unchanged def test_stale_never_reminded_banners_and_records_last_reminded(self, tmp_path): now = _utc(2026, 6, 24, 12, 0) store = self._store(tmp_path) store.set_timestamp("last_check", _utc(2026, 5, 1)) # stale assert store.get_timestamp("last_reminded") is None out = StringIO() runner = ReminderRunner(store, clock=FrozenClock(now), out=out) decision = runner.run() assert decision is Decision.BANNER payload = json.loads(out.getvalue()) assert "systemMessage" in payload assert "doc-hygiene" in payload["systemMessage"] # The ONLY mutation: last_reminded == now. assert store.get_timestamp("last_reminded") == now def test_stale_reminded_same_day_is_silent(self, tmp_path): now = _utc(2026, 6, 24, 17, 0) store = self._store(tmp_path) store.set_timestamp("last_check", _utc(2026, 5, 1)) store.set_timestamp("last_reminded", _utc(2026, 6, 24, 9, 0)) before = store.get_timestamp("last_reminded") out = StringIO() runner = ReminderRunner(store, clock=FrozenClock(now), out=out) decision = runner.run() assert decision is Decision.SILENT assert out.getvalue() == "" assert store.get_timestamp("last_reminded") == before # not bumped def test_stale_reminded_prior_day_rebanners(self, tmp_path): now = _utc(2026, 6, 24, 9, 0) store = self._store(tmp_path) store.set_timestamp("last_check", _utc(2026, 5, 1)) store.set_timestamp("last_reminded", _utc(2026, 6, 23, 8, 0)) out = StringIO() runner = ReminderRunner(store, clock=FrozenClock(now), out=out) decision = runner.run() assert decision is Decision.BANNER assert "systemMessage" in json.loads(out.getvalue()) assert store.get_timestamp("last_reminded") == now def test_missing_state_treated_as_never_checked(self, tmp_path): """No state.json at all → stale → banner, never blocks.""" now = _utc(2026, 6, 24) store = self._store(tmp_path) # nothing written out = StringIO() runner = ReminderRunner(store, clock=FrozenClock(now), out=out) decision = runner.run() assert decision is Decision.BANNER assert store.get_timestamp("last_reminded") == now def test_corrupt_state_treated_as_never_checked(self, tmp_path): """A corrupt timestamp string must not crash; treated as never-checked.""" now = _utc(2026, 6, 24) state_dir = tmp_path / ".dochygiene" state_dir.mkdir(parents=True, exist_ok=True) (state_dir / "state.json").write_text( json.dumps({"last_check": "not-a-date", "last_reminded": "garbage"}) ) store = self._store(tmp_path) out = StringIO() runner = ReminderRunner(store, clock=FrozenClock(now), out=out) decision = runner.run() assert decision is Decision.BANNER # unreadable → stale assert "systemMessage" in json.loads(out.getvalue()) def test_only_last_reminded_is_mutated(self, tmp_path): """On banner, last_check / last_clean are untouched (invariant #1).""" now = _utc(2026, 6, 24, 12, 0) store = self._store(tmp_path) check_ts = _utc(2026, 5, 1) clean_ts = _utc(2026, 5, 2) store.set_timestamp("last_check", check_ts) store.set_timestamp("last_clean", clean_ts) out = StringIO() ReminderRunner(store, clock=FrozenClock(now), out=out).run() assert store.get_timestamp("last_check") == check_ts assert store.get_timestamp("last_clean") == clean_ts assert store.get_timestamp("last_reminded") == now # =========================================================================== # Entry-point guard — never create .dochygiene/ outside a git project (#3) # =========================================================================== class TestGitProjectGuard: def test_non_git_cwd_stays_silent_and_writes_nothing(self, tmp_path, monkeypatch, capsys): """No .git anywhere → resolve falls back to cwd → reminder no-ops. Guards invariant #3: must NOT create /.dochygiene/ in an arbitrary directory (e.g. ~). """ monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path)) rc = reminder_main() assert rc == 0 assert capsys.readouterr().out == "" # no banner assert not (tmp_path / ".dochygiene").exists() # nothing written def test_git_project_banners_when_never_checked(self, tmp_path, monkeypatch, capsys): """A real git project with no prior check → banner (and creates state).""" (tmp_path / ".git").mkdir() monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path)) rc = reminder_main() assert rc == 0 out = capsys.readouterr().out assert "systemMessage" in json.loads(out) assert (tmp_path / ".dochygiene" / "state.json").exists() # =========================================================================== # Banner copy # =========================================================================== class TestBanner: def test_never_checked_copy(self): b = build_banner(None) assert "doc-hygiene" in b assert "no documentation check has been run yet" in b def test_pluralisation(self): assert "1 day ago" in build_banner(1) assert "4 days ago" in build_banner(4)