Files
cleveragents-core/features/steps/plan_cli_coverage_steps.py
T
hurui200320 414abb1396
CI / benchmark-publish (push) Waiting to run
CI / lint (push) Successful in 19s
CI / build (push) Successful in 20s
CI / quality (push) Successful in 3m49s
CI / typecheck (push) Successful in 3m58s
CI / benchmark-regression (push) Waiting to run
CI / security (push) Successful in 4m7s
CI / integration_tests (push) Successful in 6m48s
CI / unit_tests (push) Successful in 7m30s
CI / docker (push) Successful in 1m12s
CI / e2e_tests (push) Successful in 10m1s
CI / coverage (push) Successful in 11m48s
CI / status-check (push) Successful in 1s
fix(cli): add missing --yes flag to plan apply command (#1127)
## Summary

Adds the `--yes`/`-y` flag to the `lifecycle-apply` CLI command as required by the specification (`agents plan apply [--yes|-y] <PLAN_ID>`). Without `--yes`, a confirmation prompt now displays before proceeding with the destructive Apply phase. With `--yes`, the apply proceeds immediately without prompting.

Closes #932

## Changes

### Source Code
- **`src/cleveragents/cli/commands/plan.py`**: Added `yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt")] = False` parameter to `lifecycle_apply_plan`. Added `typer.confirm()` prompt before the apply operation, consistent with the pattern used by `rollback_plan`, `correct_plan`, and other destructive commands.
  - Confirmation prompt text matches spec exactly: `"Apply changes for plan {plan_id}?"` producing `Apply changes for plan <ID>? [y/N]:`.
  - Fixed redundant plan ID display when `pre_plan` is `None` — now shows `"Apply changes for plan X?"` instead of `"Apply plan X (X)?"`.
  - Added `except ValueError` handler consistent with sibling commands `lifecycle_execute_plan` and `_lifecycle_apply_with_id`.
  - Added `except Exception` catch-all handler with `isinstance(e, (typer.Abort, typer.Exit))` re-raise guard, consistent with `lifecycle_execute_plan`.
  - Moved `PlanPhase` and `ProcessingState` imports to module level per CONTRIBUTING.md §Import Guidelines.

### TDD Tag Removal (Bug Fix Workflow)
- **`features/tdd_plan_apply_yes_flag.feature`**: Removed `@tdd_expected_fail` tag (leaving `@tdd_bug` and `@tdd_bug_932` as permanent regression guards).
- **`robot/tdd_plan_apply_yes_flag.robot`**: Removed `tdd_expected_fail` tag (leaving `tdd_bug` and `tdd_bug_932`).

### Test Updates
Updated all existing `lifecycle-apply` invocations across 17 test/benchmark files to pass `--yes`, since the new confirmation prompt would otherwise abort in non-interactive test environments:
- 9 Behave step definition files
- 3 Robot Framework helper scripts
- 2 Robot Framework e2e acceptance tests
- 3 ASV benchmark files (4 invocations: `cli_robot_flow_bench.py` ×2, `m1_sourcecode_smoke_bench.py` ×1, `plan_cli_smoke_bench.py` ×1)

### Confirmation Prompt Tests (New + Strengthened)
- **`features/tdd_plan_apply_yes_flag.feature`**: 5 scenarios total:
  - `lifecycle-apply recognises the --yes long flag` — verifies flag acceptance, prompt suppression, exit code 0, and `apply_plan` was called
  - `lifecycle-apply recognises the -y short flag` — same as above for short flag
  - `lifecycle-apply without --yes prompts for confirmation and user declines` — verifies `"Apply cancelled."` message, `exit_code == 0`, and `apply_plan` was NOT called
  - `lifecycle-apply without --yes prompts for confirmation and user accepts` — verifies prompt appears, `exit_code == 0`, and `apply_plan` was called
  - `lifecycle-apply catches unexpected exceptions cleanly` — verifies `"Unexpected error"` output, no traceback leak, non-zero exit code (exercises the `except Exception` catch-all)
- **`features/steps/tdd_plan_apply_yes_flag_steps.py`**: Refactored step definitions:
  - `_make_mock_plan` uses `PlanPhase` and `ProcessingState` enum types instead of raw strings
  - `_make_mock_plan` uses `datetime.now(tz=UTC)` instead of timezone-naive `datetime.now()`
  - Unified prompt suppression step handles both `--yes` and `-y` via parameterised step pattern
  - Added `When` step for unexpected error scenario with `RuntimeError` side_effect
  - Added `Then` step for non-zero exit code assertion
- **Feature/Robot documentation**: Updated stale descriptions that said "implementation does not accept --yes" to reflect the flag is now implemented.

### Documentation
- **`docs/reference/plan_cli.md`**: Updated `lifecycle-apply` section with:
  - `### Synopsis` heading with code block
  - `### Options` table listing `--yes/-y` and `--format/-f` flags
  - `### Arguments` table listing `PLAN_ID`
  - Matches the style used by other command sections in the same file

## Review Fixes (Cycle 3 — Luis's review)

| ID | Severity | Issue | Resolution |
|----|----------|-------|------------|
| M1 | Medium | `typer.Abort()` on user decline produces exit code 1 and redundant "Aborted." | Changed to `raise typer.Exit(0)` — consistent with `correct_decision` and legacy `apply` |
| M2 | Medium | Missing exit code assertion on decline scenario | Added `And the lifecycle-apply exit code should be 0` to the decline scenario |
| M3 | Medium | Spec compliance: "summary of pending changes" not implemented | Deferred — spec example shows summary *after* confirmation, not before; implementation matches spec. Ticket-vs-spec ambiguity noted. |
| L1 | Low | Missing `except Exception` catch-all handler | Added catch-all matching `lifecycle_execute_plan` pattern; re-raises `typer.Abort`/`typer.Exit` |
| L2 | Low | Documentation description not updated | Expanded description in `plan_cli.md` to explain confirmation prompt and `--yes` |
| L3 | Low | Dead code `is not None` guards | Removed both guards — `get_plan()` raises `NotFoundError`, never returns `None` |
| I1 | Info | Duplicate `PlanPhase` import | Hoisted import to top of `try` block, eliminating duplicate at old line 2087 |
| I2 | Info | `typer.confirm` without explicit `default=False` | Added `default=False` for consistency with sibling commands |
| L4 | Low | No test for `--yes` after positional arg | Not addressed — Typer/Click handles both orderings; low risk |
| L5 | Low | No test for auto-select + interactive prompt | Not addressed — separate concern outside ticket scope |
| I3 | Info | Robot helper only tests flag recognition | By design — noted as informational |

## Review Fixes (Cycle 4 — Self-QA)

| ID | Severity | Issue | Resolution |
|----|----------|-------|------------|
| Major-1 | Major | No test for `except Exception` catch-all handler | Added new scenario `"lifecycle-apply catches unexpected exceptions cleanly"` with `RuntimeError` side_effect; asserts `"Unexpected error"` output, no traceback, non-zero exit |
| Minor-2 | Minor | Missing `ValueError` handler inconsistent with siblings | Added `except ValueError as e:` with `"[red]Execution Error:[/red]"` before catch-all, matching `lifecycle_execute_plan` and `_lifecycle_apply_with_id` |
| Minor-3 | Minor | Flag scenarios don't verify `apply_plan` called | Added `And the lifecycle-apply should have called apply` to both `--yes` and `-y` scenarios |
| Minor-4 | Minor | Stale docstring in Robot helper references `tdd_expected_fail` inversion | Updated to reflect bug is fixed and tests serve as regression guards |
| Minor-5 | Minor | `plan_cli.md` lacks Options table for `lifecycle-apply` | Added Synopsis, Options, and Arguments sections matching sibling command style |
| Nit-6 | Nit | Duplicated step defs for `--yes` vs `-y` prompt suppression | Unified into single parameterised step `"the lifecycle-apply {flag} output should not contain the confirmation prompt"` |
| Nit-7 | Nit | `datetime.now()` timezone-naive | Changed to `datetime.now(tz=UTC)` |
| Nit-8 | Nit | `_make_mock_plan` params use `str` instead of enum types | Changed to `PlanPhase` and `ProcessingState` enum types |

## Review Fixes (Cycle 5 — Jeff's approval note)

| ID | Severity | Issue | Resolution |
|----|----------|-------|------------|
| Import-1 | Minor | `PlanPhase`/`ProcessingState` imports inside function body instead of module level | Moved to module-level import per CONTRIBUTING.md §Import Guidelines |

## Known Limitations / Deferred Items

- **M3: Ticket AC mentions "summary of pending changes"** but the spec example only shows `"Apply changes for plan <ID>? [y/N]: y"` without a change summary. The implementation shows plan ID only (matching the spec), not a change summary. This is a ticket-vs-spec ambiguity; recommend discussing with ticket author.
- **Legacy `apply` command** accepts `--yes` but does not pass it to `_lifecycle_apply_with_id()`. This is a pre-existing issue outside the scope of this ticket.
- **`pre_plan is None` branch** has no explicit test. Pre-existing architectural issue; no action taken.

## Quality Gates

| Gate | Result |
|------|--------|
| `nox -s lint` |  passed |
| `nox -s typecheck` |  passed (0 errors) |
| `nox -s unit_tests` |  passed (471 features, 12,424 scenarios, 0 failures) |
| `nox -s integration_tests` |  passed (1,727 tests, 0 failures) |
| `nox -s e2e_tests` |  passed (41 tests, 0 failures) |
| `nox -s coverage_report` |  passed (≥97% coverage) |

Reviewed-on: #1127
Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-authored-by: Rui Hu <rui.hu@cleverthis.com>
Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
2026-03-26 07:50:09 +00:00

513 lines
18 KiB
Python

"""Step definitions for plan_cli_coverage.feature.
Covers uncovered lines/branches in cleveragents/cli/commands/plan.py:
- Lines 818, 827-828: legacy apply Progress block and PlanError handler
- Branch 999: legacy list when plan.current is False
- Lines 1017-1035: legacy cd command full body
- Lines 1314-1315: invariant_actor parameter in use_action
- Lines 1391-1392: building PlanInvariant list from --invariant flags
- Lines 1573-1578: lifecycle-apply auto-discovery with 0 plans
- Lines 1594-1596: lifecycle-apply read-only guard
- Lines 1700-1701: status CleverAgentsError handler
- Branch 1738: errors command when has_error_recovery is False
"""
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import CleverAgentsError, PlanError
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA"
_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FAB"
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
_PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
_PATCH_GET_PROJECT = "cleveragents.cli.commands.plan._get_current_project"
def _make_lifecycle_plan(
*,
plan_id: str = _ULID_A,
name: str = "local/test-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
error_message: str | None = None,
error_details: dict[str, str] | None = None,
read_only: bool = False,
) -> Plan:
"""Build a real Plan domain object for testing."""
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name="local/test-action",
description="Coverage test plan",
definition_of_done=None,
strategy_actor=None,
execution_actor=None,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 15, 10, 0, 0),
updated_at=datetime(2025, 6, 15, 11, 0, 0),
),
error_message=error_message,
error_details=error_details,
reusable=True,
read_only=read_only,
created_by=None,
)
def _make_legacy_project() -> SimpleNamespace:
"""Build a minimal project stub for legacy commands."""
return SimpleNamespace(name="test-project", path="/tmp/test")
def _make_legacy_plan(
*,
name: str = "my-plan",
status: str = "active",
current: bool = True,
) -> SimpleNamespace:
"""Build a minimal legacy plan stub."""
return SimpleNamespace(
name=name,
status=status,
current=current,
created_at=datetime(2025, 1, 1),
prompt="do stuff",
)
# ----------------------------------------------------------------------
# Given — CLI runner
# ----------------------------------------------------------------------
@given("a plan-cov CLI runner")
def step_plan_cov_cli_runner(context: Context) -> None:
"""Initialise a CliRunner and cleanup list."""
context.plan_cov_runner = CliRunner()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
# ----------------------------------------------------------------------
# Given — Legacy apply mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov apply that succeeds")
def step_mock_apply_success(context: Context) -> None:
"""Set up mocks so legacy apply command succeeds through Progress block."""
context.plan_cov_apply_changes_return = 1
context.plan_cov_apply_changes_side_effect = None
@given("a mocked legacy container for plan-cov apply that raises PlanError")
def step_mock_apply_plan_error(context: Context) -> None:
"""Set up mocks so legacy apply raises PlanError during apply_changes."""
context.plan_cov_apply_changes_return = None
context.plan_cov_apply_changes_side_effect = PlanError("Sandbox conflict")
# ----------------------------------------------------------------------
# Given — Legacy list mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov list with non-current plans")
def step_mock_list_non_current(context: Context) -> None:
"""Mark that legacy list should produce plans with current=False."""
context.plan_cov_list_plans = [
_make_legacy_plan(name="alpha", current=False),
_make_legacy_plan(name="beta", current=False),
]
# ----------------------------------------------------------------------
# Given — Legacy cd mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov cd that succeeds")
def step_mock_cd_success(context: Context) -> None:
"""Mark that legacy cd should succeed."""
context.plan_cov_cd_return = _make_legacy_plan(name="my-plan")
context.plan_cov_cd_side_effect = None
@given("a mocked legacy container for plan-cov cd that raises CleverAgentsError")
def step_mock_cd_error(context: Context) -> None:
"""Mark that legacy cd should raise CleverAgentsError."""
context.plan_cov_cd_return = None
context.plan_cov_cd_side_effect = CleverAgentsError("not found")
# ----------------------------------------------------------------------
# Given — Lifecycle service for use_action
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov use action")
def step_mock_lifecycle_for_use(context: Context) -> None:
"""Set up lifecycle service mock for the use command."""
mock_service = MagicMock()
mock_action = MagicMock()
mock_action.namespaced_name = "local/test-action"
mock_service.get_action_by_name.return_value = mock_action
mock_plan = _make_lifecycle_plan()
mock_service.use_action.return_value = mock_plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
context.plan_cov_service = mock_service
context.plan_cov_plan = mock_plan
# ----------------------------------------------------------------------
# Given — Lifecycle service for lifecycle-apply
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov that returns no execute-complete plans")
def step_mock_lifecycle_apply_no_plans(context: Context) -> None:
"""Set up lifecycle service that returns no complete plans in Execute phase."""
mock_service = MagicMock()
mock_service.list_plans.return_value = []
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given(
"a mocked lifecycle service for plan-cov that returns multiple execute-complete plans"
)
def step_mock_lifecycle_apply_multiple_plans(context: Context) -> None:
"""Set up lifecycle service that returns multiple complete Execute plans."""
mock_service = MagicMock()
plan_a = _make_lifecycle_plan(
plan_id=_ULID_A,
name="local/plan-a",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
plan_b = _make_lifecycle_plan(
plan_id=_ULID_B,
name="local/plan-b",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
mock_service.list_plans.return_value = [plan_a, plan_b]
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given("a mocked lifecycle service for plan-cov that returns a read-only plan")
def step_mock_lifecycle_apply_readonly(context: Context) -> None:
"""Set up lifecycle service that returns a read-only plan."""
mock_service = MagicMock()
ro_plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
read_only=True,
)
mock_service.get_plan.return_value = ro_plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# Given — Lifecycle service for status command error
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov status that raises CleverAgentsError")
def step_mock_lifecycle_status_error(context: Context) -> None:
"""Set up lifecycle service that raises CleverAgentsError on get_plan."""
mock_service = MagicMock()
mock_service.get_plan.side_effect = CleverAgentsError("service unavailable")
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# Given — Lifecycle service for errors command
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov errors with no error_category")
def step_mock_lifecycle_errors_no_category(context: Context) -> None:
"""Set up lifecycle service returning a plan with error_details lacking error_category."""
mock_service = MagicMock()
plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.ERRORED,
error_message="Something went wrong",
error_details={"error_phase": "execute"},
)
mock_service.get_plan.return_value = plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given("a mocked lifecycle service for plan-cov errors with empty details")
def step_mock_lifecycle_errors_empty_details(context: Context) -> None:
"""Set up lifecycle service returning a plan with no error_details or error_message."""
mock_service = MagicMock()
plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.QUEUED,
error_message=None,
error_details=None,
)
mock_service.get_plan.return_value = plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# When — Legacy apply
# ----------------------------------------------------------------------
@when("I invoke plan-cov legacy apply with --yes")
def step_invoke_legacy_apply(context: Context) -> None:
"""Invoke the legacy apply command with auto-confirm."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
mock_plan_service.get_pending_changes.return_value = [
SimpleNamespace(operation="modify", file_path="a.py"),
]
if context.plan_cov_apply_changes_side_effect:
mock_plan_service.apply_changes.side_effect = (
context.plan_cov_apply_changes_side_effect
)
else:
mock_plan_service.apply_changes.return_value = (
context.plan_cov_apply_changes_return
)
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["apply", "--yes"]
)
# ----------------------------------------------------------------------
# When — Legacy list
# ----------------------------------------------------------------------
@when("I invoke plan-cov legacy list")
def step_invoke_legacy_list(context: Context) -> None:
"""Invoke the legacy list command in rich format."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
mock_plan_service.list_plans.return_value = context.plan_cov_list_plans
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["list"])
# ----------------------------------------------------------------------
# When — Legacy cd
# ----------------------------------------------------------------------
@when('I invoke plan-cov legacy cd "{name}"')
def step_invoke_legacy_cd(context: Context, name: str) -> None:
"""Invoke the legacy cd command with a plan name."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
if context.plan_cov_cd_side_effect:
mock_plan_service.switch_to_plan.side_effect = context.plan_cov_cd_side_effect
else:
mock_plan_service.switch_to_plan.return_value = context.plan_cov_cd_return
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["cd", name])
# ----------------------------------------------------------------------
# When — use action with actor overrides
# ----------------------------------------------------------------------
@when('I invoke plan-cov use with estimation-actor "{est}" and invariant-actor "{inv}"')
def step_invoke_use_actor_overrides(context: Context, est: str, inv: str) -> None:
"""Invoke use command with estimation-actor and invariant-actor."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--project",
"proj-1",
"--estimation-actor",
est,
"--invariant-actor",
inv,
],
)
@when('I invoke plan-cov use with invariant "{text}"')
def step_invoke_use_with_invariant(context: Context, text: str) -> None:
"""Invoke use command with --invariant flag."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--project",
"proj-1",
"--invariant",
text,
],
)
# ----------------------------------------------------------------------
# When — lifecycle-apply
# ----------------------------------------------------------------------
@when("I invoke plan-cov lifecycle-apply without plan_id")
def step_invoke_lifecycle_apply_no_id(context: Context) -> None:
"""Invoke lifecycle-apply without specifying a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["lifecycle-apply", "--yes"]
)
@when('I invoke plan-cov lifecycle-apply with plan_id "{pid}"')
def step_invoke_lifecycle_apply_with_id(context: Context, pid: str) -> None:
"""Invoke lifecycle-apply with an explicit plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["lifecycle-apply", "--yes", pid]
)
# ----------------------------------------------------------------------
# When — status
# ----------------------------------------------------------------------
@when('I invoke plan-cov status "{pid}"')
def step_invoke_status(context: Context, pid: str) -> None:
"""Invoke the status command with a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["status", pid])
# ----------------------------------------------------------------------
# When — errors
# ----------------------------------------------------------------------
@when('I invoke plan-cov errors "{pid}" with format "{fmt}"')
def step_invoke_errors_json(context: Context, pid: str, fmt: str) -> None:
"""Invoke the errors command with a given format."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["errors", pid, "--format", fmt]
)
@when('I invoke plan-cov errors "{pid}"')
def step_invoke_errors_rich(context: Context, pid: str) -> None:
"""Invoke the errors command in default rich format."""
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["errors", pid])
# ----------------------------------------------------------------------
# Then — result assertions
# ----------------------------------------------------------------------
@then("the plan-cov command should exit normally")
def step_assert_exit_ok(context: Context) -> None:
"""Assert the CLI exited with code 0."""
assert context.plan_cov_result.exit_code == 0, (
f"Expected exit_code=0, got {context.plan_cov_result.exit_code}. "
f"Output: {context.plan_cov_result.output}"
)
@then("the plan-cov command should abort")
def step_assert_abort(context: Context) -> None:
"""Assert the CLI exited with a non-zero code (abort)."""
assert context.plan_cov_result.exit_code != 0, (
f"Expected non-zero exit_code, got {context.plan_cov_result.exit_code}. "
f"Output: {context.plan_cov_result.output}"
)
@then('the plan-cov output should contain "{text}"')
def step_assert_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the expected text."""
assert text in context.plan_cov_result.output, (
f"Expected output to contain '{text}'. "
f"Actual output: {context.plan_cov_result.output}"
)
@then("the plan-cov created plan should have estimation_actor set")
def step_assert_estimation_actor(context: Context) -> None:
"""Assert the plan was updated with estimation_actor."""
plan = context.plan_cov_plan
assert plan.estimation_actor == "openai/gpt-4", (
f"Expected estimation_actor='openai/gpt-4', got {plan.estimation_actor}"
)