Files
temp/features/steps/plan_apply_service_coverage_steps.py
Luis Mendes ab911dbdc4 fix(cli): write machine-readable formats directly to stdout bypassing Rich line-wrapping
The format_output() function returned a string that callers passed to
Rich console.print(), which wraps long lines at terminal width.  This
injected literal newline characters into JSON string values (e.g. in
definition_of_done fields), producing invalid JSON that downstream
parsers could not decode (JSONDecodeError: Invalid control character).

For machine-readable formats (json, yaml, plain), format_output() now
writes the rendered output directly to sys.stdout and returns an empty
string.  This preserves the exact serialization from json.dumps/
yaml.dump without Rich text processing artifacts.

Refs: #746
2026-03-17 09:53:57 +00:00

1188 lines
43 KiB
Python

"""Step definitions for plan_apply_service_coverage feature.
Covers all code paths in plan_apply_service.py including helper functions,
service methods, edge cases, and error branches.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.application.services.plan_apply_service import (
_OP_COLORS,
ApplyOutcome,
ApplyResult,
PlanApplyService,
_build_artifacts_dict,
_operation_label,
_render_diff_json,
_render_diff_plain,
_render_diff_rich,
)
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
SpecChangeSet,
)
from cleveragents.domain.models.core.plan import (
PlanPhase,
PlanTimestamps,
ProcessingState,
)
__all__: list[str] = []
# Plan ID constant for tests
_PLAN_ID = "01PLANTEST000000000000001"
_CHANGESET_ID = "01CSTEST0000000000000001"
# ----------------------------------------------------------------------
# Stub / helper factories
# ----------------------------------------------------------------------
class _StubPhase:
"""Stub for PlanPhase with a .value attribute."""
def __init__(self, phase: PlanPhase) -> None:
"""Initialize with the real enum member."""
self._phase = phase
self.value = phase.value
def __eq__(self, other: object) -> bool:
"""Compare against PlanPhase or another _StubPhase."""
if isinstance(other, PlanPhase):
return self._phase == other
if isinstance(other, _StubPhase):
return self._phase == other._phase
return NotImplemented
class _StubState:
"""Stub for ProcessingState with a .value attribute."""
def __init__(self, state: ProcessingState) -> None:
"""Initialize with the real enum member."""
self._state = state
self.value = state.value
def __eq__(self, other: object) -> bool:
"""Compare against ProcessingState or another _StubState."""
if isinstance(other, ProcessingState):
return self._state == other
if isinstance(other, _StubState):
return self._state == other._state
return NotImplemented
def _make_mock_plan(
*,
plan_id: str = _PLAN_ID,
phase: PlanPhase = PlanPhase.EXECUTE,
state: ProcessingState = ProcessingState.COMPLETE,
changeset_id: str | None = _CHANGESET_ID,
validation_summary: dict[str, Any] | None = None,
is_terminal: bool = False,
error_details: dict[str, str] | None = None,
sandbox_refs: list[str] | None = None,
) -> MagicMock:
"""Create a mock Plan object with sensible defaults."""
plan = MagicMock()
plan.identity.plan_id = plan_id
plan.phase = _StubPhase(phase)
plan.processing_state = _StubState(state)
plan.changeset_id = changeset_id
plan.validation_summary = validation_summary
plan.is_terminal = is_terminal
plan.error_details = error_details
plan.timestamps = PlanTimestamps()
plan.sandbox_refs = sandbox_refs or ["sandbox-ref-1"]
return plan
def _make_changeset(
plan_id: str = _PLAN_ID,
changeset_id: str = _CHANGESET_ID,
entries: list[ChangeEntry] | None = None,
) -> SpecChangeSet:
"""Create a SpecChangeSet with optional entries."""
cs = SpecChangeSet(changeset_id=changeset_id, plan_id=plan_id)
if entries:
for entry in entries:
cs.add_change(entry)
return cs
def _make_modify_entry(
plan_id: str = _PLAN_ID,
path: str = "src/app.py",
before_hash: str | None = "abcdef0123456789",
after_hash: str | None = "123456abcdefghij",
) -> ChangeEntry:
"""Create a modify ChangeEntry."""
return ChangeEntry(
plan_id=plan_id,
resource_id="RES001",
tool_name="builtin/test-tool",
operation=ChangeOperation.MODIFY,
path=path,
before_hash=before_hash,
after_hash=after_hash,
)
def _make_create_entry(
plan_id: str = _PLAN_ID,
path: str = "src/new_file.py",
after_hash: str | None = "aaaaaa1111111111",
) -> ChangeEntry:
"""Create a create ChangeEntry."""
return ChangeEntry(
plan_id=plan_id,
resource_id="RES002",
tool_name="builtin/test-tool",
operation=ChangeOperation.CREATE,
path=path,
before_hash=None,
after_hash=after_hash,
)
def _make_delete_entry(
plan_id: str = _PLAN_ID,
path: str = "src/old_file.py",
before_hash: str | None = "bbbbbb2222222222",
) -> ChangeEntry:
"""Create a delete ChangeEntry."""
return ChangeEntry(
plan_id=plan_id,
resource_id="RES003",
tool_name="builtin/test-tool",
operation=ChangeOperation.DELETE,
path=path,
before_hash=before_hash,
after_hash=None,
)
def _make_rename_entry(
plan_id: str = _PLAN_ID,
path: str = "src/renamed.py",
) -> ChangeEntry:
"""Create a rename ChangeEntry."""
return ChangeEntry(
plan_id=plan_id,
resource_id="RES004",
tool_name="builtin/test-tool",
operation=ChangeOperation.RENAME,
path=path,
before_hash=None,
after_hash=None,
)
def _make_lifecycle_mock() -> MagicMock:
"""Create a mock PlanLifecycleService."""
lifecycle = MagicMock()
lifecycle._commit_plan = MagicMock()
lifecycle.complete_apply = MagicMock()
lifecycle.constrain_apply = MagicMock()
lifecycle.fail_apply = MagicMock()
return lifecycle
def _make_service(
lifecycle: MagicMock | None = None,
plan: MagicMock | None = None,
changeset_store: Any | None = None,
) -> PlanApplyService:
"""Create a PlanApplyService with mocked dependencies."""
lc = lifecycle or _make_lifecycle_mock()
if plan is not None:
lc.get_plan.return_value = plan
svc = PlanApplyService(
lifecycle_service=lc,
changeset_store=changeset_store,
)
return svc
# ======================================================================
# _operation_label steps
# ======================================================================
@when('pas_cov I call _operation_label with "{op}"')
def step_call_operation_label(context: Context, op: str) -> None:
"""Call _operation_label with the given operation string."""
context.pas_label_result = _operation_label(op)
@then('pas_cov the label should be "{expected}"')
def step_label_should_be(context: Context, expected: str) -> None:
"""Assert _operation_label returned the expected string."""
assert context.pas_label_result == expected, (
f"Expected label '{expected}', got '{context.pas_label_result}'"
)
# ======================================================================
# SpecChangeSet construction steps (shared)
# ======================================================================
@given("pas_cov a SpecChangeSet with no entries")
def step_changeset_no_entries(context: Context) -> None:
"""Create an empty SpecChangeSet."""
context.pas_changeset = _make_changeset()
@given("pas_cov a SpecChangeSet with a modify entry having both hashes")
def step_changeset_modify_both_hashes(context: Context) -> None:
"""Create a SpecChangeSet with one modify entry that has both hashes."""
entry = _make_modify_entry()
context.pas_changeset = _make_changeset(entries=[entry])
@given("pas_cov a SpecChangeSet with a create entry having only after_hash")
def step_changeset_create_after_hash(context: Context) -> None:
"""Create a SpecChangeSet with one create entry that has only after_hash."""
entry = _make_create_entry()
context.pas_changeset = _make_changeset(entries=[entry])
@given("pas_cov a SpecChangeSet with a delete entry having only before_hash")
def step_changeset_delete_before_hash(context: Context) -> None:
"""Create a SpecChangeSet with one delete entry that has only before_hash."""
entry = _make_delete_entry()
context.pas_changeset = _make_changeset(entries=[entry])
@given('pas_cov a SpecChangeSet with an entry having operation "{op}"')
def step_changeset_with_operation(context: Context, op: str) -> None:
"""Create a SpecChangeSet with an entry having the given operation."""
if op == "rename":
entry = _make_rename_entry()
elif op == "create":
entry = _make_create_entry()
elif op == "delete":
entry = _make_delete_entry()
else:
entry = _make_modify_entry()
context.pas_changeset = _make_changeset(entries=[entry])
# ======================================================================
# _render_diff_plain steps
# ======================================================================
@when("pas_cov I render diff plain")
def step_render_diff_plain(context: Context) -> None:
"""Call _render_diff_plain with the context changeset."""
context.pas_diff_result = _render_diff_plain(context.pas_changeset)
@then('pas_cov the plain diff should be "{expected}"')
def step_plain_diff_exact(context: Context, expected: str) -> None:
"""Assert the plain diff output matches exactly."""
assert context.pas_diff_result == expected, (
f"Expected '{expected}', got '{context.pas_diff_result}'"
)
@then('pas_cov the plain diff should contain "{text}"')
def step_plain_diff_contains(context: Context, text: str) -> None:
"""Assert the plain diff output contains the given text."""
assert text in context.pas_diff_result, (
f"Expected plain diff to contain '{text}', got:\n{context.pas_diff_result}"
)
@then('pas_cov the plain diff should not contain "{text}"')
def step_plain_diff_not_contains(context: Context, text: str) -> None:
"""Assert the plain diff output does not contain the given text."""
assert text not in context.pas_diff_result, (
f"Expected plain diff NOT to contain '{text}', got:\n{context.pas_diff_result}"
)
# ======================================================================
# _render_diff_rich steps
# ======================================================================
@when("pas_cov I render diff rich")
def step_render_diff_rich(context: Context) -> None:
"""Call _render_diff_rich with the context changeset."""
context.pas_diff_result = _render_diff_rich(context.pas_changeset)
@then('pas_cov the rich diff should contain "{text}"')
def step_rich_diff_contains(context: Context, text: str) -> None:
"""Assert the rich diff output contains the given text."""
assert text in context.pas_diff_result, (
f"Expected rich diff to contain '{text}', got:\n{context.pas_diff_result}"
)
@then('pas_cov the rich diff should not contain "{text}"')
def step_rich_diff_not_contains(context: Context, text: str) -> None:
"""Assert the rich diff output does not contain the given text."""
assert text not in context.pas_diff_result, (
f"Expected rich diff NOT to contain '{text}', got:\n{context.pas_diff_result}"
)
# ======================================================================
# _render_diff_json steps
# ======================================================================
@when("pas_cov I render diff json")
def step_render_diff_json(context: Context) -> None:
"""Call _render_diff_json with the context changeset."""
context.pas_json_result = _render_diff_json(context.pas_changeset)
@then('pas_cov the json diff should have key "{key}"')
def step_json_diff_has_key(context: Context, key: str) -> None:
"""Assert the JSON diff dict has the given key."""
assert key in context.pas_json_result, (
f"Expected JSON diff to have key '{key}', keys: {list(context.pas_json_result.keys())}"
)
@then('pas_cov the json diff should have key "{key}" with value {value}')
def step_json_diff_key_value(context: Context, key: str, value: str) -> None:
"""Assert the JSON diff dict has the given key with the given value."""
assert key in context.pas_json_result, f"Expected key '{key}' in JSON diff"
actual = context.pas_json_result[key]
expected = int(value) if value.isdigit() else value
assert actual == expected, f"Expected '{key}' to be {expected}, got {actual}"
@then("pas_cov the json diff entries should have length {n}")
def step_json_diff_entries_length(context: Context, n: str) -> None:
"""Assert the JSON diff entries list has the given length."""
entries = context.pas_json_result["entries"]
assert len(entries) == int(n), f"Expected {n} entries, got {len(entries)}"
@then('pas_cov the json diff first entry should have "{key}" equal to "{value}"')
def step_json_diff_first_entry_value(context: Context, key: str, value: str) -> None:
"""Assert the first entry in JSON diff has the given key/value."""
entry = context.pas_json_result["entries"][0]
assert entry[key] == value, (
f"Expected entry['{key}'] == '{value}', got '{entry[key]}'"
)
@then('pas_cov the json diff first entry should have key "{key}"')
def step_json_diff_first_entry_has_key(context: Context, key: str) -> None:
"""Assert the first entry in JSON diff has the given key."""
entry = context.pas_json_result["entries"][0]
assert key in entry, (
f"Expected entry to have key '{key}', keys: {list(entry.keys())}"
)
# ======================================================================
# _build_artifacts_dict steps
# ======================================================================
@given('pas_cov a mock plan with changeset_id "{cs_id}"')
def step_mock_plan_with_changeset_id(context: Context, cs_id: str) -> None:
"""Create a mock plan with the given changeset_id."""
context.pas_plan = _make_mock_plan(changeset_id=cs_id)
@given('pas_cov a mock plan with changeset_id "{cs_id}" and validation summary')
def step_mock_plan_with_validation(context: Context, cs_id: str) -> None:
"""Create a mock plan with changeset_id and validation summary."""
context.pas_plan = _make_mock_plan(
changeset_id=cs_id,
validation_summary={"total": 3, "required_passed": 2, "required_failed": 1},
)
@given("pas_cov a mock plan with apply summary in error_details")
def step_mock_plan_with_apply_summary(context: Context) -> None:
"""Create a mock plan with apply summary in error_details."""
context.pas_plan = _make_mock_plan(
changeset_id=_CHANGESET_ID,
validation_summary=None,
error_details={
"apply_files_changed": "5",
"apply_validations_run": "3",
},
)
@when("pas_cov I build artifacts dict")
def step_build_artifacts_dict(context: Context) -> None:
"""Call _build_artifacts_dict with plan and changeset."""
context.pas_artifacts = _build_artifacts_dict(
context.pas_plan,
context.pas_changeset,
)
@when("pas_cov I build artifacts dict with no changeset")
def step_build_artifacts_dict_no_changeset(context: Context) -> None:
"""Call _build_artifacts_dict with plan and None changeset."""
context.pas_artifacts = _build_artifacts_dict(context.pas_plan, None)
@then('pas_cov the artifacts dict should have key "{key}"')
def step_artifacts_dict_has_key(context: Context, key: str) -> None:
"""Assert artifacts dict contains the given key."""
assert key in context.pas_artifacts, (
f"Expected artifacts to have key '{key}', keys: {list(context.pas_artifacts.keys())}"
)
@then("pas_cov the artifacts dict files_changed list should have length {n}")
def step_artifacts_files_changed_length(context: Context, n: str) -> None:
"""Assert the files_changed list has the expected length."""
fc = context.pas_artifacts["files_changed"]
assert len(fc) == int(n), f"Expected files_changed length {n}, got {len(fc)}"
@then("pas_cov the artifacts dict changeset_summary should be null")
def step_artifacts_changeset_summary_null(context: Context) -> None:
"""Assert changeset_summary is None in artifacts dict."""
assert context.pas_artifacts["changeset_summary"] is None, (
f"Expected changeset_summary to be None, got {context.pas_artifacts['changeset_summary']}"
)
@then('pas_cov the artifacts dict apply_summary files_changed should be "{value}"')
def step_artifacts_apply_summary_files(context: Context, value: str) -> None:
"""Assert apply_summary.files_changed has the expected value."""
summary = context.pas_artifacts["apply_summary"]
assert summary["files_changed"] == value, (
f"Expected files_changed '{value}', got '{summary['files_changed']}'"
)
# ======================================================================
# PlanApplyService initialization steps
# ======================================================================
@when("pas_cov I create PlanApplyService with None lifecycle service")
def step_create_service_none_lifecycle(context: Context) -> None:
"""Attempt to create PlanApplyService with None lifecycle."""
context.pas_error = None
try:
PlanApplyService(lifecycle_service=None) # type: ignore[arg-type]
except ValidationError as exc:
context.pas_error = exc
@then("pas_cov a ValidationError should be raised")
def step_validation_error_raised(context: Context) -> None:
"""Assert that a ValidationError was raised."""
assert context.pas_error is not None, "Expected ValidationError but none was raised"
assert isinstance(context.pas_error, ValidationError), (
f"Expected ValidationError, got {type(context.pas_error).__name__}"
)
@given("pas_cov a mock lifecycle service")
def step_mock_lifecycle(context: Context) -> None:
"""Create a mock lifecycle service on context."""
context.pas_lifecycle = _make_lifecycle_mock()
@given("pas_cov a mock changeset store")
def step_mock_changeset_store(context: Context) -> None:
"""Create a mock changeset store on context."""
context.pas_store = MagicMock()
@when("pas_cov I create PlanApplyService with valid lifecycle and no store")
def step_create_service_no_store(context: Context) -> None:
"""Create PlanApplyService with valid lifecycle and no store."""
context.pas_service = PlanApplyService(
lifecycle_service=context.pas_lifecycle,
changeset_store=None,
)
@when("pas_cov I create PlanApplyService with valid lifecycle and store")
def step_create_service_with_store(context: Context) -> None:
"""Create PlanApplyService with valid lifecycle and store."""
context.pas_service = PlanApplyService(
lifecycle_service=context.pas_lifecycle,
changeset_store=context.pas_store,
)
@then("pas_cov the service should be created successfully")
def step_service_created(context: Context) -> None:
"""Assert the PlanApplyService instance was created."""
assert context.pas_service is not None, "Service should not be None"
assert isinstance(context.pas_service, PlanApplyService), (
f"Expected PlanApplyService, got {type(context.pas_service).__name__}"
)
# ======================================================================
# diff method steps
# ======================================================================
@given("pas_cov a service with a plan that has no changeset_id")
def step_service_plan_no_changeset(context: Context) -> None:
"""Create a service with a plan whose changeset_id is None."""
plan = _make_mock_plan(changeset_id=None)
context.pas_plan = plan
context.pas_service = _make_service(plan=plan)
@given("pas_cov a service with a plan that has a changeset with entries")
def step_service_plan_with_changeset_entries(context: Context) -> None:
"""Create a service with a plan whose changeset has entries in the store."""
plan = _make_mock_plan(changeset_id=_CHANGESET_ID)
context.pas_plan = plan
cs = _make_changeset(entries=[_make_modify_entry()])
store = MagicMock()
store.get.return_value = cs
context.pas_changeset = cs
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@given("pas_cov a service with a plan that has an empty changeset")
def step_service_plan_empty_changeset(context: Context) -> None:
"""Create a service with a plan whose changeset has no entries."""
plan = _make_mock_plan(changeset_id=_CHANGESET_ID)
context.pas_plan = plan
cs = _make_changeset()
store = MagicMock()
store.get.return_value = cs
context.pas_changeset = cs
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@when("pas_cov I call diff on the service")
def step_call_diff_default(context: Context) -> None:
"""Call diff on the service with default format."""
context.pas_error = None
try:
context.pas_diff_result = context.pas_service.diff(_PLAN_ID)
except PlanError as exc:
context.pas_error = exc
@when('pas_cov I call diff with format "{fmt}"')
def step_call_diff_format(context: Context, fmt: str) -> None:
"""Call diff on the service with specified format."""
context.pas_error = None
try:
context.pas_diff_result = context.pas_service.diff(_PLAN_ID, fmt=fmt)
except PlanError as exc:
context.pas_error = exc
@then('pas_cov a PlanError should be raised with message containing "{text}"')
def step_plan_error_raised_with_message(context: Context, text: str) -> None:
"""Assert a PlanError was raised with the given text in its message."""
assert context.pas_error is not None, "Expected PlanError but none was raised"
assert isinstance(context.pas_error, PlanError), (
f"Expected PlanError, got {type(context.pas_error).__name__}"
)
assert text in str(context.pas_error), (
f"Expected message to contain '{text}', got: {context.pas_error}"
)
@then('pas_cov the diff result should contain "{text}"')
def step_diff_result_contains(context: Context, text: str) -> None:
"""Assert the diff result contains the given text."""
assert text in context.pas_diff_result, (
f"Expected diff result to contain '{text}', got:\n{context.pas_diff_result}"
)
@then('pas_cov the diff result should not contain "{text}"')
def step_diff_result_not_contains(context: Context, text: str) -> None:
"""Assert the diff result does not contain the given text."""
assert text not in context.pas_diff_result, (
f"Expected diff result NOT to contain '{text}'"
)
@then("pas_cov the diff result should be valid JSON")
def step_diff_result_is_json(context: Context) -> None:
"""Assert the diff result is valid JSON."""
try:
parsed = json.loads(context.pas_diff_result)
assert isinstance(parsed, dict), "Expected JSON object"
except json.JSONDecodeError as exc:
assert False, f"Diff result is not valid JSON: {exc}" # noqa: B011
# ======================================================================
# artifacts method steps
# ======================================================================
@when('pas_cov I call artifacts with format "{fmt}"')
def step_call_artifacts_format(context: Context, fmt: str) -> None:
"""Call artifacts on the service with specified format."""
from contextlib import redirect_stdout
from io import StringIO
buf = StringIO()
with redirect_stdout(buf):
result = context.pas_service.artifacts(_PLAN_ID, fmt=fmt)
context.pas_artifacts_result = result or buf.getvalue().rstrip("\n")
@then('pas_cov the artifacts result should contain "{text}"')
def step_artifacts_result_contains(context: Context, text: str) -> None:
"""Assert artifacts result contains the given text."""
assert text in context.pas_artifacts_result, (
f"Expected artifacts to contain '{text}', got:\n{context.pas_artifacts_result}"
)
@then("pas_cov the artifacts result should be a non-empty string")
def step_artifacts_result_non_empty(context: Context) -> None:
"""Assert artifacts result is a non-empty string."""
assert isinstance(context.pas_artifacts_result, str), "Expected string"
assert len(context.pas_artifacts_result) > 0, "Expected non-empty string"
# ======================================================================
# persist_apply_summary steps
# ======================================================================
@given("pas_cov a service with a plan that has no error_details")
def step_service_plan_no_error_details(context: Context) -> None:
"""Create a service with a plan that has no error_details."""
plan = _make_mock_plan(error_details=None)
context.pas_plan = plan
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
lifecycle.fail_apply.return_value = _make_mock_plan(
state=ProcessingState.ERRORED, is_terminal=True
)
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
@given("pas_cov a service with a plan that has existing error_details")
def step_service_plan_existing_error_details(context: Context) -> None:
"""Create a service with a plan that has pre-existing error_details."""
plan = _make_mock_plan(
error_details={"existing_key": "existing_value"},
)
context.pas_plan = plan
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
lifecycle.fail_apply.return_value = _make_mock_plan(
state=ProcessingState.ERRORED, is_terminal=True
)
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
@when(
"pas_cov I persist apply summary with {files} files and {validations} validations"
)
def step_persist_apply_summary(context: Context, files: str, validations: str) -> None:
"""Call persist_apply_summary on the service."""
context.pas_returned_plan = context.pas_service.persist_apply_summary(
plan_id=_PLAN_ID,
files_changed=int(files),
validations_run=int(validations),
)
@then('pas_cov the plan error_details should contain "{key}" with value "{value}"')
def step_plan_error_details_value(context: Context, key: str, value: str) -> None:
"""Assert the plan error_details has the given key with expected value."""
details = context.pas_plan.error_details
assert details is not None, "error_details should not be None"
assert key in details, f"Expected key '{key}' in error_details, got: {details}"
assert details[key] == value, (
f"Expected error_details['{key}'] == '{value}', got '{details[key]}'"
)
@then('pas_cov the plan error_details should contain key "{key}"')
def step_plan_error_details_has_key(context: Context, key: str) -> None:
"""Assert the plan error_details has the given key."""
details = context.pas_plan.error_details
assert details is not None, "error_details should not be None"
assert key in details, (
f"Expected key '{key}' in error_details, keys: {list(details.keys())}"
)
@then("pas_cov the lifecycle _commit_plan should have been called")
def step_commit_plan_called(context: Context) -> None:
"""Assert lifecycle._commit_plan was called."""
context.pas_lifecycle._commit_plan.assert_called()
# ======================================================================
# handle_merge_failure steps
# ======================================================================
@when('pas_cov I handle merge failure with conflict "{details}"')
def step_handle_merge_failure(context: Context, details: str) -> None:
"""Call handle_merge_failure on the service."""
context.pas_returned_plan = context.pas_service.handle_merge_failure(
plan_id=_PLAN_ID,
conflict_details=details,
)
@then("pas_cov the lifecycle fail_apply should have been called")
def step_fail_apply_called(context: Context) -> None:
"""Assert lifecycle.fail_apply was called."""
context.pas_lifecycle.fail_apply.assert_called()
@then("pas_cov the returned plan should reflect errored state")
def step_returned_plan_errored(context: Context) -> None:
"""Assert the returned plan is in errored state."""
assert context.pas_returned_plan is not None, "Returned plan should not be None"
@then('pas_cov the committed plan error_details should contain "{key}"')
def step_committed_error_details_contains(context: Context, key: str) -> None:
"""Assert the plan error_details set before commit contains the key."""
# The error_details were set on the mock plan directly
details = context.pas_plan.error_details
assert details is not None, "error_details should not be None"
assert key in details, (
f"Expected key '{key}' in committed error_details, keys: {list(details.keys())}"
)
# ======================================================================
# guard_empty_changeset steps
# ======================================================================
@when("pas_cov I call guard_empty_changeset")
def step_call_guard(context: Context) -> None:
"""Call guard_empty_changeset on the service."""
context.pas_error = None
try:
context.pas_guard_result = context.pas_service.guard_empty_changeset(_PLAN_ID)
except PlanError as exc:
context.pas_error = exc
@when("pas_cov I call guard_empty_changeset with allow_empty true")
def step_call_guard_allow_empty(context: Context) -> None:
"""Call guard_empty_changeset with allow_empty=True."""
context.pas_error = None
try:
context.pas_guard_result = context.pas_service.guard_empty_changeset(
_PLAN_ID, allow_empty=True
)
except PlanError as exc:
context.pas_error = exc
@then("pas_cov the guard should return true")
def step_guard_returns_true(context: Context) -> None:
"""Assert the guard returned True."""
assert context.pas_guard_result is True, (
f"Expected guard to return True, got {context.pas_guard_result}"
)
# ======================================================================
# _extract_validation_counts steps
# ======================================================================
@when("pas_cov I extract validation counts from None")
def step_extract_counts_none(context: Context) -> None:
"""Call _extract_validation_counts with None."""
context.pas_counts = PlanApplyService._extract_validation_counts(None)
@when(
"pas_cov I extract validation counts from full summary {p} passed {f} failed {t} total"
)
def step_extract_counts_full(context: Context, p: str, f: str, t: str) -> None:
"""Call _extract_validation_counts with a full summary dict."""
vs = {
"required_passed": int(p),
"required_failed": int(f),
"total": int(t),
}
context.pas_counts = PlanApplyService._extract_validation_counts(vs)
@when("pas_cov I extract validation counts from partial summary {p} passed {f} failed")
def step_extract_counts_no_total(context: Context, p: str, f: str) -> None:
"""Call _extract_validation_counts with no total key."""
vs = {
"required_passed": int(p),
"required_failed": int(f),
}
context.pas_counts = PlanApplyService._extract_validation_counts(vs)
@then("pas_cov the counts should be {p} passed {f} failed {t} total")
def step_assert_counts(context: Context, p: str, f: str, t: str) -> None:
"""Assert the extracted validation counts match."""
expected = (int(p), int(f), int(t))
assert context.pas_counts == expected, (
f"Expected counts {expected}, got {context.pas_counts}"
)
# ======================================================================
# _resolve_changeset steps
# ======================================================================
@when("pas_cov I call _resolve_changeset")
def step_call_resolve_changeset(context: Context) -> None:
"""Call _resolve_changeset on the service."""
plan = context.pas_plan
context.pas_resolved_cs = context.pas_service._resolve_changeset(plan)
@then("pas_cov the resolved changeset should be None")
def step_resolved_cs_none(context: Context) -> None:
"""Assert _resolve_changeset returned None."""
assert context.pas_resolved_cs is None, (
f"Expected None, got {context.pas_resolved_cs}"
)
@then("pas_cov the resolved changeset should have entries")
def step_resolved_cs_has_entries(context: Context) -> None:
"""Assert _resolve_changeset returned a changeset with entries."""
assert context.pas_resolved_cs is not None, "Resolved changeset should not be None"
assert len(context.pas_resolved_cs.entries) > 0, (
f"Expected entries, got {len(context.pas_resolved_cs.entries)}"
)
@then("pas_cov the resolved changeset should have no entries")
def step_resolved_cs_no_entries(context: Context) -> None:
"""Assert _resolve_changeset returned a changeset with no entries."""
assert context.pas_resolved_cs is not None, "Resolved changeset should not be None"
assert len(context.pas_resolved_cs.entries) == 0, (
f"Expected 0 entries, got {len(context.pas_resolved_cs.entries)}"
)
@then("pas_cov the resolved changeset should have the plan changeset_id")
def step_resolved_cs_has_changeset_id(context: Context) -> None:
"""Assert the resolved changeset has the plan's changeset_id."""
assert context.pas_resolved_cs.changeset_id == context.pas_plan.changeset_id, (
f"Expected changeset_id '{context.pas_plan.changeset_id}', "
f"got '{context.pas_resolved_cs.changeset_id}'"
)
@given(
"pas_cov a service with a plan that has changeset_id and store returns a changeset"
)
def step_service_store_returns_changeset(context: Context) -> None:
"""Create a service where the store returns a populated changeset."""
plan = _make_mock_plan(changeset_id=_CHANGESET_ID)
context.pas_plan = plan
cs = _make_changeset(entries=[_make_modify_entry()])
store = MagicMock()
store.get.return_value = cs
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@given("pas_cov a service with a plan that has changeset_id but store returns None")
def step_service_store_returns_none(context: Context) -> None:
"""Create a service where the store returns None (miss)."""
plan = _make_mock_plan(changeset_id=_CHANGESET_ID)
context.pas_plan = plan
store = MagicMock()
store.get.return_value = None
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@given("pas_cov a service with a plan that has changeset_id and no store")
def step_service_no_store(context: Context) -> None:
"""Create a service with no changeset store configured."""
plan = _make_mock_plan(changeset_id=_CHANGESET_ID)
context.pas_plan = plan
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
# ======================================================================
# cleanup_changeset steps
# ======================================================================
@given("pas_cov a service with a mock lifecycle")
def step_service_mock_lifecycle(context: Context) -> None:
"""Create a basic service with a mock lifecycle."""
lifecycle = _make_lifecycle_mock()
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
@when("pas_cov I call cleanup_changeset with empty plan_id")
def step_cleanup_empty_plan_id(context: Context) -> None:
"""Call cleanup_changeset with empty plan_id."""
context.pas_error = None
try:
context.pas_service.cleanup_changeset("")
except ValidationError as exc:
context.pas_error = exc
@given("pas_cov a service with a store that has delete_for_plan returning {n}")
def step_service_store_with_delete(context: Context, n: str) -> None:
"""Create a service whose store.delete_for_plan returns n."""
store = MagicMock()
store.delete_for_plan.return_value = int(n)
lifecycle = _make_lifecycle_mock()
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@when('pas_cov I call cleanup_changeset with plan_id "{plan_id}"')
def step_cleanup_with_plan_id(context: Context, plan_id: str) -> None:
"""Call cleanup_changeset with the given plan_id."""
context.pas_cleanup_result = context.pas_service.cleanup_changeset(plan_id)
@then("pas_cov cleanup should return {n}")
def step_cleanup_returns(context: Context, n: str) -> None:
"""Assert cleanup_changeset returned the expected count."""
assert context.pas_cleanup_result == int(n), (
f"Expected cleanup to return {n}, got {context.pas_cleanup_result}"
)
@given("pas_cov a service with no changeset store")
def step_service_no_changeset_store(context: Context) -> None:
"""Create a service with changeset_store=None."""
lifecycle = _make_lifecycle_mock()
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
@given("pas_cov a service with a store that lacks delete_for_plan")
def step_service_store_no_delete(context: Context) -> None:
"""Create a service whose store has no delete_for_plan attribute."""
class _StubStore:
"""Stub store without delete_for_plan method."""
def get(self, changeset_id: str) -> None:
"""Return None for any lookup."""
return None
lifecycle = _make_lifecycle_mock()
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=_StubStore(),
)
# ======================================================================
# apply_with_validation_gate: exception branches
# ======================================================================
@given("pas_cov a service where constrain_apply raises PlanError")
def step_service_constrain_raises(context: Context) -> None:
"""Create a service where constrain_apply raises PlanError."""
plan = _make_mock_plan(
changeset_id=_CHANGESET_ID,
is_terminal=False,
)
context.pas_plan = plan
cs = _make_changeset(entries=[_make_modify_entry()])
store = MagicMock()
store.get.return_value = cs
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
lifecycle.constrain_apply.side_effect = PlanError("Not in Apply phase")
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@given("pas_cov the plan has failed validations")
def step_plan_failed_validations(context: Context) -> None:
"""Set the plan's validation_summary to have failed validations."""
context.pas_plan.validation_summary = {
"total": 3,
"required_passed": 1,
"required_failed": 2,
}
@given("pas_cov a service where complete_apply raises PlanError")
def step_service_complete_raises(context: Context) -> None:
"""Create a service where complete_apply raises PlanError."""
plan = _make_mock_plan(
changeset_id=_CHANGESET_ID,
is_terminal=False,
)
context.pas_plan = plan
cs = _make_changeset(entries=[_make_modify_entry()])
store = MagicMock()
store.get.return_value = cs
lifecycle = _make_lifecycle_mock()
lifecycle.get_plan.return_value = plan
lifecycle.complete_apply.side_effect = PlanError("Not in Apply phase")
context.pas_lifecycle = lifecycle
context.pas_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
@given("pas_cov the plan has passing validations and entries")
def step_plan_passing_validations(context: Context) -> None:
"""Set the plan's validation_summary to all passing."""
context.pas_plan.validation_summary = {
"total": 2,
"required_passed": 2,
"required_failed": 0,
}
@when("pas_cov I call apply_with_validation_gate")
def step_call_apply_gate(context: Context) -> None:
"""Call apply_with_validation_gate on the service."""
context.pas_apply_result = context.pas_service.apply_with_validation_gate(
plan_id=_PLAN_ID,
)
@then('pas_cov the result outcome should be "{outcome}"')
def step_result_outcome(context: Context, outcome: str) -> None:
"""Assert the apply result outcome matches."""
expected = ApplyOutcome(outcome)
assert context.pas_apply_result.outcome == expected, (
f"Expected outcome '{outcome}', got '{context.pas_apply_result.outcome}'"
)
@then('pas_cov the result message should contain "{text}"')
def step_result_message_contains(context: Context, text: str) -> None:
"""Assert the apply result message contains the given text."""
assert text in context.pas_apply_result.message, (
f"Expected message to contain '{text}', got: {context.pas_apply_result.message}"
)
# ======================================================================
# ApplyOutcome and ApplyResult model steps
# ======================================================================
@then('pas_cov ApplyOutcome should have value "{value}"')
def step_apply_outcome_value(context: Context, value: str) -> None:
"""Assert ApplyOutcome enum contains the given value."""
outcome = ApplyOutcome(value)
assert outcome.value == value, (
f"Expected ApplyOutcome value '{value}', got '{outcome.value}'"
)
@when("pas_cov I create an ApplyResult with whitespace in message")
def step_create_apply_result_whitespace(context: Context) -> None:
"""Create an ApplyResult with whitespace in message field."""
context.pas_apply_result_model = ApplyResult(
outcome=ApplyOutcome.APPLIED,
plan_id=_PLAN_ID,
message=" hello world ",
)
@then("pas_cov the ApplyResult message should be stripped")
def step_apply_result_stripped(context: Context) -> None:
"""Assert the ApplyResult message had whitespace stripped."""
assert context.pas_apply_result_model.message == "hello world", (
f"Expected 'hello world', got '{context.pas_apply_result_model.message}'"
)
# ======================================================================
# _OP_COLORS steps
# ======================================================================
@then('pas_cov OP_COLORS should map "{key}" to "{value}"')
def step_op_colors_map(context: Context, key: str, value: str) -> None:
"""Assert _OP_COLORS maps the key to the expected value."""
assert key in _OP_COLORS, (
f"Expected key '{key}' in _OP_COLORS, keys: {list(_OP_COLORS.keys())}"
)
assert _OP_COLORS[key] == value, (
f"Expected _OP_COLORS['{key}'] == '{value}', got '{_OP_COLORS[key]}'"
)