- models.py: ProviderConfig, RoleConfig, DiscussionConfig, ScenarioConfig, Message, ToolCallRecord, Session — compatible with config.yaml format - events.py: Event constants, Event dataclass, EventEmitter with on/off/emit - 67 tests passing (37 models + 30 events) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
311 lines
9.8 KiB
Python
311 lines
9.8 KiB
Python
"""Tests for meeting_room.events — EventEmitter and event types."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
|
|
from meeting_room.events import (
|
|
AGENT_ERROR,
|
|
AGENT_MESSAGE,
|
|
AGENT_TURN_START,
|
|
BOSS_TURN,
|
|
DISCUSSION_END,
|
|
DISCUSSION_START,
|
|
Event,
|
|
EventEmitter,
|
|
ROUND_END,
|
|
ROUND_START,
|
|
TOOL_CALL,
|
|
TOOL_RESULT,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event type constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventConstants:
|
|
"""All event type constants must be lowercase strings."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"name, value",
|
|
[
|
|
("DISCUSSION_START", "discussion_start"),
|
|
("ROUND_START", "round_start"),
|
|
("AGENT_TURN_START", "agent_turn_start"),
|
|
("AGENT_MESSAGE", "agent_message"),
|
|
("TOOL_CALL", "tool_call"),
|
|
("TOOL_RESULT", "tool_result"),
|
|
("AGENT_ERROR", "agent_error"),
|
|
("ROUND_END", "round_end"),
|
|
("DISCUSSION_END", "discussion_end"),
|
|
("BOSS_TURN", "boss_turn"),
|
|
],
|
|
)
|
|
def test_constants(self, name: str, value: str) -> None:
|
|
import meeting_room.events as mod
|
|
|
|
assert getattr(mod, name) == value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvent:
|
|
def test_defaults(self) -> None:
|
|
e = Event(type=AGENT_MESSAGE)
|
|
assert e.type == AGENT_MESSAGE
|
|
assert e.data == {}
|
|
assert isinstance(e.timestamp, datetime)
|
|
|
|
def test_custom_data(self) -> None:
|
|
e = Event(type=TOOL_CALL, data={"tool": "search", "query": "test"})
|
|
assert e.data["tool"] == "search"
|
|
assert e.data["query"] == "test"
|
|
|
|
def test_timestamp_preserved(self) -> None:
|
|
ts = datetime(2025, 1, 1, 12, 0, 0)
|
|
e = Event(type=AGENT_ERROR, data={"msg": "fail"}, timestamp=ts)
|
|
assert e.timestamp == ts
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventEmitter — subscription
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventEmitterOn:
|
|
def test_on_returns_handler(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
def handler(event: Event) -> None:
|
|
pass
|
|
|
|
result = bus.on(AGENT_MESSAGE, handler)
|
|
assert result is handler
|
|
|
|
def test_on_stores_handler(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
|
|
assert len(bus.handlers(AGENT_MESSAGE)) == 1
|
|
|
|
def test_on_multiple_handlers_same_event(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
|
|
assert len(bus.handlers(AGENT_MESSAGE)) == 3
|
|
|
|
def test_on_different_event_types(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
bus.on(TOOL_CALL, lambda e: None)
|
|
|
|
assert len(bus.handlers(AGENT_MESSAGE)) == 1
|
|
assert len(bus.handlers(TOOL_CALL)) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventEmitter — unsubscription
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventEmitterOff:
|
|
def test_off_removes_handler(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
def handler(event: Event) -> None:
|
|
pass
|
|
|
|
bus.on(AGENT_MESSAGE, handler)
|
|
bus.off(AGENT_MESSAGE, handler)
|
|
|
|
assert len(bus.handlers(AGENT_MESSAGE)) == 0
|
|
|
|
def test_off_unknown_event_type_is_noop(self) -> None:
|
|
bus = EventEmitter()
|
|
bus.off("nonexistent_event", lambda e: None) # should not raise
|
|
|
|
def test_off_missing_handler_is_noop(self) -> None:
|
|
bus = EventEmitter()
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
bus.off(AGENT_MESSAGE, lambda e: None) # different lambda, no raise
|
|
|
|
def test_off_cleans_up_empty_list(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
def handler(event: Event) -> None:
|
|
pass
|
|
|
|
bus.on(AGENT_MESSAGE, handler)
|
|
bus.off(AGENT_MESSAGE, handler)
|
|
|
|
# Internal dict should not keep empty lists
|
|
assert AGENT_MESSAGE not in bus._handlers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventEmitter — emission
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventEmitterEmit:
|
|
def test_emit_calls_handler(self) -> None:
|
|
bus = EventEmitter()
|
|
received: list[Event] = []
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: received.append(e))
|
|
bus.emit(AGENT_MESSAGE, text="hello", agent="Alice")
|
|
|
|
assert len(received) == 1
|
|
assert received[0].type == AGENT_MESSAGE
|
|
assert received[0].data == {"text": "hello", "agent": "Alice"}
|
|
|
|
def test_emit_calls_all_handlers(self) -> None:
|
|
bus = EventEmitter()
|
|
results_a: list[str] = []
|
|
results_b: list[str] = []
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: results_a.append(e.data["text"]))
|
|
bus.on(AGENT_MESSAGE, lambda e: results_b.append(e.data["text"]))
|
|
|
|
bus.emit(AGENT_MESSAGE, text="hello")
|
|
|
|
assert results_a == ["hello"]
|
|
assert results_b == ["hello"]
|
|
|
|
def test_emit_no_handlers_is_safe(self) -> None:
|
|
bus = EventEmitter()
|
|
event = bus.emit(DISCUSSION_START) # no subscribers — no crash
|
|
assert event.type == DISCUSSION_START
|
|
|
|
def test_emit_returns_event(self) -> None:
|
|
bus = EventEmitter()
|
|
event = bus.emit(ROUND_START, round_number=3)
|
|
assert event.type == ROUND_START
|
|
assert event.data == {"round_number": 3}
|
|
assert isinstance(event.timestamp, datetime)
|
|
|
|
def test_emit_handler_exception_does_not_crash(self) -> None:
|
|
bus = EventEmitter()
|
|
call_count = 0
|
|
|
|
def bad_handler(event: Event) -> None:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
raise RuntimeError("boom")
|
|
|
|
def good_handler(event: Event) -> None:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
|
|
bus.on(AGENT_ERROR, bad_handler)
|
|
bus.on(AGENT_ERROR, good_handler)
|
|
|
|
# bad_handler raises, but good_handler still runs
|
|
bus.emit(AGENT_ERROR, msg="fail")
|
|
|
|
assert call_count == 2 # both handlers were called
|
|
|
|
def test_emit_multiple_events_independently(self) -> None:
|
|
bus = EventEmitter()
|
|
msg_log: list[str] = []
|
|
tool_log: list[str] = []
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: msg_log.append(e.data["text"]))
|
|
bus.on(TOOL_CALL, lambda e: tool_log.append(e.data["tool"]))
|
|
|
|
bus.emit(AGENT_MESSAGE, text="hi")
|
|
bus.emit(TOOL_CALL, tool="search")
|
|
bus.emit(AGENT_MESSAGE, text="bye")
|
|
|
|
assert msg_log == ["hi", "bye"]
|
|
assert tool_log == ["search"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventEmitter — clear
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEventEmitterClear:
|
|
def test_clear_removes_all_handlers(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
bus.on(AGENT_MESSAGE, lambda e: None)
|
|
bus.on(TOOL_CALL, lambda e: None)
|
|
bus.clear()
|
|
|
|
assert bus.handlers(AGENT_MESSAGE) == []
|
|
assert bus.handlers(TOOL_CALL) == []
|
|
assert bus._handlers == {}
|
|
|
|
def test_handlers_returns_copy(self) -> None:
|
|
bus = EventEmitter()
|
|
|
|
def handler(event: Event) -> None:
|
|
pass
|
|
|
|
bus.on(AGENT_MESSAGE, handler)
|
|
h = bus.handlers(AGENT_MESSAGE)
|
|
h.clear() # mutating the copy should not affect the bus
|
|
|
|
assert len(bus.handlers(AGENT_MESSAGE)) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration-style: full discussion lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDiscussionLifecycle:
|
|
"""Simulate a full discussion lifecycle to exercise all event types."""
|
|
|
|
def test_full_lifecycle(self) -> None:
|
|
bus = EventEmitter()
|
|
log: list[str] = []
|
|
|
|
bus.on(DISCUSSION_START, lambda e: log.append(f"DISCUSSION_START"))
|
|
bus.on(ROUND_START, lambda e: log.append(f"ROUND_START:{e.data['round']}"))
|
|
bus.on(AGENT_TURN_START, lambda e: log.append(f"TURN:{e.data['agent']}"))
|
|
bus.on(AGENT_MESSAGE, lambda e: log.append(f"MSG:{e.data['text']}"))
|
|
bus.on(TOOL_CALL, lambda e: log.append(f"TOOL:{e.data['tool']}"))
|
|
bus.on(TOOL_RESULT, lambda e: log.append(f"RESULT:{e.data['tool']}"))
|
|
bus.on(AGENT_ERROR, lambda e: log.append(f"ERR:{e.data['msg']}"))
|
|
bus.on(ROUND_END, lambda e: log.append(f"ROUND_END:{e.data['round']}"))
|
|
bus.on(BOSS_TURN, lambda e: log.append(f"BOSS:{e.data['decision']}"))
|
|
bus.on(DISCUSSION_END, lambda e: log.append("DISCUSSION_END"))
|
|
|
|
bus.emit(DISCUSSION_START)
|
|
bus.emit(ROUND_START, round=1)
|
|
bus.emit(AGENT_TURN_START, agent="Alice")
|
|
bus.emit(AGENT_MESSAGE, text="I think we should use Python.")
|
|
bus.emit(TOOL_CALL, tool="search")
|
|
bus.emit(TOOL_RESULT, tool="search")
|
|
bus.emit(ROUND_END, round=1)
|
|
bus.emit(BOSS_TURN, decision="proceed")
|
|
bus.emit(AGENT_ERROR, msg="timeout")
|
|
bus.emit(DISCUSSION_END)
|
|
|
|
assert log == [
|
|
"DISCUSSION_START",
|
|
"ROUND_START:1",
|
|
"TURN:Alice",
|
|
"MSG:I think we should use Python.",
|
|
"TOOL:search",
|
|
"RESULT:search",
|
|
"ROUND_END:1",
|
|
"BOSS:proceed",
|
|
"ERR:timeout",
|
|
"DISCUSSION_END",
|
|
] |