"""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}"