forked from Rockachopa/Timmy-time-dashboard
177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
"""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
|