Files
temp/features/steps/plan_cli_coverage_boost_steps.py
T
Luis Mendes 01efa2922c fix(test): align behave BDD scenarios with updated lifecycle-apply behavior
Updated automation_levels.feature to expect "applied" processing state
instead of "queued" after completing execute on a full_automation plan,
matching the new auto_progress() behavior that drives Apply to terminal
state.

Updated plan_cli_coverage_boost_steps.py to properly mock the full apply
lifecycle (get_plan → apply_plan → start_apply → complete_apply) instead
of returning a single plan from apply_plan, matching the updated
lifecycle_apply_plan command handler that now drives plans through to the
terminal applied state.

Refs: #753
2026-03-26 17:35:34 +00:00

511 lines
16 KiB
Python

"""Step definitions for plan_cli_coverage_boost.feature.
Covers uncovered branches in cleveragents/cli/commands/plan.py:
- _plan_spec_dict: error_message truthy branch, legacy fallback
- _print_lifecycle_plan: all optional timestamps, estimation_actor,
invariant_actor, non-Plan fallback
- list_plans: non-rich format path
- execute_plan: non-rich format path
- lifecycle_apply_plan: non-rich format path
- lifecycle_list_plans: regex, state, processing_state filtering
- cancel_plan: rich and non-rich paths, with and without reason
"""
from __future__ import annotations
from datetime import datetime, timedelta
from io import StringIO
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands import plan as plan_module
from cleveragents.cli.commands.plan import (
_plan_spec_dict,
_print_lifecycle_plan,
)
from cleveragents.cli.commands.plan import (
app as plan_app,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# Valid ULIDs for test plans
_ULIDS = [
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"01ARZ3NDEKTSV4RRFFQ69G5FAW",
"01ARZ3NDEKTSV4RRFFQ69G5FAX",
"01ARZ3NDEKTSV4RRFFQ69G5FAY",
"01ARZ3NDEKTSV4RRFFQ69G5FAZ",
"01ARZ3NDEKTSV4RRFFQ69G5FB0",
]
def _make_plan(
*,
plan_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
name: str = "local/test-plan",
description: str = "Test plan for coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
error_message: str | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
project_links: list[ProjectLink] | None = None,
action_name: str = "local/test-action",
) -> Plan:
"""Build a real Plan object for testing."""
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
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 [],
error_message=error_message,
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
reusable=True,
read_only=False,
)
# --------------------------------------------------------------------------
# _plan_spec_dict helpers
# --------------------------------------------------------------------------
@given('a v3 Plan with error_message set to "{error_msg}"')
def step_plan_with_error_message(context, error_msg: str) -> None:
context.test_plan = _make_plan(error_message=error_msg)
@given("a v3 Plan with error_message set to None")
def step_plan_with_no_error_message(context) -> None:
context.test_plan = _make_plan(error_message=None)
@given('a non-Plan object with string value "{value}"')
def step_non_plan_object(context, value: str) -> None:
context.test_plan = value
@when("I call _plan_spec_dict on the plan")
def step_call_plan_spec_dict_on_plan(context) -> None:
context.spec_dict = _plan_spec_dict(context.test_plan)
@when("I call _plan_spec_dict on the object")
def step_call_plan_spec_dict_on_object(context) -> None:
context.spec_dict = _plan_spec_dict(context.test_plan)
@then('the spec dict should contain key "{key}" with value "{value}"')
def step_spec_dict_contains_key_value(context, key: str, value: str) -> None:
assert key in context.spec_dict, (
f"Key '{key}' not in spec dict: {context.spec_dict}"
)
assert str(context.spec_dict[key]) == value, (
f"Expected '{value}', got '{context.spec_dict[key]}'"
)
@then("the spec dict should equal {expected}")
def step_spec_dict_equals(context, expected: str) -> None:
import json
expected_dict = json.loads(expected.replace("'", '"'))
assert context.spec_dict == expected_dict, (
f"Expected {expected_dict}, got {context.spec_dict}"
)
@then('the spec dict should not contain key "{key}"')
def step_spec_dict_not_contains_key(context, key: str) -> None:
assert key not in context.spec_dict, (
f"Key '{key}' should not be in spec dict: {context.spec_dict}"
)
# --------------------------------------------------------------------------
# _print_lifecycle_plan helpers
# --------------------------------------------------------------------------
@given("a v3 Plan with all timestamps populated")
def step_plan_with_all_timestamps(context) -> None:
now = datetime.now()
ts = PlanTimestamps(
created_at=now - timedelta(hours=2),
updated_at=now,
strategize_started_at=now - timedelta(hours=1, minutes=50),
strategize_completed_at=now - timedelta(hours=1, minutes=40),
execute_started_at=now - timedelta(hours=1, minutes=30),
execute_completed_at=now - timedelta(hours=1),
applied_at=now - timedelta(minutes=30),
)
context.test_plan = _make_plan(
timestamps=ts,
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
@given('a v3 Plan with estimation_actor set to "{actor}"')
def step_plan_with_estimation_actor(context, actor: str) -> None:
context.test_plan = _make_plan(estimation_actor=actor)
@given('a v3 Plan with invariant_actor set to "{actor}"')
def step_plan_with_invariant_actor(context, actor: str) -> None:
context.test_plan = _make_plan(invariant_actor=actor)
@when("I call _print_lifecycle_plan on the plan")
def step_call_print_lifecycle_plan_on_plan(context) -> None:
buf = StringIO()
from rich.console import Console
test_console = Console(file=buf, width=200, no_color=True)
with patch.object(plan_module, "console", test_console):
_print_lifecycle_plan(context.test_plan, title="Test Plan")
context.printed_output = buf.getvalue()
@when("I call _print_lifecycle_plan on the object")
def step_call_print_lifecycle_plan_on_object(context) -> None:
buf = StringIO()
from rich.console import Console
test_console = Console(file=buf, width=200, no_color=True)
with patch.object(plan_module, "console", test_console):
_print_lifecycle_plan(context.test_plan, title="Legacy Plan")
context.printed_output = buf.getvalue()
@then('the printed output should contain "{text}"')
def step_printed_output_contains(context, text: str) -> None:
assert text in context.printed_output, (
f"Expected '{text}' in output:\n{context.printed_output}"
)
# --------------------------------------------------------------------------
# CLI runner steps for list_plans, execute, lifecycle-apply, lifecycle-list,
# cancel in non-rich and rich formats
# --------------------------------------------------------------------------
def _output(context) -> str:
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
@given("a plan lifecycle CLI runner for coverage")
def step_cli_runner_for_coverage(context) -> None:
context.runner = CliRunner()
@given("a mocked lifecycle service for plan coverage commands")
def step_mocked_lifecycle_service_for_coverage(context) -> None:
context.mock_lifecycle_service = MagicMock()
patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_lifecycle_service,
)
patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(patcher.stop)
# Widen the Rich console so table columns do not wrap during tests
console_patcher = patch.object(plan_module.console, "width", 200)
console_patcher.start()
context._cleanup_handlers.append(console_patcher.stop)
@given("the mocked plan service returns legacy plans")
def step_mocked_plan_service_returns_legacy_plans(context) -> None:
"""Set up the container mock so list_plans returns legacy Plan objects."""
mock_plan = MagicMock()
mock_plan.name = "test-plan"
mock_plan.status = "active"
mock_plan.created_at = datetime.now()
mock_plan.current = False
mock_plan.id = "plan-001"
mock_project = MagicMock()
mock_project.name = "test-project"
mock_plan_service = MagicMock()
mock_plan_service.list_plans.return_value = [mock_plan]
mock_project_service = MagicMock()
mock_project_service.get_current_project.return_value = mock_project
mock_container = MagicMock()
mock_container.plan_service.return_value = mock_plan_service
mock_container.project_service.return_value = mock_project_service
patcher = patch(
"cleveragents.application.container.get_container",
return_value=mock_container,
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given("the service has a complete strategize plan for execute")
def step_service_has_strategize_plan(context) -> None:
plan = _make_plan(
plan_id=_ULIDS[0],
name="local/exec-plan",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
)
context.mock_lifecycle_service.get_plan.return_value = plan
context.mock_lifecycle_service.execute_plan.return_value = plan
context._execute_plan_id = _ULIDS[0]
# Mock the plan executor so the execute phase runs with a mock
executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
executor_patcher.start()
context._cleanup_handlers.append(executor_patcher.stop)
@given("the service has a complete execute plan for apply")
def step_service_has_execute_plan(context) -> None:
pre_plan = _make_plan(
plan_id=_ULIDS[1],
name="local/apply-plan",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
apply_queued_plan = _make_plan(
plan_id=_ULIDS[1],
name="local/apply-plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.QUEUED,
)
applied_plan = _make_plan(
plan_id=_ULIDS[1],
name="local/apply-plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
# lifecycle_apply_plan calls get_plan twice: first to check read_only
# and phase, then again after apply_plan to drive _complete_apply_if_queued.
context.mock_lifecycle_service.get_plan.side_effect = [
pre_plan,
apply_queued_plan,
]
context.mock_lifecycle_service.apply_plan.return_value = apply_queued_plan
context.mock_lifecycle_service._complete_apply_if_queued.return_value = applied_plan
context._apply_plan_id = _ULIDS[1]
@given("the service has multiple plans for lifecycle list")
def step_service_has_multiple_plans(context) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[0],
name="local/alpha-plan",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
),
_make_plan(
plan_id=_ULIDS[1],
name="local/beta-plan",
action_name="local/other-action",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
),
]
context.mock_lifecycle_service.list_plans.return_value = plans
@given("the service has plans in different processing states")
def step_service_has_plans_different_states(context) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[0],
name="local/plan-processing",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.PROCESSING,
),
_make_plan(
plan_id=_ULIDS[1],
name="local/plan-complete",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
),
]
context.mock_lifecycle_service.list_plans.return_value = plans
@given("the service can cancel a plan")
def step_service_can_cancel(context) -> None:
plan = _make_plan(
plan_id=_ULIDS[2],
name="local/cancel-me",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.CANCELLED,
)
context.mock_lifecycle_service.cancel_plan.return_value = plan
context._cancel_plan_id = _ULIDS[2]
# ---- When steps for CLI invocations ----
@when('I invoke list_plans with "--format" "{fmt}"')
def step_invoke_list_plans_format(context, fmt: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", "--format", fmt],
)
@when('I invoke execute with "--format" "json" and plan id')
def step_invoke_execute_json(context) -> None:
context.result = context.runner.invoke(
plan_app,
["execute", context._execute_plan_id, "--format", "json"],
)
@when('I invoke lifecycle-apply with "--format" "json" and plan id')
def step_invoke_lifecycle_apply_json(context) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-apply", "--yes", context._apply_plan_id, "--format", "json"],
)
@when('I invoke lifecycle-list with regex "{pattern}"')
def step_invoke_lifecycle_list_regex(context, pattern: str) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-list", pattern],
)
@when('I invoke lifecycle-list with "--state" "{state}"')
def step_invoke_lifecycle_list_state(context, state: str) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-list", "--state", state],
)
@when('I invoke lifecycle-list with "--processing-state" "{state}"')
def step_invoke_lifecycle_list_processing_state(context, state: str) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-list", "--processing-state", state],
)
@when('I invoke lifecycle-list with "--action" "{action}"')
def step_invoke_lifecycle_list_action(context, action: str) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-list", "--action", action],
)
@when('I invoke lifecycle-list with "--format" "{fmt}"')
def step_invoke_lifecycle_list_format(context, fmt: str) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-list", "--format", fmt],
)
@when('I invoke cancel with "--format" "json" and no reason')
def step_invoke_cancel_json_no_reason(context) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--format", "json"],
)
@when('I invoke cancel with "--format" "json" and reason "{reason}"')
def step_invoke_cancel_json_with_reason(context, reason: str) -> None:
context.result = context.runner.invoke(
plan_app,
[
"cancel",
context._cancel_plan_id,
"--format",
"json",
"--reason",
reason,
],
)
@when('I invoke cancel in rich format with reason "{reason}"')
def step_invoke_cancel_rich_with_reason(context, reason: str) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--reason", reason],
)
@when("I invoke cancel in rich format without reason")
def step_invoke_cancel_rich_no_reason(context) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id],
)
# ---- Then steps for CLI assertions ----
@then("the plan coverage command should succeed")
def step_plan_coverage_command_succeeds(context) -> None:
assert context.result.exit_code == 0, (
f"Expected success but got exit code {context.result.exit_code}.\n"
f"Output: {_output(context)}"
)
@then("the plan coverage command should abort")
def step_plan_coverage_command_aborts(context) -> None:
assert context.result.exit_code != 0, (
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
)
@then('the plan coverage output should contain "{text}"')
def step_plan_coverage_output_contains(context, text: str) -> None:
output = _output(context)
assert text in output, f"Expected '{text}' in output:\n{output}"
@then('the plan coverage output should not contain "{text}"')
def step_plan_coverage_output_not_contains(context, text: str) -> None:
output = _output(context)
assert text not in output, f"Did not expect '{text}' in output:\n{output}"