"""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", ]