"""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]}'" )