test: add unit tests for briefing.py (#422)
This commit is contained in:
228
tests/timmy/test_briefing.py
Normal file
228
tests/timmy/test_briefing.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Unit tests for timmy.briefing — the morning briefing engine."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from timmy.briefing import (
|
||||
ApprovalItem,
|
||||
Briefing,
|
||||
BriefingEngine,
|
||||
_gather_swarm_summary,
|
||||
_load_latest,
|
||||
_save_briefing,
|
||||
is_fresh,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ApprovalItem / Briefing dataclass basics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApprovalItem:
|
||||
def test_fields(self):
|
||||
now = datetime.now(UTC)
|
||||
item = ApprovalItem(
|
||||
id="a1",
|
||||
title="Deploy v2",
|
||||
description="Upgrade prod",
|
||||
proposed_action="deploy",
|
||||
impact="high",
|
||||
created_at=now,
|
||||
status="pending",
|
||||
)
|
||||
assert item.id == "a1"
|
||||
assert item.status == "pending"
|
||||
|
||||
def test_briefing_defaults(self):
|
||||
b = Briefing(generated_at=datetime.now(UTC), summary="hello")
|
||||
assert b.approval_items == []
|
||||
assert b.period_start < b.period_end
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_fresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsFresh:
|
||||
def test_fresh_briefing(self):
|
||||
b = Briefing(generated_at=datetime.now(UTC), summary="ok")
|
||||
assert is_fresh(b) is True
|
||||
|
||||
def test_stale_briefing(self):
|
||||
old = datetime.now(UTC) - timedelta(hours=2)
|
||||
b = Briefing(generated_at=old, summary="old")
|
||||
assert is_fresh(b) is False
|
||||
|
||||
def test_custom_max_age(self):
|
||||
recent = datetime.now(UTC) - timedelta(minutes=10)
|
||||
b = Briefing(generated_at=recent, summary="recent")
|
||||
assert is_fresh(b, max_age_minutes=5) is False
|
||||
assert is_fresh(b, max_age_minutes=15) is True
|
||||
|
||||
def test_naive_datetime_handled(self):
|
||||
# briefing.generated_at without tzinfo should still work
|
||||
naive = datetime.now(UTC).replace(tzinfo=None)
|
||||
b = Briefing(generated_at=naive, summary="naive")
|
||||
assert is_fresh(b) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite cache round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSqliteCache:
|
||||
def test_save_and_load(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
now = datetime.now(UTC)
|
||||
b = Briefing(
|
||||
generated_at=now,
|
||||
summary="Good morning",
|
||||
period_start=now - timedelta(hours=6),
|
||||
period_end=now,
|
||||
)
|
||||
_save_briefing(b, db)
|
||||
loaded = _load_latest(db)
|
||||
assert loaded is not None
|
||||
assert loaded.summary == "Good morning"
|
||||
assert loaded.generated_at.isoformat()[:19] == now.isoformat()[:19]
|
||||
|
||||
def test_load_latest_returns_most_recent(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
old = datetime.now(UTC) - timedelta(hours=12)
|
||||
new = datetime.now(UTC)
|
||||
_save_briefing(Briefing(generated_at=old, summary="old"), db)
|
||||
_save_briefing(Briefing(generated_at=new, summary="new"), db)
|
||||
loaded = _load_latest(db)
|
||||
assert loaded.summary == "new"
|
||||
|
||||
def test_load_latest_empty_db(self, tmp_path):
|
||||
db = tmp_path / "empty.db"
|
||||
assert _load_latest(db) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _gather_swarm_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatherSwarmSummary:
|
||||
def test_missing_db_file(self):
|
||||
with patch("timmy.briefing.Path") as mock_path_cls:
|
||||
# Simulate swarm_db.exists() -> False
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.exists.return_value = False
|
||||
|
||||
original_path = Path
|
||||
|
||||
def side_effect(arg):
|
||||
if arg == "data/swarm.db":
|
||||
return mock_instance
|
||||
return original_path(arg)
|
||||
|
||||
mock_path_cls.side_effect = side_effect
|
||||
mock_path_cls.home = original_path.home
|
||||
|
||||
result = _gather_swarm_summary(datetime.now(UTC))
|
||||
assert (
|
||||
"No swarm" in result
|
||||
or "unavailable" in result.lower()
|
||||
or "No swarm activity" in result
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BriefingEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBriefingEngine:
|
||||
def test_get_cached_empty(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
engine = BriefingEngine(db_path=db)
|
||||
assert engine.get_cached() is None
|
||||
|
||||
def test_needs_refresh_empty(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
engine = BriefingEngine(db_path=db)
|
||||
assert engine.needs_refresh() is True
|
||||
|
||||
def test_needs_refresh_fresh(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_save_briefing(Briefing(generated_at=datetime.now(UTC), summary="fresh"), db)
|
||||
engine = BriefingEngine(db_path=db)
|
||||
assert engine.needs_refresh() is False
|
||||
|
||||
def test_needs_refresh_stale(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
old = datetime.now(UTC) - timedelta(hours=2)
|
||||
_save_briefing(Briefing(generated_at=old, summary="stale"), db)
|
||||
engine = BriefingEngine(db_path=db)
|
||||
assert engine.needs_refresh() is True
|
||||
|
||||
@patch("timmy.briefing.BriefingEngine._call_agent")
|
||||
@patch("timmy.briefing.BriefingEngine._load_pending_items")
|
||||
@patch("timmy.briefing._gather_swarm_summary")
|
||||
@patch("timmy.briefing._gather_chat_summary")
|
||||
@patch("timmy.briefing._gather_task_queue_summary")
|
||||
def test_generate(self, mock_task, mock_chat, mock_swarm, mock_pending, mock_agent, tmp_path):
|
||||
mock_swarm.return_value = "2 tasks completed"
|
||||
mock_chat.return_value = "No conversations"
|
||||
mock_task.return_value = "No tasks"
|
||||
mock_agent.return_value = "Good morning, Alexander."
|
||||
mock_pending.return_value = []
|
||||
|
||||
db = tmp_path / "test.db"
|
||||
engine = BriefingEngine(db_path=db)
|
||||
briefing = engine.generate()
|
||||
|
||||
assert briefing.summary == "Good morning, Alexander."
|
||||
mock_agent.assert_called_once()
|
||||
# Verify it was cached
|
||||
assert _load_latest(db) is not None
|
||||
|
||||
@patch("timmy.briefing.BriefingEngine._call_agent")
|
||||
@patch("timmy.briefing.BriefingEngine._load_pending_items")
|
||||
@patch("timmy.briefing._gather_swarm_summary")
|
||||
@patch("timmy.briefing._gather_chat_summary")
|
||||
@patch("timmy.briefing._gather_task_queue_summary")
|
||||
def test_generate_agent_failure(
|
||||
self, mock_task, mock_chat, mock_swarm, mock_pending, mock_agent, tmp_path
|
||||
):
|
||||
mock_swarm.return_value = ""
|
||||
mock_chat.return_value = ""
|
||||
mock_task.return_value = ""
|
||||
mock_agent.side_effect = Exception("LLM offline")
|
||||
mock_pending.return_value = []
|
||||
|
||||
db = tmp_path / "test.db"
|
||||
engine = BriefingEngine(db_path=db)
|
||||
briefing = engine.generate()
|
||||
# Should gracefully degrade
|
||||
assert "offline" in briefing.summary.lower()
|
||||
|
||||
@patch("timmy.briefing.BriefingEngine._load_pending_items")
|
||||
def test_get_or_generate_returns_cached(self, mock_pending, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_save_briefing(Briefing(generated_at=datetime.now(UTC), summary="cached"), db)
|
||||
mock_pending.return_value = []
|
||||
|
||||
engine = BriefingEngine(db_path=db)
|
||||
result = engine.get_or_generate()
|
||||
assert result.summary == "cached"
|
||||
|
||||
@patch("timmy.briefing.BriefingEngine.generate")
|
||||
def test_get_or_generate_regenerates_when_stale(self, mock_gen, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
old = datetime.now(UTC) - timedelta(hours=2)
|
||||
_save_briefing(Briefing(generated_at=old, summary="stale"), db)
|
||||
|
||||
fresh = Briefing(generated_at=datetime.now(UTC), summary="fresh")
|
||||
mock_gen.return_value = fresh
|
||||
|
||||
engine = BriefingEngine(db_path=db)
|
||||
result = engine.get_or_generate()
|
||||
assert result.summary == "fresh"
|
||||
mock_gen.assert_called_once()
|
||||
Reference in New Issue
Block a user