Files
temp/features/steps/plan_cli_spec_print_r2_steps.py
freemo 3a2b134f3c test(coverage): add Behave scenarios for remaining under-tested modules
Added Behave BDD feature files and step definitions targeting coverage
gaps in six modules:

- container.py: exercise get_database_url env-var fallback, AI provider
  None path, cached container singleton, override_providers edge cases
  (lines 66-69, 125-130; branches at 51, 57, 82, 87, 256, 284-288)
- correction_service.py: exercise exception-handling paths in
  execute_revert and execute_append via monkeypatched analyze_impact
  and ULID failures (lines 254-262, 320-328)
- plan_lifecycle_service.py: exercise _persisted UoW commit paths,
  InvalidPhaseTransitionError custom message branch, non-reusable
  action archive, and error_details merge logic (branches at 100,
  216, 237, 327, 461, 570, 576, 607)
- plan.py (CLI): exercise spec-dict optional field branches,
  _print_lifecycle_plan conditional rendering, use_action argument
  parsing, auto-resolve paths, legacy wrappers, and validation
  error branches across 66 scenarios
- skill.py (CLI): exercise singleton cache, timestamp-absent show,
  no-tools MCP, add/remove/list/show format and error branches
  across 26 scenarios
- models.py (DB): exercise to_domain/from_domain None-field branches
  in SkillModel, SessionModel, ToolModel, LifecycleActionModel,
  LifecyclePlanModel, NamespacedProjectModel, and SessionMessageModel
  across 41 scenarios

All 302 features, 6503 scenarios, 28271 steps pass (nox -e unit_tests).

ISSUES CLOSED: #446
2026-02-25 19:38:43 -05:00

397 lines
13 KiB
Python

"""Step definitions for plan_cli_spec_print_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 1 of 3.
Covers:
- ``_plan_spec_dict``: project link alias/read_only, automation_profile,
invariants, validation_summary/dod, last_completed_step, last_checkpoint_id
- ``_print_lifecycle_plan``: definition_of_done, dod evaluation pass/fail,
arguments with/without order, automation profile, invariants, resume
metadata, project link alias/read_only, long description
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
from datetime import datetime
from io import StringIO
from typing import Any
from unittest.mock import 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.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
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,
)
def _capture_print(plan: Any) -> str:
"""Call _print_lifecycle_plan and capture the console output."""
buf = StringIO()
from rich.console import Console as RichConsole
test_console = RichConsole(file=buf, width=200, no_color=True)
with patch.object(plan_module, "console", test_console):
_print_lifecycle_plan(plan, title="R2 Test")
return buf.getvalue()
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given('r2plan-a v3 Plan with a project link that has alias "{alias}"')
def step_plan_link_alias(context: Any, alias: str) -> None:
link = ProjectLink(project_name="local/api", alias=alias)
context.r2_plan = _make_plan(project_links=[link])
@given("r2plan-a v3 Plan with a project link that is read_only")
def step_plan_link_readonly(context: Any) -> None:
link = ProjectLink(project_name="local/docs", read_only=True)
context.r2_plan = _make_plan(project_links=[link])
@given("r2plan-a v3 Plan with a plain project link")
def step_plan_link_plain(context: Any) -> None:
link = ProjectLink(project_name="local/plain")
context.r2_plan = _make_plan(project_links=[link])
@given('r2plan-a v3 Plan with automation_profile "{profile}"')
def step_plan_with_automation_profile(context: Any, profile: str) -> None:
ap = AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
context.r2_plan = _make_plan(automation_profile=ap)
@given("r2plan-a v3 Plan without automation_profile")
def step_plan_without_automation_profile(context: Any) -> None:
context.r2_plan = _make_plan(automation_profile=None)
@given("r2plan-a v3 Plan with invariants")
def step_plan_with_invariants(context: Any) -> None:
invs = [
PlanInvariant(text="No new warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Coverage >= 80%", source=InvariantSource.ACTION),
]
context.r2_plan = _make_plan(invariants=invs)
@given("r2plan-a v3 Plan without invariants")
def step_plan_without_invariants(context: Any) -> None:
context.r2_plan = _make_plan(invariants=[])
@given("r2plan-a v3 Plan with dod validation summary")
def step_plan_with_dod_validation(context: Any) -> None:
vs = {
"dod_evaluated": True,
"dod_all_passed": True,
"required_passed": 3,
"required_failed": 0,
}
context.r2_plan = _make_plan(validation_summary=vs)
@given("r2plan-a v3 Plan without validation_summary")
def step_plan_without_validation_summary(context: Any) -> None:
context.r2_plan = _make_plan(validation_summary=None)
@given("r2plan-a v3 Plan with last_completed_step {n:d}")
def step_plan_with_step(context: Any, n: int) -> None:
context.r2_plan = _make_plan(last_completed_step=n)
@given("r2plan-a v3 Plan with last_completed_step default")
def step_plan_with_step_default(context: Any) -> None:
context.r2_plan = _make_plan(last_completed_step=-1)
@given('r2plan-a v3 Plan with last_checkpoint_id "{chk}"')
def step_plan_with_checkpoint(context: Any, chk: str) -> None:
context.r2_plan = _make_plan(last_checkpoint_id=chk)
@given("r2plan-a v3 Plan without last_checkpoint_id")
def step_plan_without_checkpoint(context: Any) -> None:
context.r2_plan = _make_plan(last_checkpoint_id=None)
@given('r2plan-a v3 Plan with definition_of_done "{dod}"')
def step_plan_with_dod(context: Any, dod: str) -> None:
context.r2_plan = _make_plan(definition_of_done=dod)
@given("r2plan-a v3 Plan with definition_of_done longer than 200 chars")
def step_plan_with_long_dod(context: Any) -> None:
dod = "x" * 250
context.r2_plan = _make_plan(definition_of_done=dod)
@given("r2plan-a v3 Plan without definition_of_done")
def step_plan_without_dod(context: Any) -> None:
context.r2_plan = _make_plan(definition_of_done=None)
@given("r2plan-a v3 Plan with dod evaluated as passed")
def step_plan_dod_passed(context: Any) -> None:
vs = {
"dod_evaluated": True,
"dod_all_passed": True,
"required_passed": 5,
"required_failed": 0,
}
context.r2_plan = _make_plan(validation_summary=vs)
@given("r2plan-a v3 Plan with dod evaluated as failed")
def step_plan_dod_failed(context: Any) -> None:
vs = {
"dod_evaluated": True,
"dod_all_passed": False,
"required_passed": 2,
"required_failed": 3,
}
context.r2_plan = _make_plan(validation_summary=vs)
@given("r2plan-a v3 Plan with arguments and arguments_order")
def step_plan_with_args_order(context: Any) -> None:
context.r2_plan = _make_plan(
arguments={"target_coverage": 80, "format": "json"},
arguments_order=["target_coverage", "format"],
)
@given("r2plan-a v3 Plan with arguments but no arguments_order")
def step_plan_with_args_no_order(context: Any) -> None:
context.r2_plan = _make_plan(
arguments={"beta_key": "val", "alpha_key": "val2"},
arguments_order=[],
)
@given("r2plan-a v3 Plan with description longer than 200 chars")
def step_plan_with_long_description(context: Any) -> None:
context.r2_plan = _make_plan(description="D" * 250)
@given("r2plan-a v3 Plan with resume metadata")
def step_plan_with_resume_metadata(context: Any) -> None:
context.r2_plan = _make_plan(
last_completed_step=5,
last_checkpoint_id="01CHKPTRESUME000000000000",
)
@given("r2plan-a v3 Plan with project link alias and read_only")
def step_plan_with_link_alias_readonly(context: Any) -> None:
link = ProjectLink(project_name="local/ref-data", alias="data", read_only=True)
context.r2_plan = _make_plan(project_links=[link])
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("r2plan-I call _plan_spec_dict")
def step_call_spec_dict(context: Any) -> None:
context.r2_spec = _plan_spec_dict(context.r2_plan)
@when("r2plan-I call _print_lifecycle_plan")
def step_call_print_plan(context: Any) -> None:
context.r2_output = _capture_print(context.r2_plan)
# ---------------------------------------------------------------------------
# Then steps - spec dict assertions
# ---------------------------------------------------------------------------
@then('r2plan-the spec dict project_links should include alias "{alias}"')
def step_spec_alias(context: Any, alias: str) -> None:
links = context.r2_spec["project_links"]
assert any(link.get("alias") == alias for link in links), (
f"No link with alias '{alias}' in {links}"
)
@then("r2plan-the spec dict project_links should include read_only true")
def step_spec_readonly(context: Any) -> None:
links = context.r2_spec["project_links"]
assert any(link.get("read_only") is True for link in links), (
f"No link with read_only=True in {links}"
)
@then("r2plan-the spec dict project_links should not include alias")
def step_spec_no_alias(context: Any) -> None:
links = context.r2_spec["project_links"]
assert all("alias" not in link for link in links), f"Unexpected alias in {links}"
@then("r2plan-the spec dict project_links should not include read_only")
def step_spec_no_readonly(context: Any) -> None:
links = context.r2_spec["project_links"]
assert all("read_only" not in link for link in links), (
f"Unexpected read_only in {links}"
)
@then('r2plan-the spec dict automation_profile should be "{profile}"')
def step_spec_profile(context: Any, profile: str) -> None:
assert context.r2_spec["automation_profile"] == profile, (
f"Expected '{profile}', got '{context.r2_spec['automation_profile']}'"
)
@then("r2plan-the spec dict automation_profile should be null")
def step_spec_profile_null(context: Any) -> None:
assert context.r2_spec["automation_profile"] is None
@then('r2plan-the spec dict should contain key "{key}"')
def step_spec_has_key(context: Any, key: str) -> None:
assert key in context.r2_spec, (
f"Key '{key}' not in spec dict: {list(context.r2_spec.keys())}"
)
@then('r2plan-the spec dict should not contain key "{key}"')
def step_spec_no_key(context: Any, key: str) -> None:
assert key not in context.r2_spec, f"Key '{key}' should not be in spec dict"
@then("r2plan-the spec dict invariants count should be {n:d}")
def step_spec_inv_count(context: Any, n: int) -> None:
actual = len(context.r2_spec["invariants"])
assert actual == n, f"Expected {n} invariants, got {actual}"
@then("r2plan-the spec dict dod_evaluation all_passed should be true")
def step_spec_dod_passed(context: Any) -> None:
assert context.r2_spec["dod_evaluation"]["all_passed"] is True
@then("r2plan-the spec dict last_completed_step should be {n:d}")
def step_spec_step(context: Any, n: int) -> None:
assert context.r2_spec["last_completed_step"] == n
# -- print output assertions --
@then('r2plan-the printed output should contain "{text}"')
def step_output_contains(context: Any, text: str) -> None:
assert text in context.r2_output, (
f"Expected '{text}' in output:\n{context.r2_output}"
)
@then('r2plan-the printed output should not contain "{text}"')
def step_output_not_contains(context: Any, text: str) -> None:
assert text not in context.r2_output, (
f"Did not expect '{text}' in output:\n{context.r2_output}"
)