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

282 lines
11 KiB
Python
Raw Normal View History

"""
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 <cwd>/.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)