7348ca1911
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 16s
CI / security (pull_request) Successful in 30s
CI / typecheck (pull_request) Successful in 47s
CI / unit_tests (pull_request) Successful in 2m13s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 3m3s
CI / coverage (pull_request) Successful in 3m58s
CI / benchmark-regression (pull_request) Successful in 24m40s
- Update event_bus_steps.py to call record_decision() with individual parameters (plan_id, decision_type, question, chosen_option, rationale) instead of passing a Decision object, matching the master branch API - Remove ctx.test_decision construction from DecisionService setup steps as it is no longer needed with the new signature - Update robot/helper_event_bus.py decision_service_emits_event() to use the new record_decision() parameter signature - All 9 Robot Framework event bus tests passing - Behave scenarios at lines 125 and 131 now passing Fixes event bus test failures after master merge.
480 lines
16 KiB
Python
480 lines
16 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 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"
|