forked from HAL9000/cleveragents-core
3a2b134f3c
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
449 lines
16 KiB
Python
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()
|