Files
temp/features/steps/plan_cli_legacy_r2_steps.py
T
Luis Mendes 9e316b1a3e fix(domain): align plan lifecycle model validation with specification
Aligned the plan lifecycle model with the specification:

1. ERRORED is now treated as terminal in is_terminal property,
   matching the spec table where errored is marked "Terminal? Yes"
   for all processing phases.

2. Added per-phase state validation via model_validator: APPLIED
   and CONSTRAINED are only valid in APPLY phase; COMPLETE is only
   valid in STRATEGIZE or EXECUTE phases. Invalid combinations
   now raise ValueError at construction time.

3. Updated ProcessingState.COMPLETE docstring to clarify phase-level
   terminality semantics.

4. Fixed assignment ordering in execute_plan() to set
   processing_state before phase, consistent with the state-first
   pattern used in apply_plan() and _perform_reversion().

5. Added defensive coercion in LifecyclePlanModel.to_domain() to
   handle legacy DB rows with invalid phase/state combinations
   (e.g. APPLY/COMPLETE -> APPLY/APPLIED) with warning-level
   logging for observability.

6. Updated module docstrings: ERRORED description now reflects
   terminal semantics, terminal outcomes location clarified for
   all phases, can_revert_to docstring notes ERRORED/CONSTRAINED
   are terminal but revertable, is_terminal docstring explains
   the distinction between terminal and permanently irrecoverable
   and documents why COMPLETE is not plan-terminal despite the
   spec marking it "Terminal? Yes" (phase-level vs plan-level).

7. Updated PlanResumeService.validate_eligibility() docstring to
   reflect that ERRORED is now terminal but still eligible for
   resume.

8. Added CHANGELOG entry.

ISSUES CLOSED: #918
2026-03-23 23:33:33 +00:00

443 lines
15 KiB
Python

"""Step definitions for plan_cli_legacy_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 3 of 3.
Covers:
- Legacy wrappers: no-project branch, continue_command prompt/no-prompt
- ``_resolve_active_plan_id``: no active plans, service error
- ``build_command``: None changes -> empty list
- ``list_command``: None plans -> empty list
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
import warnings
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import typer
from behave import then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import (
_resolve_active_plan_id,
)
from cleveragents.core.exceptions import CleverAgentsError
from cleveragents.domain.models.core.plan import (
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F"
_runner = CliRunner()
def _ulid(suffix: str = "A1") -> str:
"""Return a valid 26-char ULID for tests.
ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U).
"""
# Map potentially invalid chars to valid Crockford Base32
cleaned = (
suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V")
)
base = _ULID_BASE + cleaned
return base[:26]
def _make_plan(
*,
plan_id: str | None = None,
name: str = "local/r2-plan",
description: str = "Test plan for r2 coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
validation_summary: dict[str, Any] | None = None,
error_message: str | None = None,
last_completed_step: int = -1,
last_checkpoint_id: str | None = None,
definition_of_done: str | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
action_name: str = "local/test-action",
) -> Plan:
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id or _ulid("A1")),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
definition_of_done=definition_of_done,
phase=phase,
processing_state=processing_state,
strategy_actor=None,
execution_actor=None,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
validation_summary=validation_summary,
error_message=error_message,
last_completed_step=last_completed_step,
last_checkpoint_id=last_checkpoint_id,
arguments=arguments or {},
arguments_order=arguments_order or [],
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
created_by=None,
reusable=True,
read_only=False,
)
def _mock_container(project_exists: bool = True) -> MagicMock:
"""Build a mock container for legacy wrapper tests."""
container = MagicMock()
project_service = MagicMock()
plan_service = MagicMock()
if project_exists:
project_service.get_current_project.return_value = SimpleNamespace(name="proj")
else:
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
container.plan_service.return_value = plan_service
return container
# ---------------------------------------------------------------------------
# When steps - legacy programmatic wrappers (no project)
# ---------------------------------------------------------------------------
@when("r2plan-I call tell_command with no project")
def step_tell_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import tell_command
tell_command("do stuff")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call build_command with no project")
def step_build_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import build_command
build_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call apply_command with no project")
def step_apply_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import apply_command
apply_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call new_command with no project")
def step_new_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import new_command
new_command("test")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call current_command with no project")
def step_current_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import current_command
current_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call list_command with no project")
def step_list_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import list_command
list_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call cd_command with no project")
def step_cd_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import cd_command
cd_command("some-plan")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call continue_command with no project")
def step_continue_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import continue_command
continue_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# When steps - continue_command with prompt / no-prompt
# ---------------------------------------------------------------------------
@when('r2plan-I call continue_command with prompt "{prompt}"')
def step_continue_with_prompt(context: Any, prompt: str) -> None:
container = _mock_container(project_exists=True)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import continue_command
continue_command(prompt=prompt)
context.r2_plan_service = container.plan_service()
@when("r2plan-I call continue_command with no prompt and no current plan")
def step_continue_no_prompt_no_plan(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.get_current_plan.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import continue_command
continue_command(prompt=None)
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# When steps - build_command returns None / list_command returns None
# ---------------------------------------------------------------------------
@when("r2plan-I call build_command with build returning None")
def step_build_returns_none(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.build_plan.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import build_command
context.r2_build_result = build_command()
@when("r2plan-I call list_command with list returning None")
def step_list_returns_none(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.list_plans.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import list_command
context.r2_list_result = list_command()
# ---------------------------------------------------------------------------
# When steps - _resolve_active_plan_id
# ---------------------------------------------------------------------------
@when("r2plan-I call _resolve_active_plan_id with no active plans")
def step_resolve_no_active(context: Any) -> None:
mock_svc = MagicMock()
# All plans are terminal
p = _make_plan(phase=PlanPhase.APPLY, processing_state=ProcessingState.APPLIED)
mock_svc.list_plans.return_value = [p]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
@when("r2plan-I call _resolve_active_plan_id with service error")
def step_resolve_service_error(context: Any) -> None:
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
side_effect=CleverAgentsError("service unavailable"),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
# ---------------------------------------------------------------------------
# Then steps - legacy wrapper assertions
# ---------------------------------------------------------------------------
@then('r2plan-a CleverAgentsError should be raised with message "{fragment}"')
def step_agents_error(context: Any, fragment: str) -> None:
assert context.r2_error is not None, "Expected CleverAgentsError but none raised"
assert isinstance(context.r2_error, CleverAgentsError), (
f"Expected CleverAgentsError, got {type(context.r2_error).__name__}"
)
assert fragment in context.r2_error.message, (
f"Expected '{fragment}' in '{context.r2_error.message}'"
)
@then("r2plan-the continue_plan service method should be called")
def step_continue_called(context: Any) -> None:
context.r2_plan_service.continue_plan.assert_called_once()
@then("r2plan-the build result should be an empty list")
def step_build_empty(context: Any) -> None:
assert context.r2_build_result == [], f"Expected [], got {context.r2_build_result}"
@then("r2plan-the list result should be an empty list")
def step_list_empty(context: Any) -> None:
assert context.r2_list_result == [], f"Expected [], got {context.r2_list_result}"
@then("r2plan-a typer Abort should be raised")
def step_typer_abort(context: Any) -> None:
assert context.r2_error is not None, "Expected typer.Abort but none raised"
assert isinstance(context.r2_error, typer.Abort), (
f"Expected typer.Abort, got {type(context.r2_error).__name__}"
)