"""Step definitions for event_bus.feature. Tests EventType, DomainEvent, EventBus protocol, ReactiveEventBus, LoggingEventBus, service event emission, and DI container registration. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from ulid import ULID from cleveragents.application.container import Container from cleveragents.application.services.decision_service import DecisionService from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) from cleveragents.domain.models.core.decision import DecisionType from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState from cleveragents.infrastructure.events import ( DomainEvent, EventBus, EventType, LoggingEventBus, ReactiveEventBus, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_event(event_type_str: str, **kwargs: Any) -> DomainEvent: return DomainEvent(event_type=EventType(event_type_str), **kwargs) class _EventCollector: """Simple in-test stub that collects emitted events.""" def __init__(self) -> None: self.received: list[DomainEvent] = [] def __call__(self, event: DomainEvent) -> None: self.received.append(event) # --------------------------------------------------------------------------- # EventType enum steps # --------------------------------------------------------------------------- @then('EventType should include "{value}"') def step_event_type_includes(ctx: Context, value: str) -> None: values = {e.value for e in EventType} assert value in values, f"{value!r} not found in EventType values: {values}" @then('EventType member {member} should equal string "{value}"') def step_event_type_member_equals(ctx: Context, member: str, value: str) -> None: et = EventType[member] assert et == value, f"EventType.{member} = {et!r}, expected {value!r}" # --------------------------------------------------------------------------- # DomainEvent steps # --------------------------------------------------------------------------- @when('I create a minimal DomainEvent with event_type "{et}"') def step_create_domain_event(ctx: Context, et: str) -> None: ctx.domain_event = _make_event(et) @when( 'I create a DomainEvent with event_type "{et}" plan_id "{pid}"' ' and details key "{key}" value "{val}"' ) def step_create_domain_event_with_details( ctx: Context, et: str, pid: str, key: str, val: str ) -> None: ctx.domain_event = _make_event(et, plan_id=pid, details={key: val}) @then('the DomainEvent event_type should be "{et}"') def step_domain_event_type(ctx: Context, et: str) -> None: assert str(ctx.domain_event.event_type) == et @then("the DomainEvent should have a non-empty correlation_id") def step_domain_event_correlation_id(ctx: Context) -> None: cid = ctx.domain_event.correlation_id assert cid and len(cid) == 26, f"Expected 26-char ULID, got {cid!r}" @then("the DomainEvent should have a timestamp") def step_domain_event_timestamp(ctx: Context) -> None: assert ctx.domain_event.timestamp is not None @then("the DomainEvent plan_id should be None") def step_domain_event_plan_id_none(ctx: Context) -> None: assert ctx.domain_event.plan_id is None @then("the DomainEvent actor_name should be None") def step_domain_event_actor_name_none(ctx: Context) -> None: assert ctx.domain_event.actor_name is None @then("the DomainEvent details should be empty") def step_domain_event_details_empty(ctx: Context) -> None: assert ctx.domain_event.details == {} @then('the DomainEvent plan_id should be "{pid}"') def step_domain_event_plan_id(ctx: Context, pid: str) -> None: assert ctx.domain_event.plan_id == pid @then('the DomainEvent details should contain key "{key}"') def step_domain_event_details_key(ctx: Context, key: str) -> None: assert key in ctx.domain_event.details @then("modifying the DomainEvent should raise an error") def step_domain_event_immutable(ctx: Context) -> None: raised = False try: ctx.domain_event.event_type = EventType.PLAN_CANCELLED # type: ignore[misc] except Exception: raised = True assert raised, "Expected an exception when mutating a frozen DomainEvent" # --------------------------------------------------------------------------- # Protocol conformance # --------------------------------------------------------------------------- @then("ReactiveEventBus should satisfy the EventBus protocol") def step_reactive_satisfies_protocol(ctx: Context) -> None: bus = ReactiveEventBus() assert isinstance(bus, EventBus), "ReactiveEventBus does not satisfy EventBus" @then("LoggingEventBus should satisfy the EventBus protocol") def step_logging_satisfies_protocol(ctx: Context) -> None: bus = LoggingEventBus() assert isinstance(bus, EventBus), "LoggingEventBus does not satisfy EventBus" # --------------------------------------------------------------------------- # ReactiveEventBus given # --------------------------------------------------------------------------- @given("a ReactiveEventBus") def step_given_reactive_bus(ctx: Context) -> None: ctx.bus = ReactiveEventBus() ctx.collectors: list[_EventCollector] = [] @given("a LoggingEventBus") def step_given_logging_bus(ctx: Context) -> None: ctx.bus = LoggingEventBus() ctx.collectors = [] # --------------------------------------------------------------------------- # ReactiveEventBus / LoggingEventBus when # --------------------------------------------------------------------------- @when('I subscribe to "{et}" events') def step_subscribe_single(ctx: Context, et: str) -> None: collector = _EventCollector() ctx.collectors.append(collector) ctx.bus.subscribe(EventType(et), collector) @when('I subscribe two handlers to "{et}" events') def step_subscribe_two(ctx: Context, et: str) -> None: for _ in range(2): collector = _EventCollector() ctx.collectors.append(collector) ctx.bus.subscribe(EventType(et), collector) @when('I emit a "{et}" DomainEvent') def step_emit_event(ctx: Context, et: str) -> None: ctx.bus.emit(_make_event(et)) # --------------------------------------------------------------------------- # ReactiveEventBus then # --------------------------------------------------------------------------- @then("the handler should have received {n:d} event") def step_handler_received_n(ctx: Context, n: int) -> None: assert ctx.collectors, "No collectors registered" count = len(ctx.collectors[0].received) assert count == n, f"Expected {n} events, got {count}" @then('the received event type should be "{et}"') def step_received_event_type(ctx: Context, et: str) -> None: assert ctx.collectors[0].received, "No events received" assert str(ctx.collectors[0].received[0].event_type) == et @then("each handler should have received {n:d} event") def step_each_handler_received(ctx: Context, n: int) -> None: for i, col in enumerate(ctx.collectors): assert len(col.received) == n, ( f"Handler {i} received {len(col.received)}, expected {n}" ) @then("the bus should expose an observable stream") def step_bus_has_stream(ctx: Context) -> None: stream = ctx.bus.stream assert stream is not None, "Expected a non-None observable stream" @then("emitting a non-DomainEvent should raise TypeError") def step_emit_non_domain_event(ctx: Context) -> None: raised = False try: ctx.bus.emit("not-an-event") # type: ignore[arg-type] except TypeError: raised = True assert raised, "Expected TypeError for non-DomainEvent" @then("subscribing with a non-EventType should raise TypeError") def step_subscribe_bad_type(ctx: Context) -> None: raised = False try: ctx.bus.subscribe("plan.created", lambda e: None) # type: ignore[arg-type] except TypeError: raised = True assert raised, "Expected TypeError for non-EventType event_type" @then("subscribing with a non-callable handler should raise TypeError") def step_subscribe_non_callable(ctx: Context) -> None: raised = False try: ctx.bus.subscribe(EventType.PLAN_CREATED, "not-callable") # type: ignore[arg-type] except TypeError: raised = True assert raised, "Expected TypeError for non-callable handler" @then("the EventBus Protocol stubs should be callable") def step_protocol_stubs_callable(ctx: Context) -> None: """Exercise Protocol method stubs directly to ensure 100% coverage.""" class _BareImpl(EventBus): # type: ignore[misc] pass obj = _BareImpl() event = _make_event("plan.created") obj.emit(event) obj.subscribe(EventType.PLAN_CREATED, lambda e: None) # --------------------------------------------------------------------------- # DecisionService event emission steps # --------------------------------------------------------------------------- @given("a DecisionService with an injected event bus") def step_given_decision_service_with_bus(ctx: Context) -> None: from unittest.mock import MagicMock, create_autospec from cleveragents.config.settings import Settings from cleveragents.infrastructure.database.unit_of_work import UnitOfWork ctx.all_events: list[DomainEvent] = [] ctx.event_bus = ReactiveEventBus() ctx.event_bus.subscribe(EventType.DECISION_CREATED, ctx.all_events.append) mock_uow = create_autospec(UnitOfWork, instance=True) mock_tx_ctx = MagicMock() mock_tx_ctx.decisions = MagicMock() mock_uow.transaction.return_value.__enter__.return_value = mock_tx_ctx mock_uow.transaction.return_value.__exit__.return_value = False ctx.decision_service = DecisionService( settings=create_autospec(Settings, instance=True), unit_of_work=mock_uow, event_bus=ctx.event_bus, ) ctx.test_plan_id = str(ULID()) @given("a DecisionService without an event bus") def step_given_decision_service_no_bus(ctx: Context) -> None: from unittest.mock import MagicMock, create_autospec from cleveragents.config.settings import Settings from cleveragents.infrastructure.database.unit_of_work import UnitOfWork mock_uow = create_autospec(UnitOfWork, instance=True) mock_tx_ctx = MagicMock() mock_tx_ctx.decisions = MagicMock() mock_uow.transaction.return_value.__enter__.return_value = mock_tx_ctx mock_uow.transaction.return_value.__exit__.return_value = False ctx.decision_service = DecisionService( settings=create_autospec(Settings, instance=True), unit_of_work=mock_uow, ) ctx.test_plan_id = str(ULID()) @when("I call record_decision") def step_call_record_decision(ctx: Context) -> None: ctx.exception = None try: ctx.decision_service.record_decision( plan_id=ctx.test_plan_id, decision_type=DecisionType.STRATEGY_CHOICE, question="Which approach?", chosen_option="TDD", rationale="Test-first", ) except Exception as exc: ctx.exception = exc @then('the event bus should have received a "{et}" event') def step_event_bus_received(ctx: Context, et: str) -> None: all_events: list[DomainEvent] = getattr(ctx, "all_events", []) received_types = [str(e.event_type) for e in all_events] assert et in received_types, ( f"Expected {et!r} in received events, got {received_types}" ) @then("the emitted event plan_id should match the decision plan_id") def step_emitted_plan_id_matches(ctx: Context) -> None: all_events: list[DomainEvent] = getattr(ctx, "all_events", []) assert all_events, "No events received" emitted_plan_id = all_events[0].plan_id assert emitted_plan_id == ctx.test_plan_id, ( f"Expected plan_id {ctx.test_plan_id!r}, got {emitted_plan_id!r}" ) @then("no event bus exception should be raised") def step_no_event_bus_exception(ctx: Context) -> None: assert ctx.exception is None, f"Unexpected exception: {ctx.exception}" # --------------------------------------------------------------------------- # PlanLifecycleService event emission steps # --------------------------------------------------------------------------- def _make_settings_stub() -> MagicMock: from unittest.mock import create_autospec from cleveragents.config.settings import Settings return create_autospec(Settings, instance=True) @given("a PlanLifecycleService with an injected event bus") def step_given_lifecycle_service_with_bus(ctx: Context) -> None: ctx.all_events: list[DomainEvent] = [] ctx.event_bus = ReactiveEventBus() ctx.event_bus.subscribe(EventType.PLAN_CREATED, ctx.all_events.append) ctx.lifecycle_service = PlanLifecycleService( settings=_make_settings_stub(), event_bus=ctx.event_bus, ) ctx.exception = None @given("a PlanLifecycleService with an injected event bus and a strategized plan") def step_given_lifecycle_with_strategized_plan(ctx: Context) -> None: ctx.all_events: list[DomainEvent] = [] ctx.event_bus = ReactiveEventBus() ctx.event_bus.subscribe(EventType.PLAN_PHASE_CHANGED, ctx.all_events.append) ctx.lifecycle_service = PlanLifecycleService( settings=_make_settings_stub(), event_bus=ctx.event_bus, ) ctx.exception = None ctx.lifecycle_service.create_action( name="test/action", description="benchmark action", definition_of_done="Done", strategy_actor="local/strategist", execution_actor="local/executor", ) plan = ctx.lifecycle_service.use_action(action_name="test/action") plan_id = plan.identity.plan_id ctx.lifecycle_service._plans[plan_id] = plan.model_copy( update={ "phase": PlanPhase.STRATEGIZE, "processing_state": ProcessingState.COMPLETE, } ) ctx.test_plan_id = plan_id @given("a PlanLifecycleService without an event bus") def step_given_lifecycle_service_no_bus(ctx: Context) -> None: ctx.lifecycle_service = PlanLifecycleService( settings=_make_settings_stub(), ) ctx.exception = None @when("I call use_action to create a plan") def step_call_use_action(ctx: Context) -> None: try: ctx.lifecycle_service.create_action( name="test/action", description="test action", definition_of_done="Done when implemented", strategy_actor="local/strategist", execution_actor="local/executor", ) ctx.lifecycle_service.use_action(action_name="test/action") except Exception as exc: ctx.exception = exc @when("I call execute_plan") def step_call_execute_plan(ctx: Context) -> None: try: ctx.lifecycle_service.execute_plan(ctx.test_plan_id) except Exception as exc: ctx.exception = exc # --------------------------------------------------------------------------- # DI container steps # --------------------------------------------------------------------------- @when("I resolve event_bus from the DI container twice") def step_resolve_event_bus_twice(ctx: Context) -> None: container = Container() ctx.event_bus_a = container.event_bus() ctx.event_bus_b = container.event_bus() @then("both resolutions should return the same instance") def step_same_instance(ctx: Context) -> None: assert ctx.event_bus_a is ctx.event_bus_b, ( "event_bus Singleton returned different instances" ) @when("I resolve decision_service from the DI container") def step_resolve_decision_service(ctx: Context) -> None: container = Container() ctx.resolved_decision_service = container.decision_service() @then("the decision_service should have an event_bus attribute") def step_decision_service_has_event_bus(ctx: Context) -> None: svc = ctx.resolved_decision_service assert hasattr(svc, "event_bus"), ( "DecisionService resolved from DI has no event_bus attribute" ) assert svc.event_bus is not None, "DecisionService.event_bus should not be None"