""" Unit tests for the space event system. Tests the event models and EventBus functionality. """ import pytest import asyncio from datetime import datetime from unittest.mock import Mock, AsyncMock from markitect.spaces.events import ( SpaceEvent, SpaceEventType, EventBus, get_event_bus, reset_event_bus, space_created_event, space_updated_event, space_deleted_event, document_added_event, document_updated_event, document_removed_event, document_content_changed_event, cache_invalidated_event, ) class TestSpaceEventType: """Tests for SpaceEventType enum.""" def test_event_type_values(self): """Test that event types have expected string values.""" assert SpaceEventType.SPACE_CREATED.value == "space.created" assert SpaceEventType.DOCUMENT_ADDED.value == "document.added" assert SpaceEventType.CACHE_INVALIDATED.value == "cache.invalidated" def test_all_event_types_have_values(self): """Test that all event types have non-empty values.""" for event_type in SpaceEventType: assert event_type.value assert "." in event_type.value class TestSpaceEvent: """Tests for SpaceEvent dataclass.""" def test_event_creation(self): """Test basic event creation.""" event = SpaceEvent( event_type=SpaceEventType.SPACE_CREATED, space_id="space-123", payload={"name": "test-space"}, ) assert event.event_type == SpaceEventType.SPACE_CREATED assert event.space_id == "space-123" assert event.payload["name"] == "test-space" assert event.event_id is not None assert event.timestamp is not None def test_event_with_source_and_correlation(self): """Test event with source and correlation ID.""" event = SpaceEvent( event_type=SpaceEventType.DOCUMENT_ADDED, space_id="space-123", source="api", correlation_id="req-456", ) assert event.source == "api" assert event.correlation_id == "req-456" def test_event_to_dict(self): """Test event serialization.""" event = SpaceEvent( event_type=SpaceEventType.SPACE_UPDATED, space_id="space-123", payload={"changes": {"name": "new-name"}}, source="cli", ) data = event.to_dict() assert data["event_type"] == "space.updated" assert data["space_id"] == "space-123" assert data["payload"]["changes"]["name"] == "new-name" assert data["source"] == "cli" assert "event_id" in data assert "timestamp" in data def test_event_from_dict(self): """Test event deserialization.""" data = { "event_id": "evt-123", "event_type": "document.added", "space_id": "space-456", "payload": {"document_id": "doc-1", "space_path": "/intro.md"}, "timestamp": "2025-01-15T10:30:00", "source": "sync", } event = SpaceEvent.from_dict(data) assert event.event_id == "evt-123" assert event.event_type == SpaceEventType.DOCUMENT_ADDED assert event.space_id == "space-456" assert event.payload["document_id"] == "doc-1" assert event.source == "sync" def test_event_roundtrip(self): """Test that to_dict and from_dict are inverses.""" original = SpaceEvent( event_type=SpaceEventType.RENDER_COMPLETED, space_id="space-123", payload={"output_path": "/tmp/output"}, source="renderer", correlation_id="batch-1", ) data = original.to_dict() restored = SpaceEvent.from_dict(data) assert restored.event_type == original.event_type assert restored.space_id == original.space_id assert restored.payload == original.payload assert restored.source == original.source assert restored.correlation_id == original.correlation_id class TestEventFactories: """Tests for event factory functions.""" def test_space_created_event(self): """Test space_created_event factory.""" event = space_created_event("space-1", "my-docs", source="api") assert event.event_type == SpaceEventType.SPACE_CREATED assert event.space_id == "space-1" assert event.payload["name"] == "my-docs" assert event.source == "api" def test_space_updated_event(self): """Test space_updated_event factory.""" event = space_updated_event( "space-1", changes={"description": "Updated description"}, ) assert event.event_type == SpaceEventType.SPACE_UPDATED assert event.payload["changes"]["description"] == "Updated description" def test_space_deleted_event(self): """Test space_deleted_event factory.""" event = space_deleted_event("space-1", "old-space") assert event.event_type == SpaceEventType.SPACE_DELETED assert event.payload["name"] == "old-space" def test_document_added_event(self): """Test document_added_event factory.""" event = document_added_event("space-1", "doc-1", "/intro.md") assert event.event_type == SpaceEventType.DOCUMENT_ADDED assert event.payload["document_id"] == "doc-1" assert event.payload["space_path"] == "/intro.md" def test_document_content_changed_event(self): """Test document_content_changed_event factory.""" event = document_content_changed_event( "space-1", "doc-1", old_hash="abc", new_hash="xyz" ) assert event.event_type == SpaceEventType.DOCUMENT_CONTENT_CHANGED assert event.payload["old_hash"] == "abc" assert event.payload["new_hash"] == "xyz" def test_cache_invalidated_event(self): """Test cache_invalidated_event factory.""" event = cache_invalidated_event( "space-1", document_ids=["doc-1", "doc-2"], reason="dependency_changed", ) assert event.event_type == SpaceEventType.CACHE_INVALIDATED assert event.payload["document_ids"] == ["doc-1", "doc-2"] assert event.payload["reason"] == "dependency_changed" class TestEventBus: """Tests for EventBus.""" @pytest.fixture def bus(self): """Create a fresh EventBus for each test.""" return EventBus() def test_subscribe_and_emit(self, bus): """Test basic subscribe and emit.""" received_events = [] def handler(event: SpaceEvent): received_events.append(event) bus.subscribe(SpaceEventType.SPACE_CREATED, handler) event = space_created_event("space-1", "test") bus.emit(event) assert len(received_events) == 1 assert received_events[0].event_type == SpaceEventType.SPACE_CREATED def test_subscribe_returns_handler_id(self, bus): """Test that subscribe returns a handler ID.""" handler_id = bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) assert handler_id is not None assert isinstance(handler_id, str) def test_unsubscribe(self, bus): """Test unsubscribing a handler.""" received_events = [] def handler(event: SpaceEvent): received_events.append(event) bus.subscribe(SpaceEventType.SPACE_CREATED, handler) bus.emit(space_created_event("s1", "test1")) assert len(received_events) == 1 result = bus.unsubscribe(SpaceEventType.SPACE_CREATED, handler) assert result is True bus.emit(space_created_event("s2", "test2")) assert len(received_events) == 1 # No new events def test_unsubscribe_by_id(self, bus): """Test unsubscribing by handler ID.""" handler = Mock() handler_id = bus.subscribe(SpaceEventType.SPACE_CREATED, handler) bus.emit(space_created_event("s1", "test")) assert handler.call_count == 1 result = bus.unsubscribe_by_id(handler_id) assert result is True bus.emit(space_created_event("s2", "test")) assert handler.call_count == 1 # No additional calls def test_multiple_handlers(self, bus): """Test multiple handlers for same event type.""" handler1 = Mock() handler2 = Mock() bus.subscribe(SpaceEventType.SPACE_CREATED, handler1) bus.subscribe(SpaceEventType.SPACE_CREATED, handler2) bus.emit(space_created_event("s1", "test")) handler1.assert_called_once() handler2.assert_called_once() def test_handler_receives_correct_event(self, bus): """Test that handlers receive the emitted event.""" received = [] def handler(event: SpaceEvent): received.append(event) bus.subscribe(SpaceEventType.DOCUMENT_ADDED, handler) event = document_added_event("s1", "doc-1", "/intro.md") bus.emit(event) assert len(received) == 1 assert received[0].event_id == event.event_id def test_handler_isolation_by_event_type(self, bus): """Test that handlers only receive their subscribed event types.""" space_handler = Mock() doc_handler = Mock() bus.subscribe(SpaceEventType.SPACE_CREATED, space_handler) bus.subscribe(SpaceEventType.DOCUMENT_ADDED, doc_handler) bus.emit(space_created_event("s1", "test")) space_handler.assert_called_once() doc_handler.assert_not_called() def test_global_handler(self, bus): """Test handlers subscribed to all events.""" global_handler = Mock() specific_handler = Mock() bus.subscribe_all(global_handler) bus.subscribe(SpaceEventType.SPACE_CREATED, specific_handler) bus.emit(space_created_event("s1", "test")) bus.emit(document_added_event("s1", "doc-1", "/intro.md")) assert global_handler.call_count == 2 assert specific_handler.call_count == 1 def test_handler_priority(self, bus): """Test that handlers are called in priority order.""" call_order = [] def handler_low(event): call_order.append("low") def handler_high(event): call_order.append("high") def handler_medium(event): call_order.append("medium") bus.subscribe(SpaceEventType.SPACE_CREATED, handler_low, priority=100) bus.subscribe(SpaceEventType.SPACE_CREATED, handler_high, priority=10) bus.subscribe(SpaceEventType.SPACE_CREATED, handler_medium, priority=50) bus.emit(space_created_event("s1", "test")) assert call_order == ["high", "medium", "low"] def test_handler_exception_does_not_stop_others(self, bus): """Test that one handler's exception doesn't prevent other handlers.""" handler1 = Mock() handler2 = Mock(side_effect=ValueError("Test error")) handler3 = Mock() bus.subscribe(SpaceEventType.SPACE_CREATED, handler1, priority=1) bus.subscribe(SpaceEventType.SPACE_CREATED, handler2, priority=2) bus.subscribe(SpaceEventType.SPACE_CREATED, handler3, priority=3) exceptions = bus.emit(space_created_event("s1", "test")) handler1.assert_called_once() handler2.assert_called_once() handler3.assert_called_once() assert len(exceptions) == 1 assert isinstance(exceptions[0], ValueError) def test_has_handlers(self, bus): """Test checking for handlers.""" assert bus.has_handlers(SpaceEventType.SPACE_CREATED) is False bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) assert bus.has_handlers(SpaceEventType.SPACE_CREATED) is True assert bus.has_handlers(SpaceEventType.DOCUMENT_ADDED) is False def test_has_handlers_with_global(self, bus): """Test that has_handlers considers global handlers.""" assert bus.has_handlers(SpaceEventType.DOCUMENT_ADDED) is False bus.subscribe_all(lambda e: None) assert bus.has_handlers(SpaceEventType.DOCUMENT_ADDED) is True def test_handler_count(self, bus): """Test counting handlers.""" assert bus.handler_count() == 0 assert bus.handler_count(SpaceEventType.SPACE_CREATED) == 0 bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) bus.subscribe(SpaceEventType.DOCUMENT_ADDED, lambda e: None) assert bus.handler_count() == 3 assert bus.handler_count(SpaceEventType.SPACE_CREATED) == 2 assert bus.handler_count(SpaceEventType.DOCUMENT_ADDED) == 1 def test_clear_all(self, bus): """Test clearing all handlers.""" bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) bus.subscribe(SpaceEventType.DOCUMENT_ADDED, lambda e: None) bus.subscribe_all(lambda e: None) count = bus.clear() assert count == 3 assert bus.handler_count() == 0 def test_clear_by_event_type(self, bus): """Test clearing handlers for specific event type.""" bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) bus.subscribe(SpaceEventType.DOCUMENT_ADDED, lambda e: None) count = bus.clear(SpaceEventType.SPACE_CREATED) assert count == 2 assert bus.handler_count(SpaceEventType.SPACE_CREATED) == 0 assert bus.handler_count(SpaceEventType.DOCUMENT_ADDED) == 1 class TestEventBusHistory: """Tests for EventBus history feature.""" @pytest.fixture def bus(self): """Create a fresh EventBus with history enabled.""" bus = EventBus() bus.enable_history() return bus def test_history_disabled_by_default(self): """Test that history is disabled by default.""" bus = EventBus() bus.emit(space_created_event("s1", "test")) history = bus.get_history() assert len(history) == 0 def test_history_records_events(self, bus): """Test that history records emitted events.""" bus.emit(space_created_event("s1", "test1")) bus.emit(space_created_event("s2", "test2")) history = bus.get_history() assert len(history) == 2 def test_history_newest_first(self, bus): """Test that history returns newest events first.""" bus.emit(space_created_event("s1", "first")) bus.emit(space_created_event("s2", "second")) history = bus.get_history() assert history[0].payload["name"] == "second" assert history[1].payload["name"] == "first" def test_history_filter_by_event_type(self, bus): """Test filtering history by event type.""" bus.emit(space_created_event("s1", "test")) bus.emit(document_added_event("s1", "doc-1", "/intro.md")) bus.emit(space_created_event("s2", "test2")) history = bus.get_history(event_type=SpaceEventType.SPACE_CREATED) assert len(history) == 2 def test_history_filter_by_space_id(self, bus): """Test filtering history by space ID.""" bus.emit(space_created_event("s1", "test1")) bus.emit(space_created_event("s2", "test2")) bus.emit(document_added_event("s1", "doc-1", "/intro.md")) history = bus.get_history(space_id="s1") assert len(history) == 2 def test_history_limit(self, bus): """Test history limit.""" for i in range(10): bus.emit(space_created_event(f"s{i}", f"test{i}")) history = bus.get_history(limit=3) assert len(history) == 3 def test_history_max_size(self): """Test that history respects max size.""" bus = EventBus() bus.enable_history(max_events=5) for i in range(10): bus.emit(space_created_event(f"s{i}", f"test{i}")) history = bus.get_history() assert len(history) == 5 # Should have the most recent events assert history[0].payload["name"] == "test9" def test_clear_history(self, bus): """Test clearing history.""" bus.emit(space_created_event("s1", "test")) bus.emit(space_created_event("s2", "test")) bus.clear_history() history = bus.get_history() assert len(history) == 0 def test_disable_history(self, bus): """Test disabling history.""" bus.emit(space_created_event("s1", "test")) bus.disable_history() bus.emit(space_created_event("s2", "test")) history = bus.get_history() assert len(history) == 1 class TestEventBusAsync: """Tests for async EventBus functionality.""" @pytest.fixture def bus(self): """Create a fresh EventBus.""" return EventBus() @pytest.mark.asyncio async def test_async_handler(self, bus): """Test async event handler.""" received = [] async def async_handler(event: SpaceEvent): await asyncio.sleep(0.01) received.append(event) bus.subscribe(SpaceEventType.SPACE_CREATED, async_handler) await bus.emit_async(space_created_event("s1", "test")) assert len(received) == 1 @pytest.mark.asyncio async def test_mixed_sync_async_handlers(self, bus): """Test mixing sync and async handlers.""" sync_received = [] async_received = [] def sync_handler(event: SpaceEvent): sync_received.append(event) async def async_handler(event: SpaceEvent): await asyncio.sleep(0.01) async_received.append(event) bus.subscribe(SpaceEventType.SPACE_CREATED, sync_handler) bus.subscribe(SpaceEventType.SPACE_CREATED, async_handler) await bus.emit_async(space_created_event("s1", "test")) assert len(sync_received) == 1 assert len(async_received) == 1 @pytest.mark.asyncio async def test_async_handler_exception(self, bus): """Test that async handler exceptions are caught.""" async def failing_handler(event: SpaceEvent): raise ValueError("Async error") bus.subscribe(SpaceEventType.SPACE_CREATED, failing_handler) exceptions = await bus.emit_async(space_created_event("s1", "test")) assert len(exceptions) == 1 assert isinstance(exceptions[0], ValueError) class TestGlobalEventBus: """Tests for global event bus singleton.""" def test_get_event_bus_returns_same_instance(self): """Test that get_event_bus returns the same instance.""" reset_event_bus() bus1 = get_event_bus() bus2 = get_event_bus() assert bus1 is bus2 def test_reset_event_bus(self): """Test that reset_event_bus creates new instance.""" bus1 = get_event_bus() reset_event_bus() bus2 = get_event_bus() assert bus1 is not bus2 def test_reset_clears_handlers(self): """Test that reset clears all handlers.""" bus = get_event_bus() bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None) assert bus.handler_count() == 1 reset_event_bus() new_bus = get_event_bus() assert new_bus.handler_count() == 0