feat: add Pydantic models and EventEmitter for v2 core
- 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>
This commit is contained in:
311
tests/test_events.py
Normal file
311
tests/test_events.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user