"""Tests for Workshop presence heartbeat.""" import json from unittest.mock import patch import pytest from timmy.workshop_state import ( WorkshopHeartbeat, _state_hash, get_state_dict, write_state, ) # --------------------------------------------------------------------------- # get_state_dict # --------------------------------------------------------------------------- def test_get_state_dict_returns_v1_schema(): state = get_state_dict() assert state["version"] == 1 assert "liveness" in state assert "current_focus" in state assert "mood" in state assert isinstance(state["active_threads"], list) assert isinstance(state["recent_events"], list) assert isinstance(state["concerns"], list) # Issue #360 enriched fields assert isinstance(state["confidence"], float) assert 0.0 <= state["confidence"] <= 1.0 assert isinstance(state["energy"], float) assert 0.0 <= state["energy"] <= 1.0 assert state["identity"]["name"] == "Timmy" assert state["identity"]["title"] == "The Workshop Wizard" assert isinstance(state["identity"]["uptime_seconds"], int) assert state["activity"]["current"] in ("idle", "thinking") assert state["environment"]["time_of_day"] in ( "morning", "afternoon", "evening", "night", "deep-night", ) assert state["environment"]["day_of_week"] in ( "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ) assert state["interaction"]["visitor_present"] is False assert isinstance(state["interaction"]["conversation_turns"], int) assert state["meta"]["schema_version"] == 1 assert state["meta"]["writer"] == "timmy-loop" assert "updated_at" in state["meta"] def test_get_state_dict_idle_mood(): """Idle engagement + settled mood → 'calm' presence mood.""" from timmy.cognitive_state import CognitiveState, CognitiveTracker tracker = CognitiveTracker.__new__(CognitiveTracker) tracker.state = CognitiveState(engagement="idle", mood="settled") with patch("timmy.cognitive_state.cognitive_tracker", tracker): state = get_state_dict() assert state["mood"] == "calm" def test_get_state_dict_maps_mood(): """Cognitive moods map to presence moods.""" from timmy.cognitive_state import CognitiveState, CognitiveTracker for cog_mood, expected in [ ("curious", "contemplative"), ("hesitant", "uncertain"), ("energized", "excited"), ]: tracker = CognitiveTracker.__new__(CognitiveTracker) tracker.state = CognitiveState(engagement="deep", mood=cog_mood) with patch("timmy.cognitive_state.cognitive_tracker", tracker): state = get_state_dict() assert state["mood"] == expected, f"Expected {expected} for {cog_mood}" # --------------------------------------------------------------------------- # write_state # --------------------------------------------------------------------------- def test_write_state_creates_file(tmp_path): target = tmp_path / "presence.json" state = {"version": 1, "liveness": "2026-01-01T00:00:00Z", "current_focus": ""} write_state(state, path=target) assert target.exists() data = json.loads(target.read_text()) assert data["version"] == 1 def test_write_state_creates_parent_dirs(tmp_path): target = tmp_path / "deep" / "nested" / "presence.json" write_state({"version": 1}, path=target) assert target.exists() # --------------------------------------------------------------------------- # _state_hash # --------------------------------------------------------------------------- def test_state_hash_ignores_liveness(): a = {"version": 1, "mood": "focused", "liveness": "2026-01-01T00:00:00Z"} b = {"version": 1, "mood": "focused", "liveness": "2026-12-31T23:59:59Z"} assert _state_hash(a) == _state_hash(b) def test_state_hash_detects_mood_change(): a = {"version": 1, "mood": "focused", "liveness": "t1"} b = {"version": 1, "mood": "idle", "liveness": "t1"} assert _state_hash(a) != _state_hash(b) # --------------------------------------------------------------------------- # WorkshopHeartbeat — _write_if_changed # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_write_if_changed_writes_on_first_call(tmp_path): target = tmp_path / "presence.json" hb = WorkshopHeartbeat(interval=60, path=target) with patch("timmy.workshop_state.get_state_dict") as mock_state: mock_state.return_value = { "version": 1, "liveness": "t1", "current_focus": "testing", "mood": "focused", } await hb._write_if_changed() assert target.exists() data = json.loads(target.read_text()) assert data["version"] == 1 assert data["current_focus"] == "testing" @pytest.mark.asyncio async def test_write_if_changed_skips_when_unchanged(tmp_path): target = tmp_path / "presence.json" hb = WorkshopHeartbeat(interval=60, path=target) fixed_state = {"version": 1, "liveness": "t1", "mood": "idle"} with patch("timmy.workshop_state.get_state_dict", return_value=fixed_state): await hb._write_if_changed() # First write target.write_text("") # Clear to detect if second write happens await hb._write_if_changed() # Should skip — state unchanged # File should still be empty (second write was skipped) assert target.read_text() == "" @pytest.mark.asyncio async def test_write_if_changed_writes_on_state_change(tmp_path): target = tmp_path / "presence.json" hb = WorkshopHeartbeat(interval=60, path=target) state_a = {"version": 1, "liveness": "t1", "mood": "idle"} state_b = {"version": 1, "liveness": "t2", "mood": "focused"} with patch("timmy.workshop_state.get_state_dict", return_value=state_a): await hb._write_if_changed() with patch("timmy.workshop_state.get_state_dict", return_value=state_b): await hb._write_if_changed() data = json.loads(target.read_text()) assert data["mood"] == "focused" @pytest.mark.asyncio async def test_write_if_changed_calls_on_change(tmp_path): """on_change callback is invoked with state dict when state changes.""" target = tmp_path / "presence.json" received = [] async def capture(state_dict): received.append(state_dict) hb = WorkshopHeartbeat(interval=60, path=target, on_change=capture) state = {"version": 1, "liveness": "t1", "mood": "focused"} with patch("timmy.workshop_state.get_state_dict", return_value=state): await hb._write_if_changed() assert len(received) == 1 assert received[0]["mood"] == "focused" @pytest.mark.asyncio async def test_write_if_changed_skips_on_change_when_unchanged(tmp_path): """on_change is NOT called when state hash is unchanged.""" target = tmp_path / "presence.json" call_count = 0 async def counter(_): nonlocal call_count call_count += 1 hb = WorkshopHeartbeat(interval=60, path=target, on_change=counter) state = {"version": 1, "liveness": "t1", "mood": "idle"} with patch("timmy.workshop_state.get_state_dict", return_value=state): await hb._write_if_changed() await hb._write_if_changed() assert call_count == 1 # --------------------------------------------------------------------------- # WorkshopHeartbeat — notify # --------------------------------------------------------------------------- def test_notify_sets_trigger(): hb = WorkshopHeartbeat(interval=60) assert not hb._trigger.is_set() hb.notify() assert hb._trigger.is_set() # --------------------------------------------------------------------------- # WorkshopHeartbeat — start/stop lifecycle # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_heartbeat_start_stop_lifecycle(tmp_path): target = tmp_path / "presence.json" hb = WorkshopHeartbeat(interval=60, path=target) with patch("timmy.workshop_state.get_state_dict", return_value={"version": 1}): await hb.start() assert hb._task is not None assert not hb._task.done() await hb.stop() assert hb._task is None