This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/test_briefing.py

271 lines
9.3 KiB
Python
Raw Normal View History

feat(briefing): morning briefing + approval queue Implements the Morning Briefing and Approval Queue feature — the first step from tool to companion. Timmy now shows up before the owner asks. New modules ----------- • src/timmy/approvals.py — ApprovalItem dataclass, GOLDEN_TIMMY governance constant, full SQLite CRUD (create / list / approve / reject / expire). Items auto-expire after 7 days if not actioned. • src/timmy/briefing.py — BriefingEngine that queries swarm activity and chat history, calls Timmy's Agno agent for a prose summary, and caches the result in SQLite (~/.timmy/briefings.db). get_or_generate() skips regeneration if a fresh briefing (< 30 min) already exists. New routes (src/dashboard/routes/briefing.py) ---------------------------------------------- GET /briefing — full briefing page GET /briefing/approvals — HTMX partial: pending approval cards POST /briefing/approvals/{id}/approve — approve via HTMX (no page reload) POST /briefing/approvals/{id}/reject — reject via HTMX (no page reload) New templates ------------- • briefing.html — clean, mobile-first prose layout (max 680px) • partials/approval_cards.html — list of approval cards • partials/approval_card_single.html — single approval card with Approve/Reject HTMX buttons App wiring (src/dashboard/app.py) ---------------------------------- • Added asynccontextmanager lifespan with _briefing_scheduler background task. Generates a briefing at startup and every 6 hours; skips if fresh. Push notification hook (src/notifications/push.py) --------------------------------------------------- • notify_briefing_ready(briefing) — logs + triggers local notifier. Placeholder for APNs/Pushover wiring later. Navigation ---------- • Added BRIEFING link to the header nav in base.html. Tests ----- • tests/test_approvals.py — 17 tests: GOLDEN_TIMMY, CRUD, expiry, ordering • tests/test_briefing.py — 22 tests: dataclass, freshness, cache round-trip, generate/get_or_generate, push notification hook 354 tests, 354 passing. https://claude.ai/code/session_01D7p5w91KX3grBeioGiiGy8
2026-02-22 14:04:20 +00:00
"""Tests for timmy/briefing.py — morning briefing engine."""
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from timmy.briefing import (
Briefing,
BriefingEngine,
_load_latest,
_save_briefing,
is_fresh,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def tmp_db(tmp_path):
return tmp_path / "test_briefings.db"
@pytest.fixture()
def engine(tmp_db):
return BriefingEngine(db_path=tmp_db)
def _make_briefing(offset_minutes: int = 0) -> Briefing:
"""Create a Briefing with generated_at offset by offset_minutes from now."""
now = datetime.now(timezone.utc) - timedelta(minutes=offset_minutes)
return Briefing(
generated_at=now,
summary="Good morning. All quiet on the swarm front.",
approval_items=[],
period_start=now - timedelta(hours=6),
period_end=now,
)
# ---------------------------------------------------------------------------
# Briefing dataclass
# ---------------------------------------------------------------------------
def test_briefing_fields():
b = _make_briefing()
assert isinstance(b.generated_at, datetime)
assert isinstance(b.summary, str)
assert isinstance(b.approval_items, list)
assert isinstance(b.period_start, datetime)
assert isinstance(b.period_end, datetime)
def test_briefing_default_period_is_6_hours():
b = Briefing(generated_at=datetime.now(timezone.utc), summary="test")
delta = b.period_end - b.period_start
assert abs(delta.total_seconds() - 6 * 3600) < 5 # within 5 seconds
# ---------------------------------------------------------------------------
# is_fresh
# ---------------------------------------------------------------------------
def test_is_fresh_recent_briefing():
b = _make_briefing(offset_minutes=5)
assert is_fresh(b) is True
def test_is_fresh_stale_briefing():
b = _make_briefing(offset_minutes=45)
assert is_fresh(b) is False
def test_is_fresh_custom_max_age():
b = _make_briefing(offset_minutes=10)
assert is_fresh(b, max_age_minutes=5) is False
assert is_fresh(b, max_age_minutes=15) is True
# ---------------------------------------------------------------------------
# SQLite cache (save/load round-trip)
# ---------------------------------------------------------------------------
def test_save_and_load_briefing(tmp_db):
b = _make_briefing()
_save_briefing(b, db_path=tmp_db)
loaded = _load_latest(db_path=tmp_db)
assert loaded is not None
assert loaded.summary == b.summary
def test_load_latest_returns_none_when_empty(tmp_db):
assert _load_latest(db_path=tmp_db) is None
def test_load_latest_returns_most_recent(tmp_db):
old = _make_briefing(offset_minutes=60)
new = _make_briefing(offset_minutes=5)
_save_briefing(old, db_path=tmp_db)
_save_briefing(new, db_path=tmp_db)
loaded = _load_latest(db_path=tmp_db)
assert loaded is not None
# Should return the newer one (generated_at closest to now)
assert abs((loaded.generated_at.replace(tzinfo=timezone.utc) - new.generated_at).total_seconds()) < 5
# ---------------------------------------------------------------------------
# BriefingEngine.needs_refresh
# ---------------------------------------------------------------------------
def test_needs_refresh_when_no_cache(engine, tmp_db):
assert engine.needs_refresh() is True
def test_needs_refresh_false_when_fresh(engine, tmp_db):
fresh = _make_briefing(offset_minutes=5)
_save_briefing(fresh, db_path=tmp_db)
assert engine.needs_refresh() is False
def test_needs_refresh_true_when_stale(engine, tmp_db):
stale = _make_briefing(offset_minutes=45)
_save_briefing(stale, db_path=tmp_db)
assert engine.needs_refresh() is True
# ---------------------------------------------------------------------------
# BriefingEngine.get_cached
# ---------------------------------------------------------------------------
def test_get_cached_returns_none_when_empty(engine):
assert engine.get_cached() is None
def test_get_cached_returns_briefing(engine, tmp_db):
b = _make_briefing()
_save_briefing(b, db_path=tmp_db)
cached = engine.get_cached()
assert cached is not None
assert cached.summary == b.summary
# ---------------------------------------------------------------------------
# BriefingEngine.generate (agent mocked)
# ---------------------------------------------------------------------------
def test_generate_returns_briefing(engine):
with patch.object(engine, "_call_agent", return_value="All is well."):
with patch.object(engine, "_load_pending_items", return_value=[]):
b = engine.generate()
assert isinstance(b, Briefing)
assert b.summary == "All is well."
assert b.approval_items == []
def test_generate_persists_to_cache(engine, tmp_db):
with patch.object(engine, "_call_agent", return_value="Morning report."):
with patch.object(engine, "_load_pending_items", return_value=[]):
engine.generate()
cached = _load_latest(db_path=tmp_db)
assert cached is not None
assert cached.summary == "Morning report."
def test_generate_handles_agent_failure(engine):
with patch.object(engine, "_call_agent", side_effect=Exception("Ollama down")):
with patch.object(engine, "_load_pending_items", return_value=[]):
b = engine.generate()
assert isinstance(b, Briefing)
assert "offline" in b.summary.lower() or "Morning" in b.summary
# ---------------------------------------------------------------------------
# BriefingEngine.get_or_generate
# ---------------------------------------------------------------------------
def test_get_or_generate_uses_cache_when_fresh(engine, tmp_db):
fresh = _make_briefing(offset_minutes=5)
_save_briefing(fresh, db_path=tmp_db)
with patch.object(engine, "generate") as mock_gen:
with patch.object(engine, "_load_pending_items", return_value=[]):
result = engine.get_or_generate()
mock_gen.assert_not_called()
assert result.summary == fresh.summary
def test_get_or_generate_generates_when_stale(engine, tmp_db):
stale = _make_briefing(offset_minutes=45)
_save_briefing(stale, db_path=tmp_db)
with patch.object(engine, "_call_agent", return_value="New report."):
with patch.object(engine, "_load_pending_items", return_value=[]):
result = engine.get_or_generate()
assert result.summary == "New report."
def test_get_or_generate_generates_when_no_cache(engine):
with patch.object(engine, "_call_agent", return_value="Fresh report."):
with patch.object(engine, "_load_pending_items", return_value=[]):
result = engine.get_or_generate()
assert result.summary == "Fresh report."
# ---------------------------------------------------------------------------
# BriefingEngine._call_agent (unit — mocked agent)
# ---------------------------------------------------------------------------
def test_call_agent_returns_content(engine):
mock_run = MagicMock()
mock_run.content = "Agent said hello."
mock_agent = MagicMock()
mock_agent.run.return_value = mock_run
with patch("timmy.briefing.BriefingEngine._call_agent", wraps=engine._call_agent):
with patch("timmy.agent.create_timmy", return_value=mock_agent):
result = engine._call_agent("Say hello.")
# _call_agent calls create_timmy internally; result from content attr
assert isinstance(result, str)
def test_call_agent_falls_back_on_exception(engine):
with patch("timmy.agent.create_timmy", side_effect=Exception("no ollama")):
result = engine._call_agent("prompt")
assert "offline" in result.lower()
# ---------------------------------------------------------------------------
# Push notification hook
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_notify_briefing_ready_skips_when_no_approvals(caplog):
"""notify_briefing_ready should NOT fire native notification with 0 approvals."""
from notifications.push import notify_briefing_ready
b = _make_briefing() # approval_items=[]
with patch("notifications.push.notifier") as mock_notifier:
await notify_briefing_ready(b)
mock_notifier.notify.assert_not_called()
@pytest.mark.asyncio
async def test_notify_briefing_ready_fires_when_approvals_exist():
"""notify_briefing_ready should fire when there are pending approval items."""
from notifications.push import notify_briefing_ready
from timmy.briefing import ApprovalItem
feat(briefing): morning briefing + approval queue Implements the Morning Briefing and Approval Queue feature — the first step from tool to companion. Timmy now shows up before the owner asks. New modules ----------- • src/timmy/approvals.py — ApprovalItem dataclass, GOLDEN_TIMMY governance constant, full SQLite CRUD (create / list / approve / reject / expire). Items auto-expire after 7 days if not actioned. • src/timmy/briefing.py — BriefingEngine that queries swarm activity and chat history, calls Timmy's Agno agent for a prose summary, and caches the result in SQLite (~/.timmy/briefings.db). get_or_generate() skips regeneration if a fresh briefing (< 30 min) already exists. New routes (src/dashboard/routes/briefing.py) ---------------------------------------------- GET /briefing — full briefing page GET /briefing/approvals — HTMX partial: pending approval cards POST /briefing/approvals/{id}/approve — approve via HTMX (no page reload) POST /briefing/approvals/{id}/reject — reject via HTMX (no page reload) New templates ------------- • briefing.html — clean, mobile-first prose layout (max 680px) • partials/approval_cards.html — list of approval cards • partials/approval_card_single.html — single approval card with Approve/Reject HTMX buttons App wiring (src/dashboard/app.py) ---------------------------------- • Added asynccontextmanager lifespan with _briefing_scheduler background task. Generates a briefing at startup and every 6 hours; skips if fresh. Push notification hook (src/notifications/push.py) --------------------------------------------------- • notify_briefing_ready(briefing) — logs + triggers local notifier. Placeholder for APNs/Pushover wiring later. Navigation ---------- • Added BRIEFING link to the header nav in base.html. Tests ----- • tests/test_approvals.py — 17 tests: GOLDEN_TIMMY, CRUD, expiry, ordering • tests/test_briefing.py — 22 tests: dataclass, freshness, cache round-trip, generate/get_or_generate, push notification hook 354 tests, 354 passing. https://claude.ai/code/session_01D7p5w91KX3grBeioGiiGy8
2026-02-22 14:04:20 +00:00
b = _make_briefing()
b.approval_items = [
ApprovalItem(
id="test-1",
title="Test approval",
description="A test item",
proposed_action="do something",
impact="low",
created_at=datetime.now(timezone.utc),
status="pending",
),
]
feat(briefing): morning briefing + approval queue Implements the Morning Briefing and Approval Queue feature — the first step from tool to companion. Timmy now shows up before the owner asks. New modules ----------- • src/timmy/approvals.py — ApprovalItem dataclass, GOLDEN_TIMMY governance constant, full SQLite CRUD (create / list / approve / reject / expire). Items auto-expire after 7 days if not actioned. • src/timmy/briefing.py — BriefingEngine that queries swarm activity and chat history, calls Timmy's Agno agent for a prose summary, and caches the result in SQLite (~/.timmy/briefings.db). get_or_generate() skips regeneration if a fresh briefing (< 30 min) already exists. New routes (src/dashboard/routes/briefing.py) ---------------------------------------------- GET /briefing — full briefing page GET /briefing/approvals — HTMX partial: pending approval cards POST /briefing/approvals/{id}/approve — approve via HTMX (no page reload) POST /briefing/approvals/{id}/reject — reject via HTMX (no page reload) New templates ------------- • briefing.html — clean, mobile-first prose layout (max 680px) • partials/approval_cards.html — list of approval cards • partials/approval_card_single.html — single approval card with Approve/Reject HTMX buttons App wiring (src/dashboard/app.py) ---------------------------------- • Added asynccontextmanager lifespan with _briefing_scheduler background task. Generates a briefing at startup and every 6 hours; skips if fresh. Push notification hook (src/notifications/push.py) --------------------------------------------------- • notify_briefing_ready(briefing) — logs + triggers local notifier. Placeholder for APNs/Pushover wiring later. Navigation ---------- • Added BRIEFING link to the header nav in base.html. Tests ----- • tests/test_approvals.py — 17 tests: GOLDEN_TIMMY, CRUD, expiry, ordering • tests/test_briefing.py — 22 tests: dataclass, freshness, cache round-trip, generate/get_or_generate, push notification hook 354 tests, 354 passing. https://claude.ai/code/session_01D7p5w91KX3grBeioGiiGy8
2026-02-22 14:04:20 +00:00
with patch("notifications.push.notifier") as mock_notifier:
await notify_briefing_ready(b)
mock_notifier.notify.assert_called_once()
call_kwargs = mock_notifier.notify.call_args
assert "Briefing" in call_kwargs[1]["title"] or "Briefing" in call_kwargs[0][0]