161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
"""Tests for hooks/session_start.py. Run: python3 tests/hook_test.py"""
|
|
|
|
import io
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(PLUGIN_ROOT / "hooks"))
|
|
|
|
from session_start import ( # noqa: E402
|
|
ABSENT_NOTE,
|
|
PRESENT_NOTE,
|
|
StateDir,
|
|
adr_system_present,
|
|
decide,
|
|
find_project_root,
|
|
run,
|
|
)
|
|
|
|
TODAY = date(2026, 7, 3)
|
|
YESTERDAY = date(2026, 7, 2)
|
|
|
|
|
|
class DecideTest(unittest.TestCase):
|
|
def test_present_injects_context_naming_both_surfaces(self):
|
|
outcome, note = decide(True, False, None, TODAY)
|
|
self.assertEqual("context", outcome)
|
|
self.assertIn("/os-adr:create", note)
|
|
self.assertIn("/os-adr:find", note)
|
|
|
|
def test_present_wins_even_if_suppressed(self):
|
|
self.assertEqual("context", decide(True, True, None, TODAY)[0])
|
|
|
|
def test_absent_unsuppressed_banners_with_init_and_migrate(self):
|
|
outcome, note = decide(False, False, None, TODAY)
|
|
self.assertEqual("banner", outcome)
|
|
self.assertIn("/os-adr:init", note)
|
|
self.assertIn("/os-adr:migrate", note)
|
|
|
|
def test_absent_suppressed_is_silent(self):
|
|
self.assertEqual(("silent", None), decide(False, True, None, TODAY))
|
|
|
|
def test_absent_reminded_today_is_silent(self):
|
|
self.assertEqual(("silent", None), decide(False, False, TODAY, TODAY))
|
|
|
|
def test_absent_reminded_yesterday_banners_again(self):
|
|
self.assertEqual("banner", decide(False, False, YESTERDAY, TODAY)[0])
|
|
|
|
|
|
class RunTest(unittest.TestCase):
|
|
def project(self, tmp, with_adr=False):
|
|
root = Path(tmp)
|
|
(root / ".git").mkdir()
|
|
if with_adr:
|
|
(root / "docs" / "adr").mkdir(parents=True)
|
|
(root / "docs" / "adr" / "README.md").write_text("index")
|
|
return root
|
|
|
|
def test_present_emits_additional_context(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = self.project(tmp, with_adr=True)
|
|
out = io.StringIO()
|
|
self.assertEqual("context", run(root, TODAY, out))
|
|
payload = json.loads(out.getvalue())
|
|
self.assertEqual(
|
|
PRESENT_NOTE,
|
|
payload["hookSpecificOutput"]["additionalContext"],
|
|
)
|
|
self.assertFalse((root / ".os-adr").exists(), "present case must not create state")
|
|
|
|
def test_absent_emits_banner_and_stamps_snooze(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = self.project(tmp)
|
|
out = io.StringIO()
|
|
self.assertEqual("banner", run(root, TODAY, out))
|
|
self.assertEqual(ABSENT_NOTE, json.loads(out.getvalue())["systemMessage"])
|
|
self.assertEqual(
|
|
TODAY.isoformat(), (root / ".os-adr" / "last_reminded").read_text()
|
|
)
|
|
|
|
def test_second_run_same_day_is_silent(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = self.project(tmp)
|
|
run(root, TODAY, io.StringIO())
|
|
out = io.StringIO()
|
|
self.assertEqual("silent", run(root, TODAY, out))
|
|
self.assertEqual("", out.getvalue())
|
|
|
|
def test_next_day_banners_again(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = self.project(tmp)
|
|
run(root, YESTERDAY, io.StringIO())
|
|
self.assertEqual("banner", run(root, TODAY, io.StringIO()))
|
|
|
|
def test_suppressed_project_stays_silent(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = self.project(tmp)
|
|
(root / ".os-adr").mkdir()
|
|
(root / ".os-adr" / "suppress").write_text("")
|
|
out = io.StringIO()
|
|
self.assertEqual("silent", run(root, TODAY, out))
|
|
self.assertEqual("", out.getvalue())
|
|
|
|
def test_corrupt_snooze_stamp_is_treated_as_unset(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = self.project(tmp)
|
|
(root / ".os-adr").mkdir()
|
|
(root / ".os-adr" / "last_reminded").write_text("not-a-date")
|
|
self.assertEqual("banner", run(root, TODAY, io.StringIO()))
|
|
|
|
|
|
class ProjectRootTest(unittest.TestCase):
|
|
def test_walks_up_to_git_root(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
(root / ".git").mkdir()
|
|
nested = root / "a" / "b"
|
|
nested.mkdir(parents=True)
|
|
self.assertEqual(root, find_project_root(nested))
|
|
|
|
def test_none_outside_any_git_project(self):
|
|
with TemporaryDirectory() as tmp:
|
|
self.assertIsNone(find_project_root(Path(tmp)))
|
|
|
|
|
|
class PythonRubyAgreementTest(unittest.TestCase):
|
|
"""Shared fixture tree: the Python existence check and Ruby
|
|
Adr::Repository#exists? must give the same answer (design D3 risk)."""
|
|
|
|
def ruby_exists(self, root):
|
|
script = (
|
|
f'require "{PLUGIN_ROOT}/lib/adr"; '
|
|
f'puts Adr::Repository.new(root: {str(root)!r}).exists?'
|
|
)
|
|
out = subprocess.run(
|
|
["ruby", "-e", script], capture_output=True, text=True, check=True
|
|
)
|
|
return out.stdout.strip() == "true"
|
|
|
|
def assert_agreement(self, root, expected):
|
|
self.assertEqual(expected, adr_system_present(root), "python check")
|
|
self.assertEqual(expected, self.ruby_exists(root), "ruby check")
|
|
|
|
def test_agreement_across_fixture_states(self):
|
|
with TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
self.assert_agreement(root, False) # nothing
|
|
(root / "docs" / "adr").mkdir(parents=True)
|
|
self.assert_agreement(root, False) # dir, no index
|
|
(root / "docs" / "adr" / "README.md").write_text("index")
|
|
self.assert_agreement(root, True) # dir + index
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|