Files
placeholder/features/steps/plan_cli_cancel_revert_coverage_steps.py
freemo 48cff5cfe0 refactor(cli): rename plan lifecycle-list and lifecycle-apply to match specification
Renames `plan lifecycle-list` to `plan list` and `plan lifecycle-apply` to `plan apply` to align with the specification's canonical command names. Removes legacy V2 plan commands that occupied those names.

- Renamed CLI command registrations from lifecycle-list/lifecycle-apply to list/apply
- Removed legacy V2 apply and list commands (~200 lines)
- Updated apply shortcut in main.py to delegate to v3 lifecycle
- Added defensive null check for plan existence in apply command
- Updated 63+ test, doc, and benchmark files for consistency

Closes #881

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
2026-04-02 19:09:04 +00:00

360 lines
12 KiB
Python

"""Step definitions for plan_cli_cancel_revert_coverage.feature.
Covers uncovered lines in cleveragents/cli/commands/plan.py:
- Line 1817: timestamps.created_at.strftime in list_plans rich table
- Lines 1821-1822: console.print(table) and CleverAgentsError catch
- Lines 1845-1855: cancel_plan command body
- Lines 1878-1888: revert_plan command body
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# Valid ULIDs for test plans
_ULID_CANCEL = "01ARZ3NDEKTSV4RRFFQ69G5FB1"
_ULID_REVERT = "01ARZ3NDEKTSV4RRFFQ69G5FB2"
_ULID_LIST_1 = "01ARZ3NDEKTSV4RRFFQ69G5FB3"
_ULID_LIST_2 = "01ARZ3NDEKTSV4RRFFQ69G5FB4"
def _make_plan(
*,
plan_id: str = _ULID_CANCEL,
name: str = "local/test-plan",
description: str = "Test plan for cancel-revert 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,
timestamps: PlanTimestamps | None = None,
reversion_count: int = 0,
action_name: str = "local/test-action",
) -> Plan:
"""Build a real Plan object for testing."""
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime(2025, 6, 15, 10, 30, 0),
updated_at=datetime(2025, 6, 15, 11, 0, 0),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
timestamps=timestamps,
reversion_count=reversion_count,
reusable=True,
read_only=False,
)
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("a CLI runner for cancel-revert coverage")
def step_cli_runner(context):
context.runner = CliRunner()
@given("a mocked lifecycle service for cancel-revert coverage")
def step_mocked_lifecycle_service(context):
context.mock_service = MagicMock()
patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_service,
)
patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(patcher.stop)
# ---------------------------------------------------------------------------
# Given steps — list rich format
# ---------------------------------------------------------------------------
@given("the service returns plans with project links and timestamps for rich list")
def step_service_plans_with_links_and_timestamps(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/plan-with-links",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-alpha"),
ProjectLink(project_name="proj-beta"),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 3, 10, 14, 25, 0),
updated_at=datetime(2025, 3, 10, 15, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
@given("the service returns plans with an automation profile for rich list")
def step_service_plans_with_profile(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/profiled-plan",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
project_links=[ProjectLink(project_name="proj-one")],
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.ACTION,
),
timestamps=PlanTimestamps(
created_at=datetime(2025, 4, 1, 9, 0, 0),
updated_at=datetime(2025, 4, 1, 10, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
@given("the service returns a plan with three project links for rich list")
def step_service_plan_three_links(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/multi-project",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-a"),
ProjectLink(project_name="proj-b"),
ProjectLink(project_name="proj-c"),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 5, 20, 8, 15, 0),
updated_at=datetime(2025, 5, 20, 9, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
@given("the service returns plans with invariants for rich list")
def step_service_plans_with_invariants(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/invariant-plan",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-one")],
invariants=[
PlanInvariant(
text="No breaking changes", source=InvariantSource.ACTION
),
PlanInvariant(
text="Keep backward compat", source=InvariantSource.PROJECT
),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
# ---------------------------------------------------------------------------
# Given steps — cancel_plan
# ---------------------------------------------------------------------------
@given("the service can cancel a plan for cancel-revert coverage")
def step_service_can_cancel(context):
plan = _make_plan(
plan_id=_ULID_CANCEL,
name="local/cancel-target",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.CANCELLED,
project_links=[ProjectLink(project_name="proj-cancel")],
)
context.mock_service.cancel_plan.return_value = plan
context._cancel_plan_id = _ULID_CANCEL
# ---------------------------------------------------------------------------
# Given steps — revert_plan
# ---------------------------------------------------------------------------
@given("the service can revert a plan for cancel-revert coverage")
def step_service_can_revert(context):
plan = _make_plan(
plan_id=_ULID_REVERT,
name="local/revert-target",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-revert")],
reversion_count=1,
)
context.mock_service.revert_plan.return_value = plan
context._revert_plan_id = _ULID_REVERT
# ---------------------------------------------------------------------------
# When steps — list
# ---------------------------------------------------------------------------
@when("I invoke list in rich format for cancel-revert coverage")
def step_invoke_list_rich(context):
context.result = context.runner.invoke(
plan_app,
["list", "--format", "rich"],
)
# ---------------------------------------------------------------------------
# When steps — cancel_plan
# ---------------------------------------------------------------------------
@when("I invoke cancel in rich format without reason for cancel-revert coverage")
def step_invoke_cancel_rich_no_reason(context):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id],
)
@when(
'I invoke cancel in rich format with reason "{reason}" for cancel-revert coverage'
)
def step_invoke_cancel_rich_with_reason(context, reason):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--reason", reason],
)
@when('I invoke cancel with format "{fmt}" and no reason for cancel-revert coverage')
def step_invoke_cancel_fmt_no_reason(context, fmt):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--format", fmt],
)
@when(
'I invoke cancel with format "{fmt}" and reason "{reason}" for cancel-revert coverage'
)
def step_invoke_cancel_fmt_with_reason(context, fmt, reason):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--format", fmt, "--reason", reason],
)
# ---------------------------------------------------------------------------
# When steps — revert_plan
# ---------------------------------------------------------------------------
@when("I invoke revert in rich format with default phase for cancel-revert coverage")
def step_invoke_revert_rich_default(context):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id],
)
@when(
'I invoke revert in rich format with reason "{reason}" for cancel-revert coverage'
)
def step_invoke_revert_rich_with_reason(context, reason):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id, "--reason", reason],
)
@when('I invoke revert with format "{fmt}" for cancel-revert coverage')
def step_invoke_revert_fmt(context, fmt):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id, "--format", fmt],
)
@when('I invoke revert with invalid phase "{phase}" for cancel-revert coverage')
def step_invoke_revert_invalid_phase(context, phase):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id, "--to-phase", phase],
)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
def _output(context) -> str:
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
@then("the cancel-revert command should succeed")
def step_command_succeeds(context):
assert context.result.exit_code == 0, (
f"Expected exit code 0 but got {context.result.exit_code}.\n"
f"Output: {_output(context)}"
)
@then("the cancel-revert command should abort")
def step_command_aborts(context):
assert context.result.exit_code != 0, (
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
)
@then('the cancel-revert output should contain "{text}"')
def step_output_contains(context, text):
output = _output(context)
assert text in output, f"Expected '{text}' in output:\n{output}"
@then('the cancel-revert output should not contain "{text}"')
def step_output_not_contains(context, text):
output = _output(context)
assert text not in output, f"Did not expect '{text}' in output:\n{output}"