283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""
|
|
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 / ".cc-os" / "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 .cc-os/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 <cwd>/.cc-os/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 / ".cc-os").exists() # nothing written
|
|
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 / ".cc-os" / "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)
|