d3a74aa86c
Replace all # type: ignore[arg-type] and # type: ignore[misc] comments with pyright-compatible alternatives: typing.cast() for intentional type mismatches in error-handling tests, and explicit stub method bodies for Protocol subclasses.
574 lines
19 KiB
Python
574 lines
19 KiB
Python
"""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 collections.abc import Callable
|
|
from typing import Any, cast
|
|
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 unsubscribe from "{et}" with the same handler')
|
|
def step_unsubscribe_handler(ctx: Context, et: str) -> None:
|
|
collector = ctx.collectors[0]
|
|
ctx.unsubscribe_result = ctx.bus.unsubscribe(EventType(et), collector)
|
|
|
|
|
|
@when('I unsubscribe only the first handler from "{et}"')
|
|
def step_unsubscribe_first_handler(ctx: Context, et: str) -> None:
|
|
collector = ctx.collectors[0]
|
|
ctx.bus.unsubscribe(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("exactly one handler should have received 1 event")
|
|
def step_exactly_one_handler_received(ctx: Context) -> None:
|
|
counts = [len(col.received) for col in ctx.collectors]
|
|
assert sum(1 for c in counts if c == 1) == 1, (
|
|
f"Expected exactly one handler with 1 event, got {counts}"
|
|
)
|
|
|
|
|
|
@then('the handler count for "{et}" should be 0')
|
|
def step_handler_count_zero(ctx: Context, et: str) -> None:
|
|
actual = len(ctx.bus._subscriptions.get(EventType(et), []))
|
|
assert actual == 0, f"Expected 0 handlers for {et}, got {actual}"
|
|
|
|
|
|
@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(cast(DomainEvent, "not-an-event"))
|
|
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(cast(EventType, "plan.created"), lambda e: None)
|
|
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, cast(Callable[[DomainEvent], None], "not-callable")
|
|
)
|
|
except TypeError:
|
|
raised = True
|
|
assert raised, "Expected TypeError for non-callable handler"
|
|
|
|
|
|
@then("unsubscribing should return True")
|
|
def step_unsubscribe_returns_true(ctx: Context) -> None:
|
|
result: bool = getattr(ctx, "unsubscribe_result", False)
|
|
assert result is True, f"Expected unsubscribe to return True, got {result}"
|
|
|
|
|
|
@then("unsubscribing a non-existent handler should return False")
|
|
def step_unsubscribe_nonexistent_returns_false(ctx: Context) -> None:
|
|
ctx.exception = None
|
|
try:
|
|
ctx.unsubscribe_result = ctx.bus.unsubscribe(
|
|
EventType.PLAN_CREATED, lambda e: None
|
|
)
|
|
except Exception as exc:
|
|
ctx.exception = exc
|
|
assert ctx.unsubscribe_result is False
|
|
|
|
|
|
@then("unsubscribing with a non-EventType should raise TypeError")
|
|
def step_unsubscribe_bad_type(ctx: Context) -> None:
|
|
raised = False
|
|
try:
|
|
ctx.bus.unsubscribe(cast(EventType, "plan.created"), lambda e: None)
|
|
except TypeError:
|
|
raised = True
|
|
assert raised, "Expected TypeError for non-EventType event_type in unsubscribe"
|
|
|
|
|
|
@then("unsubscribing with a non-callable handler should raise TypeError")
|
|
def step_unsubscribe_non_callable(ctx: Context) -> None:
|
|
raised = False
|
|
try:
|
|
ctx.bus.unsubscribe(
|
|
EventType.PLAN_CREATED, cast(Callable[[DomainEvent], None], "not-callable")
|
|
)
|
|
except TypeError:
|
|
raised = True
|
|
assert raised, "Expected TypeError for non-callable handler in unsubscribe"
|
|
|
|
|
|
@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):
|
|
def emit(self, event: DomainEvent) -> None: ...
|
|
def subscribe(
|
|
self, event_type: EventType, handler: Callable[[DomainEvent], None]
|
|
) -> None: ...
|
|
def unsubscribe(
|
|
self, event_type: EventType, handler: Callable[[DomainEvent], None]
|
|
) -> bool:
|
|
return True
|
|
|
|
obj = _BareImpl()
|
|
event = _make_event("plan.created")
|
|
obj.emit(event)
|
|
obj.subscribe(EventType.PLAN_CREATED, lambda e: None)
|
|
|
|
|
|
@then("the EventBus Protocol unsubscribe stub should be callable")
|
|
def step_protocol_unsubscribe_callable(ctx: Context) -> None:
|
|
"""Exercise Protocol unsubscribe stub directly to ensure coverage."""
|
|
|
|
class _BareImpl(EventBus):
|
|
def emit(self, event: DomainEvent) -> None: ...
|
|
def subscribe(
|
|
self, event_type: EventType, handler: Callable[[DomainEvent], None]
|
|
) -> None: ...
|
|
def unsubscribe(
|
|
self, event_type: EventType, handler: Callable[[DomainEvent], None]
|
|
) -> bool:
|
|
return True
|
|
|
|
obj = _BareImpl()
|
|
obj.unsubscribe(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"
|