Files
temp/features/steps/plan_lifecycle_error_r2_steps.py
freemo 3a2b134f3c test(coverage): add Behave scenarios for remaining under-tested modules
Added Behave BDD feature files and step definitions targeting coverage
gaps in six modules:

- container.py: exercise get_database_url env-var fallback, AI provider
  None path, cached container singleton, override_providers edge cases
  (lines 66-69, 125-130; branches at 51, 57, 82, 87, 256, 284-288)
- correction_service.py: exercise exception-handling paths in
  execute_revert and execute_append via monkeypatched analyze_impact
  and ULID failures (lines 254-262, 320-328)
- plan_lifecycle_service.py: exercise _persisted UoW commit paths,
  InvalidPhaseTransitionError custom message branch, non-reusable
  action archive, and error_details merge logic (branches at 100,
  216, 237, 327, 461, 570, 576, 607)
- plan.py (CLI): exercise spec-dict optional field branches,
  _print_lifecycle_plan conditional rendering, use_action argument
  parsing, auto-resolve paths, legacy wrappers, and validation
  error branches across 66 scenarios
- skill.py (CLI): exercise singleton cache, timestamp-absent show,
  no-tools MCP, add/remove/list/show format and error branches
  across 26 scenarios
- models.py (DB): exercise to_domain/from_domain None-field branches
  in SkillModel, SessionModel, ToolModel, LifecycleActionModel,
  LifecyclePlanModel, NamespacedProjectModel, and SessionMessageModel
  across 41 scenarios

All 302 features, 6503 scenarios, 28271 steps pass (nox -e unit_tests).

ISSUES CLOSED: #446
2026-02-25 19:38:43 -05:00

449 lines
16 KiB
Python

"""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()