"""Unit tests for the sovereignty metrics emitter and store. Refs: #954 """ from unittest.mock import AsyncMock, patch import pytest from timmy.sovereignty.metrics import ( ALL_EVENT_TYPES, SovereigntyMetricsStore, emit_sovereignty_event, get_cost_per_hour, get_skills_crystallized, get_sovereignty_pct, record, ) pytestmark = pytest.mark.unit @pytest.fixture def store(tmp_path): """A fresh SovereigntyMetricsStore backed by a temp database.""" return SovereigntyMetricsStore(db_path=tmp_path / "test_sov.db") # ── ALL_EVENT_TYPES ─────────────────────────────────────────────────────────── class TestEventTypes: def test_all_expected_event_types_present(self): expected = { "perception_cache_hit", "perception_vlm_call", "decision_rule_hit", "decision_llm_call", "narration_template", "narration_llm", "skill_crystallized", "api_call", "api_cost", } assert ALL_EVENT_TYPES == expected # ── Record & retrieval ──────────────────────────────────────────────────────── class TestRecord: def test_record_inserts_event(self, store): store.record("perception_cache_hit") pct = store.get_sovereignty_pct("perception") assert pct == 100.0 def test_record_with_metadata(self, store): store.record("api_cost", metadata={"usd": 0.05}) cost = store.get_cost_per_hour() assert cost > 0.0 def test_record_with_session_id(self, store): store.record("skill_crystallized", session_id="sess-1") assert store.get_skills_crystallized("sess-1") == 1 def test_record_unknown_type_does_not_raise(self, store): """Unknown event types are silently stored (no crash).""" store.record("totally_unknown_event") # should not raise # ── Sessions ────────────────────────────────────────────────────────────────── class TestSessions: def test_start_session_returns_id(self, store): sid = store.start_session(game="Bannerlord") assert isinstance(sid, str) assert len(sid) > 0 def test_start_session_accepts_custom_id(self, store): sid = store.start_session(game="Bannerlord", session_id="my-session") assert sid == "my-session" def test_end_session_does_not_raise(self, store): sid = store.start_session() store.end_session(sid) # should not raise def test_start_session_idempotent(self, store): """Starting a session with the same ID twice is a no-op.""" sid = store.start_session(session_id="dup") sid2 = store.start_session(session_id="dup") assert sid == sid2 # ── Sovereignty percentage ──────────────────────────────────────────────────── class TestGetSovereigntyPct: def test_perception_all_cache_hits(self, store): for _ in range(5): store.record("perception_cache_hit") assert store.get_sovereignty_pct("perception") == 100.0 def test_perception_mixed(self, store): store.record("perception_cache_hit") store.record("perception_vlm_call") assert store.get_sovereignty_pct("perception") == 50.0 def test_decision_all_sovereign(self, store): for _ in range(3): store.record("decision_rule_hit") assert store.get_sovereignty_pct("decision") == 100.0 def test_narration_all_sovereign(self, store): store.record("narration_template") store.record("narration_template") assert store.get_sovereignty_pct("narration") == 100.0 def test_narration_all_llm(self, store): store.record("narration_llm") assert store.get_sovereignty_pct("narration") == 0.0 def test_no_events_returns_zero(self, store): assert store.get_sovereignty_pct("perception") == 0.0 def test_unknown_layer_returns_zero(self, store): assert store.get_sovereignty_pct("nonexistent_layer") == 0.0 def test_time_window_filters_old_events(self, store, tmp_path): """Events outside the time window are excluded.""" # Insert an event with a very old timestamp directly import sqlite3 from contextlib import closing with closing(sqlite3.connect(str(store._db_path))) as conn: conn.execute( "INSERT INTO events (timestamp, event_type, session_id, metadata_json) VALUES (?, ?, ?, ?)", ("2000-01-01T00:00:00+00:00", "perception_cache_hit", "", "{}"), ) conn.commit() # With a 60-second window, the old event should be excluded pct = store.get_sovereignty_pct("perception", time_window=60) assert pct == 0.0 def test_time_window_includes_recent_events(self, store): store.record("decision_rule_hit") pct = store.get_sovereignty_pct("decision", time_window=60) assert pct == 100.0 # ── Cost per hour ───────────────────────────────────────────────────────────── class TestGetCostPerHour: def test_no_events_returns_zero(self, store): assert store.get_cost_per_hour() == 0.0 def test_single_cost_event(self, store): # Record a cost of $1.00 within the last hour window store.record("api_cost", metadata={"usd": 1.00}) cost = store.get_cost_per_hour(time_window=3600) assert cost == pytest.approx(1.00, rel=1e-3) def test_multiple_cost_events(self, store): store.record("api_cost", metadata={"usd": 0.25}) store.record("api_cost", metadata={"usd": 0.75}) cost = store.get_cost_per_hour(time_window=3600) assert cost == pytest.approx(1.00, rel=1e-3) def test_missing_usd_field_is_zero(self, store): store.record("api_cost", metadata={"model": "gpt-4"}) assert store.get_cost_per_hour() == 0.0 def test_cost_extrapolated_for_short_window(self, store): """Cost recorded in a 1800s window is doubled to get per-hour rate.""" store.record("api_cost", metadata={"usd": 0.5}) cost = store.get_cost_per_hour(time_window=1800) assert cost == pytest.approx(1.0, rel=1e-3) # ── Skills crystallised ─────────────────────────────────────────────────────── class TestGetSkillsCrystallized: def test_no_skills_returns_zero(self, store): assert store.get_skills_crystallized() == 0 def test_counts_all_sessions(self, store): store.record("skill_crystallized", session_id="a") store.record("skill_crystallized", session_id="b") assert store.get_skills_crystallized() == 2 def test_filters_by_session(self, store): store.record("skill_crystallized", session_id="sess-1") store.record("skill_crystallized", session_id="sess-2") assert store.get_skills_crystallized("sess-1") == 1 def test_session_with_no_skills(self, store): store.record("skill_crystallized", session_id="sess-1") assert store.get_skills_crystallized("sess-999") == 0 # ── Snapshot ────────────────────────────────────────────────────────────────── class TestGetSnapshot: def test_snapshot_structure(self, store): snap = store.get_snapshot() assert "sovereignty" in snap assert "cost_per_hour" in snap assert "skills_crystallized" in snap def test_snapshot_sovereignty_has_all_layers(self, store): snap = store.get_snapshot() assert set(snap["sovereignty"].keys()) == {"perception", "decision", "narration"} def test_snapshot_reflects_events(self, store): store.record("perception_cache_hit") store.record("skill_crystallized") snap = store.get_snapshot() assert snap["sovereignty"]["perception"] == 100.0 assert snap["skills_crystallized"] == 1 # ── Module-level convenience functions ─────────────────────────────────────── class TestModuleLevelFunctions: def test_record_and_get_sovereignty_pct(self, tmp_path): with ( patch("timmy.sovereignty.metrics._store", None), patch("timmy.sovereignty.metrics.DB_PATH", tmp_path / "fn_test.db"), ): record("decision_rule_hit") pct = get_sovereignty_pct("decision") assert pct == 100.0 def test_get_cost_per_hour_module_fn(self, tmp_path): with ( patch("timmy.sovereignty.metrics._store", None), patch("timmy.sovereignty.metrics.DB_PATH", tmp_path / "fn_test2.db"), ): record("api_cost", {"usd": 0.5}) cost = get_cost_per_hour() assert cost > 0.0 def test_get_skills_crystallized_module_fn(self, tmp_path): with ( patch("timmy.sovereignty.metrics._store", None), patch("timmy.sovereignty.metrics.DB_PATH", tmp_path / "fn_test3.db"), ): record("skill_crystallized") count = get_skills_crystallized() assert count == 1 # ── emit_sovereignty_event ──────────────────────────────────────────────────── class TestEmitSovereigntyEvent: @pytest.mark.asyncio async def test_emit_records_and_publishes(self, tmp_path): with ( patch("timmy.sovereignty.metrics._store", None), patch("timmy.sovereignty.metrics.DB_PATH", tmp_path / "emit_test.db"), patch("infrastructure.events.bus.emit", new_callable=AsyncMock) as mock_emit, ): await emit_sovereignty_event("perception_cache_hit", {"frame": 42}, session_id="s1") mock_emit.assert_called_once() args = mock_emit.call_args[0] assert args[0] == "sovereignty.event.perception_cache_hit"