AsyncEventBridge bridges sync EventEmitter to asyncio consumers. FastAPI app with session management, discussion control, roles/providers. WebSocket for real-time events + inject, SSE for read-only streaming. Minimal dark-theme HTML/JS UI. `meeting-room serve` CLI subcommand. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
225 lines
6.4 KiB
Python
225 lines
6.4 KiB
Python
"""Tests for AsyncEventBridge — sync EventEmitter → asyncio.Queue bridge."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from meeting_room.events import (
|
|
AGENT_MESSAGE,
|
|
AGENT_TURN_START,
|
|
DISCUSSION_END,
|
|
DISCUSSION_START,
|
|
ROUND_END,
|
|
ROUND_START,
|
|
TOOL_CALL,
|
|
AsyncEventBridge,
|
|
EventEmitter,
|
|
)
|
|
|
|
|
|
class TestAsyncEventBridgeSubscribe:
|
|
"""subscribe() creates a queue that receives events."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_returns_queue(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
q = bridge.subscribe()
|
|
assert isinstance(q, asyncio.Queue)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_subscribers(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
q1 = bridge.subscribe()
|
|
q2 = bridge.subscribe()
|
|
assert q1 is not q2
|
|
|
|
|
|
class TestAsyncEventBridgeStartStop:
|
|
"""start()/stop() wire the bridge to the sync bus."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_subscribes_to_all_event_types(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
for event_type in [
|
|
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
|
|
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
|
|
]:
|
|
assert len(bus.handlers(event_type)) == 1
|
|
|
|
bridge.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_unsubscribes(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
bridge.stop()
|
|
|
|
for event_type in [
|
|
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
|
|
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
|
|
]:
|
|
assert len(bus.handlers(event_type)) == 0
|
|
|
|
|
|
class TestAsyncEventBridgeForwarding:
|
|
"""Events emitted on the sync bus arrive in async queues."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_event_to_queue(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
q = bridge.subscribe()
|
|
|
|
# Emit a sync event
|
|
bus.emit(AGENT_MESSAGE, role_id="moderator", name="Moderator", content="Hello")
|
|
|
|
# Give the event loop a chance to process call_soon_threadsafe
|
|
await asyncio.sleep(0.05)
|
|
|
|
payload = q.get_nowait()
|
|
assert payload["type"] == AGENT_MESSAGE
|
|
assert payload["data"]["role_id"] == "moderator"
|
|
assert payload["data"]["content"] == "Hello"
|
|
assert "timestamp" in payload
|
|
|
|
bridge.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_queues_receive_same_event(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
q1 = bridge.subscribe()
|
|
q2 = bridge.subscribe()
|
|
|
|
bus.emit(DISCUSSION_START, name="test")
|
|
await asyncio.sleep(0.05)
|
|
|
|
p1 = q1.get_nowait()
|
|
p2 = q2.get_nowait()
|
|
assert p1["type"] == DISCUSSION_START
|
|
assert p2["type"] == DISCUSSION_START
|
|
assert p1["data"]["name"] == "test"
|
|
assert p2["data"]["name"] == "test"
|
|
|
|
bridge.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsubscribe_stops_delivery(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
q = bridge.subscribe()
|
|
bridge.unsubscribe(q)
|
|
|
|
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content="Y")
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert q.empty()
|
|
|
|
bridge.stop()
|
|
|
|
|
|
class TestAsyncEventBridgeThreadedForwarding:
|
|
"""Events from a worker thread reach async consumers."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_from_thread(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
q = bridge.subscribe()
|
|
|
|
# Run engine in a thread (simplified)
|
|
def sync_work() -> None:
|
|
bus.emit(DISCUSSION_START, name="threaded")
|
|
bus.emit(AGENT_MESSAGE, role_id="bot", name="Bot", content="Hi")
|
|
bus.emit(DISCUSSION_END, total_messages=1)
|
|
|
|
await asyncio.to_thread(sync_work)
|
|
await asyncio.sleep(0.1)
|
|
|
|
events = []
|
|
while not q.empty():
|
|
events.append(q.get_nowait())
|
|
|
|
types = [e["type"] for e in events]
|
|
assert "discussion_start" in types
|
|
assert "agent_message" in types
|
|
assert "discussion_end" in types
|
|
|
|
bridge.stop()
|
|
|
|
|
|
class TestAsyncEventBridgePayloadFormat:
|
|
"""Serialized events contain type, data, and ISO timestamp."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_payload_structure(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
q = bridge.subscribe()
|
|
bus.emit(TOOL_CALL, role_id="analyst", tool_name="search", arguments='{"q":"test"}')
|
|
await asyncio.sleep(0.05)
|
|
|
|
payload = q.get_nowait()
|
|
assert "type" in payload
|
|
assert "data" in payload
|
|
assert "timestamp" in payload
|
|
# timestamp is ISO format string
|
|
assert isinstance(payload["timestamp"], str)
|
|
assert payload["data"]["tool_name"] == "search"
|
|
|
|
bridge.stop()
|
|
|
|
|
|
class TestAsyncEventBridgeDropPolicy:
|
|
"""When a queue is full, events are dropped without crashing."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_queue_drops_events(self) -> None:
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
loop = asyncio.get_event_loop()
|
|
bridge.start(loop)
|
|
|
|
# maxsize=2 to make it fill quickly
|
|
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=2)
|
|
with bridge._lock:
|
|
bridge._queues.append(q)
|
|
|
|
# Emit 5 events — queue can hold 2
|
|
for i in range(5):
|
|
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content=f"msg{i}")
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Queue should have at most 2 items (rest dropped)
|
|
received = []
|
|
while not q.empty():
|
|
received.append(q.get_nowait())
|
|
|
|
assert len(received) <= 2
|
|
|
|
bridge.stop() |