"""Tests for Heartbeat v2 — WorldInterface-driven cognitive loop. Acceptance criteria: - With MockWorldAdapter: heartbeat runs, logs show observe→reason→act→reflect - Without adapter: existing think_once() behaviour unchanged - WebSocket broadcasts include current action and reasoning summary """ from unittest.mock import AsyncMock, patch import pytest from infrastructure.world.adapters.mock import MockWorldAdapter from infrastructure.world.types import ActionStatus from loop.heartbeat import CycleRecord, Heartbeat @pytest.fixture def mock_adapter(): adapter = MockWorldAdapter( location="Balmora", entities=["Guard", "Merchant"], events=["player_entered"], ) adapter.connect() return adapter class TestHeartbeatWithAdapter: """With MockWorldAdapter: heartbeat runs full embodied cycle.""" @pytest.mark.asyncio async def test_run_once_returns_cycle_record(self, mock_adapter): hb = Heartbeat(world=mock_adapter) record = await hb.run_once() assert isinstance(record, CycleRecord) assert record.cycle_id == 1 @pytest.mark.asyncio async def test_observation_populated(self, mock_adapter): hb = Heartbeat(world=mock_adapter) record = await hb.run_once() assert record.observation["location"] == "Balmora" assert "Guard" in record.observation["entities"] assert "player_entered" in record.observation["events"] @pytest.mark.asyncio async def test_action_dispatched_to_world(self, mock_adapter): """Act phase should dispatch to world.act() for non-idle actions.""" hb = Heartbeat(world=mock_adapter) record = await hb.run_once() # The default loop phases don't set an explicit action, so it # falls through to "idle" → NOOP. That's correct behaviour — # the real LLM-powered reason phase will set action metadata. assert record.action_status in ( ActionStatus.NOOP.value, ActionStatus.SUCCESS.value, ) @pytest.mark.asyncio async def test_reflect_notes_present(self, mock_adapter): hb = Heartbeat(world=mock_adapter) record = await hb.run_once() assert "Balmora" in record.reflect_notes @pytest.mark.asyncio async def test_cycle_count_increments(self, mock_adapter): hb = Heartbeat(world=mock_adapter) await hb.run_once() await hb.run_once() assert hb.cycle_count == 2 assert len(hb.history) == 2 @pytest.mark.asyncio async def test_duration_recorded(self, mock_adapter): hb = Heartbeat(world=mock_adapter) record = await hb.run_once() assert record.duration_ms >= 0 @pytest.mark.asyncio async def test_on_cycle_callback(self, mock_adapter): received = [] async def callback(record): received.append(record) hb = Heartbeat(world=mock_adapter, on_cycle=callback) await hb.run_once() assert len(received) == 1 assert received[0].cycle_id == 1 class TestHeartbeatWithoutAdapter: """Without adapter: existing think_once() behaviour unchanged.""" @pytest.mark.asyncio async def test_passive_cycle(self): hb = Heartbeat(world=None) record = await hb.run_once() assert record.action_taken == "think" assert record.action_status == "noop" assert "Passive" in record.reflect_notes @pytest.mark.asyncio async def test_passive_no_observation(self): hb = Heartbeat(world=None) record = await hb.run_once() assert record.observation == {} class TestHeartbeatLifecycle: def test_interval_property(self): hb = Heartbeat(interval=60.0) assert hb.interval == 60.0 hb.interval = 10.0 assert hb.interval == 10.0 def test_interval_minimum(self): hb = Heartbeat() hb.interval = 0.1 assert hb.interval == 1.0 def test_world_property(self): hb = Heartbeat() assert hb.world is None adapter = MockWorldAdapter() hb.world = adapter assert hb.world is adapter def test_stop_sets_flag(self): hb = Heartbeat() assert not hb.is_running hb.stop() assert not hb.is_running class TestHeartbeatBroadcast: """WebSocket broadcasts include action and reasoning summary.""" @pytest.mark.asyncio async def test_broadcast_called(self, mock_adapter): with patch( "loop.heartbeat.ws_manager", create=True, ) as mock_ws: mock_ws.broadcast = AsyncMock() # Patch the import inside heartbeat with patch("infrastructure.ws_manager.handler.ws_manager") as ws_mod: ws_mod.broadcast = AsyncMock() hb = Heartbeat(world=mock_adapter) await hb.run_once() ws_mod.broadcast.assert_called_once() call_args = ws_mod.broadcast.call_args assert call_args[0][0] == "heartbeat.cycle" data = call_args[0][1] assert "action" in data assert "reasoning_summary" in data assert "observation" in data class TestHeartbeatLog: """Verify logging of observe→reason→act→reflect cycle.""" @pytest.mark.asyncio async def test_embodied_cycle_logs(self, mock_adapter, caplog): import logging with caplog.at_level(logging.INFO): hb = Heartbeat(world=mock_adapter) await hb.run_once() messages = caplog.text assert "Phase 1 (Gather)" in messages assert "Phase 2 (Reason)" in messages assert "Phase 3 (Act)" in messages assert "Heartbeat cycle #1 complete" in messages