"""Step definitions for plan_lifecycle_error_r2.feature. Targets partial branches in ``plan_lifecycle_service.py`` that are only exercised in one direction (True-only or False-only): * **Line 100** — ``InvalidPhaseTransitionError.__init__``: ``if not message:`` False branch (custom message provided). * **Line 216** — ``_commit_plan``: ``if self._persisted and self.unit_of_work is not None:`` True branch. * **Line 327** — ``create_action``: ``if self._persisted …`` True branch. * **Line 364** — ``get_action`` persistence fallback: True branch (action not in memory, persistence also returns ``None``). * **Line 461** — ``archive_action``: ``if self._persisted …`` True branch. * **Line 570** — ``use_action``: ``if self._persisted …`` True branch. * **Line 607** — ``get_plan`` persistence fallback: True branch (plan not in memory, persistence also returns ``None``). All step text uses the ``r2plc-`` prefix to avoid collisions with existing step definitions. This file also contains shared steps used by both error and transition feature files (Behave discovers all steps globally from the steps/ dir). """ from __future__ import annotations from collections.abc import Generator from contextlib import contextmanager from typing import Any from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.application.services.plan_lifecycle_service import ( InvalidPhaseTransitionError, PlanLifecycleService, ) from cleveragents.core.exceptions import NotFoundError from cleveragents.domain.models.core.action import ActionState from cleveragents.domain.models.core.plan import ( PlanPhase, ProjectLink, ) # ------------------------------------------------------------------- # Mock UoW builder # ------------------------------------------------------------------- def _build_mock_uow() -> tuple[MagicMock, MagicMock]: """Build a mock UnitOfWork and return (uow, shared_ctx). The shared ``ctx`` mock is reused across all ``transaction()`` calls so that assertions can inspect cumulative interactions. """ mock_uow = MagicMock() mock_ctx = MagicMock() # Make persistence lookups return None by default (not found) mock_ctx.actions.get_by_name.return_value = None mock_ctx.lifecycle_plans.get.return_value = None @contextmanager def _transaction() -> Generator[MagicMock]: yield mock_ctx mock_uow.transaction = _transaction return mock_uow, mock_ctx # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- def _create_action(context: Context, name: str, **kwargs: Any) -> Any: """Create an action through the service with sensible defaults.""" defaults: dict[str, Any] = { "name": name, "description": f"R2 test action {name}", "definition_of_done": "Tests pass", "strategy_actor": "openai/gpt-4", "execution_actor": "openai/gpt-4", } defaults.update(kwargs) return context.r2_service.create_action(**defaults) # ------------------------------------------------------------------- # Background # ------------------------------------------------------------------- @given("r2plc-a fresh plan lifecycle service with mock UoW") def step_r2_bg(context: Context) -> None: """Create a PlanLifecycleService backed by a mock UoW.""" settings = MagicMock() mock_uow, mock_ctx = _build_mock_uow() context.r2_uow = mock_uow context.r2_ctx = mock_ctx context.r2_service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow) context.r2_plan = None context.r2_error = None context.r2_action = None # =================================================================== # InvalidPhaseTransitionError with custom message (line 100) # =================================================================== @when("r2plc-I construct InvalidPhaseTransitionError with a custom message") def step_r2_construct_with_message(context: Context) -> None: """Directly construct the exception with a custom message.""" context.r2_error = InvalidPhaseTransitionError( from_phase=PlanPhase.STRATEGIZE, to_phase=PlanPhase.APPLY, message="Custom: cannot go there", ) @when("r2plc-I construct InvalidPhaseTransitionError without a message") def step_r2_construct_without_message(context: Context) -> None: """Construct the exception without a message (default path).""" context.r2_error = InvalidPhaseTransitionError( from_phase=PlanPhase.STRATEGIZE, to_phase=PlanPhase.EXECUTE, ) @then("r2plc-the error message should be the custom message") def step_r2_check_custom_message(context: Context) -> None: assert str(context.r2_error) == "Custom: cannot go there", ( f"Expected custom message, got: {context.r2_error}" ) @then('r2plc-the error message should contain "Invalid phase transition"') def step_r2_check_default_message(context: Context) -> None: assert "Invalid phase transition" in str(context.r2_error), ( f"Expected default message, got: {context.r2_error}" ) @then("r2plc-the from_phase should be STRATEGIZE") def step_r2_check_from_strategize(context: Context) -> None: err = context.r2_error assert isinstance(err, InvalidPhaseTransitionError) assert err.from_phase == PlanPhase.STRATEGIZE @then("r2plc-the to_phase should be APPLY") def step_r2_check_to_apply(context: Context) -> None: err = context.r2_error assert isinstance(err, InvalidPhaseTransitionError) assert err.to_phase == PlanPhase.APPLY @then("r2plc-the to_phase should be EXECUTE") def step_r2_check_to_execute(context: Context) -> None: err = context.r2_error assert isinstance(err, InvalidPhaseTransitionError) assert err.to_phase == PlanPhase.EXECUTE # =================================================================== # revert_plan with InvalidPhaseTransitionError (line 100 + 1245-1250) # =================================================================== @given("r2plc-a plan in STRATEGIZE phase") def step_r2_plan_in_strategize(context: Context) -> None: """Create an action and use it to produce a plan in STRATEGIZE/QUEUED.""" # Reset the mock call tracking so we can check calls per-scenario context.r2_ctx.reset_mock() action = _create_action(context, f"local/r2-strat-{id(context)}") plan = context.r2_service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-r2")], ) context.r2_plan = plan # Reset again after setup so assertions only see scenario-specific calls context.r2_ctx.reset_mock() @when("r2plc-I attempt to revert the plan to APPLY phase") def step_r2_revert_to_apply(context: Context) -> None: """Attempt to revert from STRATEGIZE to APPLY (invalid transition).""" try: context.r2_service.revert_plan( context.r2_plan.identity.plan_id, to_phase=PlanPhase.APPLY, reason="testing invalid revert", ) context.r2_error = None except InvalidPhaseTransitionError as exc: context.r2_error = exc @then("r2plc-an InvalidPhaseTransitionError should have been raised") def step_r2_check_invalid_transition(context: Context) -> None: assert context.r2_error is not None, ( "Expected InvalidPhaseTransitionError but none raised" ) assert isinstance(context.r2_error, InvalidPhaseTransitionError), ( f"Expected InvalidPhaseTransitionError, got {type(context.r2_error).__name__}" ) @then('r2plc-the caught error message should contain "Cannot revert"') def step_r2_check_revert_message(context: Context) -> None: assert "Cannot revert" in str(context.r2_error), ( f"Expected 'Cannot revert' in message, got: {context.r2_error}" ) # =================================================================== # _commit_plan in persisted mode (line 216 True) # =================================================================== @when("r2plc-I start strategize on the plan") def step_r2_start_strategize(context: Context) -> None: """Start strategize — calls _commit_plan internally.""" context.r2_plan = context.r2_service.start_strategize( context.r2_plan.identity.plan_id ) @then("r2plc-the mock UoW should have received a plan update call") def step_r2_check_plan_update(context: Context) -> None: """Verify the mock ctx received at least one lifecycle_plans.update call.""" assert context.r2_ctx.lifecycle_plans.update.called, ( "Expected lifecycle_plans.update() to be called on mock ctx, " f"but it was not. Calls: {context.r2_ctx.mock_calls}" ) # =================================================================== # fail_strategize in persisted mode (line 216 True) # =================================================================== @given("r2plc-a plan in STRATEGIZE PROCESSING state") def step_r2_plan_strategize_processing(context: Context) -> None: """Create a plan and advance it to STRATEGIZE/PROCESSING.""" context.r2_ctx.reset_mock() action = _create_action(context, f"local/r2-sp-{id(context)}") plan = context.r2_service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-r2")], ) context.r2_service.start_strategize(plan.identity.plan_id) context.r2_plan = context.r2_service.get_plan(plan.identity.plan_id) context.r2_ctx.reset_mock() @when('r2plc-I fail the strategize with error "{msg}"') def step_r2_fail_strategize(context: Context, msg: str) -> None: context.r2_plan = context.r2_service.fail_strategize( context.r2_plan.identity.plan_id, msg ) @then('r2plc-the plan processing state should be "{state}"') def step_r2_check_processing_state(context: Context, state: str) -> None: actual = context.r2_plan.processing_state.value assert actual == state, f"Expected '{state}', got '{actual}'" # =================================================================== # create_action persisted mode (line 327 True) # =================================================================== @when('r2plc-I create an action "{name}" in persisted mode') def step_r2_create_action_persisted(context: Context, name: str) -> None: context.r2_ctx.reset_mock() context.r2_action = _create_action(context, name) @then("r2plc-the mock UoW should have received an action create call") def step_r2_check_action_create(context: Context) -> None: assert context.r2_ctx.actions.create.called, ( "Expected actions.create() to be called on mock ctx, " f"but it was not. Calls: {context.r2_ctx.mock_calls}" ) @then("r2plc-the action should also be in the in-memory cache") def step_r2_check_action_in_cache(context: Context) -> None: name = str(context.r2_action.namespaced_name) assert name in context.r2_service._actions, ( f"Expected '{name}' in _actions cache, " f"got keys: {list(context.r2_service._actions.keys())}" ) # =================================================================== # use_action persisted mode (line 570 True) # =================================================================== @given('r2plc-an action "{name}" exists') def step_r2_action_exists(context: Context, name: str) -> None: context.r2_ctx.reset_mock() _create_action(context, name) context.r2_ctx.reset_mock() @when("r2plc-I use the action to create a plan in persisted mode") def step_r2_use_action_persisted(context: Context) -> None: context.r2_ctx.reset_mock() action_name = "local/r2-use-persist" context.r2_plan = context.r2_service.use_action( action_name=action_name, project_links=[ProjectLink(project_name="proj-r2-use")], ) @then("r2plc-the mock UoW should have received a plan create call") def step_r2_check_plan_create(context: Context) -> None: assert context.r2_ctx.lifecycle_plans.create.called, ( "Expected lifecycle_plans.create() to be called on mock ctx, " f"but it was not. Calls: {context.r2_ctx.mock_calls}" ) @then("r2plc-the plan should also be in the in-memory plan cache") def step_r2_check_plan_in_cache(context: Context) -> None: plan_id = context.r2_plan.identity.plan_id assert plan_id in context.r2_service._plans, ( f"Expected plan '{plan_id}' in _plans cache" ) # =================================================================== # archive_action persisted mode (line 461 True) # =================================================================== @when('r2plc-I archive the action "{name}" in persisted mode') def step_r2_archive_action_persisted(context: Context, name: str) -> None: context.r2_ctx.reset_mock() context.r2_action = context.r2_service.archive_action(name) @then("r2plc-the mock UoW should have received an action update call") def step_r2_check_action_update(context: Context) -> None: assert context.r2_ctx.actions.update.called, ( "Expected actions.update() to be called on mock ctx, " f"but it was not. Calls: {context.r2_ctx.mock_calls}" ) @then("r2plc-the action state should be archived") def step_r2_check_archived_state(context: Context) -> None: assert context.r2_action.state == ActionState.ARCHIVED, ( f"Expected ARCHIVED, got {context.r2_action.state}" ) # =================================================================== # get_action persistence fallback → NotFoundError (line 364-370) # =================================================================== @when('r2plc-I attempt to get action "{name}" in persisted mode') def step_r2_get_action_not_found(context: Context, name: str) -> None: """Call get_action for an action not in memory or persistence.""" try: context.r2_service.get_action(name) context.r2_error = None except NotFoundError as exc: context.r2_error = exc @then("r2plc-a NotFoundError should have been raised for action") def step_r2_check_action_not_found(context: Context) -> None: assert context.r2_error is not None, "Expected NotFoundError but none raised" assert isinstance(context.r2_error, NotFoundError), ( f"Expected NotFoundError, got {type(context.r2_error).__name__}" ) # =================================================================== # get_plan persistence fallback → NotFoundError (line 607-613) # =================================================================== @when('r2plc-I attempt to get plan "{plan_id}" in persisted mode') def step_r2_get_plan_not_found(context: Context, plan_id: str) -> None: """Call get_plan for a plan not in memory or persistence.""" try: context.r2_service.get_plan(plan_id) context.r2_error = None except NotFoundError as exc: context.r2_error = exc @then("r2plc-a NotFoundError should have been raised for plan") def step_r2_check_plan_not_found(context: Context) -> None: assert context.r2_error is not None, "Expected NotFoundError but none raised" assert isinstance(context.r2_error, NotFoundError), ( f"Expected NotFoundError, got {type(context.r2_error).__name__}" ) # =================================================================== # update_error_details in persisted mode (line 216 True) # =================================================================== @when('r2plc-I update error details with key "{key}" value "{value}"') def step_r2_update_error_details(context: Context, key: str, value: str) -> None: context.r2_ctx.reset_mock() context.r2_service.update_error_details( context.r2_plan.identity.plan_id, {key: value}, ) # Refresh the plan reference context.r2_plan = context.r2_service.get_plan(context.r2_plan.identity.plan_id) @then('r2plc-the plan error_details should contain key "{key}"') def step_r2_check_error_details_key(context: Context, key: str) -> None: details = context.r2_plan.error_details assert details is not None, "error_details is None" assert key in details, f"Expected key '{key}' in error_details, got: {details}" @given("r2plc-a plan in STRATEGIZE phase with existing error_details") def step_r2_plan_with_error_details(context: Context) -> None: """Create a plan and manually set error_details.""" context.r2_ctx.reset_mock() action = _create_action(context, f"local/r2-errdet-{id(context)}") plan = context.r2_service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-r2-err")], ) # Manually set existing error_details plan.error_details = {"original": "existing_value"} context.r2_plan = plan context.r2_ctx.reset_mock()