Files
temp/features/steps/plan_cli_commands_r2_steps.py
hurui200320 414abb1396 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: cleveragents/cleveragents-core#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

485 lines
17 KiB
Python

"""Step definitions for plan_cli_commands_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 2 of 3.
Covers:
- ``use_action`` argument parsing: int/float/bool/string, missing '=',
invalid automation profile, invalid actor overrides
- ``execute_plan`` auto-resolve: 0 plans / >1 plans / exactly 1
- ``lifecycle_apply_plan`` auto-resolve: same subcases
- ``lifecycle_list_plans``: invalid phase/state, empty result, project truncation
- ``revert_plan``: invalid to-phase, non-rich format
- ``plan_status``: no plans, non-rich list, non-rich single plan
- ``correct_decision``: invalid mode, empty guidance
- ``plan_diff``: correction flag
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
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 app as plan_app
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,
)
# ---------------------------------------------------------------------------
# Given steps - mocked lifecycle service (shared for CLI command scenarios)
# ---------------------------------------------------------------------------
@given("r2plan-a mocked lifecycle service")
def step_mocked_lifecycle(context: Any) -> None:
context.r2_mock_svc = MagicMock()
context.r2_cleanups = [] # list[Any]
# Patch the lifecycle service getter
p = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.r2_mock_svc,
)
p.start()
context.r2_cleanups.append(p.stop)
# Patch the plan executor getter (needed by execute_plan command)
p2 = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
p2.start()
context.r2_cleanups.append(p2.stop)
# Replace the module-level console with a wider one for table tests
from rich.console import Console as RichConsole
wide_console = RichConsole(width=200)
original_console = plan_module.console
plan_module.console = wide_console
def _restore_console() -> None:
plan_module.console = original_console
context.r2_cleanups.append(_restore_console)
# Default: get_action_by_name returns a mock action
action = MagicMock()
action.namespaced_name = "local/test"
context.r2_mock_svc.get_action_by_name.return_value = action
# Default: use_action returns a plan (needed for use_action scenarios)
context.r2_use_plan = _make_plan()
context.r2_mock_svc.use_action.return_value = context.r2_use_plan
# Register cleanup
if not hasattr(context, "_r2_after_scenario"):
def _cleanup(ctx: Any) -> None:
for c in getattr(ctx, "r2_cleanups", []):
c()
context.add_cleanup(_cleanup, context)
@given("r2plan-the service lists no complete strategize plans")
def step_no_strategize_plans(context: Any) -> None:
context.r2_mock_svc.list_plans.return_value = []
@given("r2plan-the service lists multiple complete strategize plans")
def step_multi_strategize_plans(context: Any) -> None:
p1 = _make_plan(
plan_id=_ulid("B1"),
processing_state=ProcessingState.COMPLETE,
)
p2 = _make_plan(
plan_id=_ulid("B2"),
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p1, p2]
@given("r2plan-the service lists exactly one complete strategize plan")
def step_one_strategize_plan(context: Any) -> None:
p = _make_plan(
plan_id=_ulid("C1"),
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p]
context.r2_mock_svc.get_plan.return_value = p
context.r2_mock_svc.execute_plan.return_value = p
@given("r2plan-the service lists no complete execute plans")
def step_no_execute_plans(context: Any) -> None:
context.r2_mock_svc.list_plans.return_value = []
@given("r2plan-the service lists multiple complete execute plans")
def step_multi_execute_plans(context: Any) -> None:
p1 = _make_plan(
plan_id=_ulid("D1"),
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
p2 = _make_plan(
plan_id=_ulid("D2"),
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p1, p2]
@given("r2plan-the service lists exactly one complete execute plan")
def step_one_execute_plan(context: Any) -> None:
p = _make_plan(
plan_id=_ulid("E1"),
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p]
context.r2_mock_svc.apply_plan.return_value = p
@given("r2plan-the service lists no plans")
def step_no_plans(context: Any) -> None:
context.r2_mock_svc.list_plans.return_value = []
@given("r2plan-the service lists some plans")
def step_some_plans(context: Any) -> None:
plans = [_make_plan(plan_id=_ulid("F1"))]
context.r2_mock_svc.list_plans.return_value = plans
@given("r2plan-the service can get a plan by id")
def step_get_plan_by_id(context: Any) -> None:
plan = _make_plan(plan_id=_ulid("G1"))
context.r2_mock_svc.get_plan.return_value = plan
context.r2_plan_id = _ulid("G1")
@given("r2plan-the service can revert a plan")
def step_service_can_revert(context: Any) -> None:
plan = _make_plan(plan_id=_ulid("H1"))
context.r2_mock_svc.revert_plan.return_value = plan
@given("r2plan-the service lists a plan with 4 project links")
def step_plan_with_4_links(context: Any) -> None:
links = [ProjectLink(project_name=f"local/proj-{i}") for i in range(4)]
p = _make_plan(plan_id=_ulid("I1"), project_links=links)
context.r2_mock_svc.list_plans.return_value = [p]
# ---------------------------------------------------------------------------
# When steps - CLI use_action
# ---------------------------------------------------------------------------
@when('r2plan-I invoke use with action "{action}" and arg "{arg_str}"')
def step_invoke_use_with_arg(context: Any, action: str, arg_str: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--arg", arg_str],
)
# Capture the arguments passed to use_action for inspection
if context.r2_mock_svc.use_action.called:
call_kwargs = context.r2_mock_svc.use_action.call_args
context.r2_parsed_args = (
call_kwargs.kwargs.get("arguments", call_kwargs[1].get("arguments", {}))
if call_kwargs
else {}
)
else:
context.r2_parsed_args = {}
@when('r2plan-I invoke use with action "{action}" and automation profile "{profile}"')
def step_invoke_use_with_profile(context: Any, action: str, profile: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--automation-profile", profile],
)
@when('r2plan-I invoke use with action "{action}" and strategy actor "{actor}"')
def step_invoke_use_strategy_actor(context: Any, action: str, actor: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--strategy-actor", actor],
)
@when('r2plan-I invoke use with action "{action}" and execution actor "{actor}"')
def step_invoke_use_execution_actor(context: Any, action: str, actor: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--execution-actor", actor],
)
# ---------------------------------------------------------------------------
# When steps - CLI execute / lifecycle-apply
# ---------------------------------------------------------------------------
@when("r2plan-I invoke execute without plan_id")
def step_invoke_execute_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["execute"])
@when("r2plan-I invoke lifecycle-apply without plan_id")
def step_invoke_apply_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-apply", "--yes"])
# ---------------------------------------------------------------------------
# When steps - CLI lifecycle-list
# ---------------------------------------------------------------------------
@when('r2plan-I invoke lifecycle-list with phase "{phase}"')
def step_invoke_list_phase(context: Any, phase: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-list", "--phase", phase])
@when('r2plan-I invoke lifecycle-list with state "{state}"')
def step_invoke_list_state(context: Any, state: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-list", "--state", state])
@when("r2plan-I invoke lifecycle-list")
def step_invoke_lifecycle_list(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-list"])
# ---------------------------------------------------------------------------
# When steps - CLI revert
# ---------------------------------------------------------------------------
@when('r2plan-I invoke revert with plan "{pid}" and invalid phase "{phase}"')
def step_invoke_revert_invalid(context: Any, pid: str, phase: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["revert", pid, "--to-phase", phase])
@when('r2plan-I invoke revert with plan "{pid}" and format "{fmt}"')
def step_invoke_revert_json(context: Any, pid: str, fmt: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["revert", pid, "--format", fmt])
# ---------------------------------------------------------------------------
# When steps - CLI status
# ---------------------------------------------------------------------------
@when("r2plan-I invoke status without plan_id")
def step_invoke_status_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["status"])
@when('r2plan-I invoke status without plan_id and format "{fmt}"')
def step_invoke_status_no_id_fmt(context: Any, fmt: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["status", "--format", fmt])
@when('r2plan-I invoke status with plan_id and format "{fmt}"')
def step_invoke_status_with_id_fmt(context: Any, fmt: str) -> None:
pid = getattr(context, "r2_plan_id", _ulid("G1"))
context.r2_result = _runner.invoke(plan_app, ["status", pid, "--format", fmt])
# ---------------------------------------------------------------------------
# When steps - CLI correct
# ---------------------------------------------------------------------------
@when('r2plan-I invoke correct with mode "{mode}"')
def step_invoke_correct_bad_mode(context: Any, mode: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["correct", "DEC-001", "--mode", mode, "--guidance", "fix it", "--yes"],
)
@when("r2plan-I invoke correct with empty guidance")
def step_invoke_correct_empty_guidance(context: Any) -> None:
context.r2_result = _runner.invoke(
plan_app,
["correct", "DEC-001", "--mode", "revert", "--guidance", "", "--yes"],
)
# ---------------------------------------------------------------------------
# When steps - CLI diff
# ---------------------------------------------------------------------------
@when('r2plan-I invoke diff with plan "{pid}" and correction "{corr}"')
def step_invoke_diff_correction(context: Any, pid: str, corr: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["diff", pid, "--correction", corr],
)
# ---------------------------------------------------------------------------
# Then steps - CLI command assertions
# ---------------------------------------------------------------------------
@then("r2plan-the command should abort")
def step_cmd_abort(context: Any) -> None:
assert context.r2_result.exit_code != 0, (
f"Expected abort but got exit code {context.r2_result.exit_code}.\n"
f"Output: {context.r2_result.output}"
)
@then("r2plan-the command should succeed")
def step_cmd_succeed(context: Any) -> None:
assert context.r2_result.exit_code == 0, (
f"Expected success but got exit code {context.r2_result.exit_code}.\n"
f"Output: {context.r2_result.output}"
)
@then('r2plan-the output should contain "{text}"')
def step_cli_output_contains(context: Any, text: str) -> None:
output = context.r2_result.output
assert text in output, f"Expected '{text}' in output:\n{output}"
# -- use_action argument parsing assertions --
@then('r2plan-the parsed arguments should have "{key}" as int {val:d}')
def step_arg_int(context: Any, key: str, val: int) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) == val, f"Expected {key}={val}, got {args.get(key)}"
assert isinstance(args[key], int)
@then('r2plan-the parsed arguments should have "{key}" as float {val:g}')
def step_arg_float(context: Any, key: str, val: float) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) == val, f"Expected {key}={val}, got {args.get(key)}"
assert isinstance(args[key], float)
@then('r2plan-the parsed arguments should have "{key}" as bool true')
def step_arg_bool_true(context: Any, key: str) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) is True, f"Expected {key}=True, got {args.get(key)}"
@then('r2plan-the parsed arguments should have "{key}" as bool false')
def step_arg_bool_false(context: Any, key: str) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) is False, f"Expected {key}=False, got {args.get(key)}"
@then('r2plan-the parsed arguments should have "{key}" as string "{val}"')
def step_arg_string(context: Any, key: str, val: str) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) == val, f"Expected {key}='{val}', got {args.get(key)}"
assert isinstance(args[key], str)