Files
temp/features/steps/plan_apply_service_branch_coverage_steps.py
freemo 2eb31a598c test(coverage): add Behave BDD tests for 8 under-covered modules
Added targeted Behave BDD feature files and step definitions to improve
unit test coverage for:

- decision_service.py: Full coverage of all 7 service methods (18 scenarios)
- plan_apply_service.py: Branch coverage for handle_merge_failure (2 scenarios)
- plan_executor.py: Edge cases for rollback, checkpoint, and parse_steps (15 scenarios)
- cli/commands/plan.py: Uncovered region lines 1950-2273 (23 scenarios)
- repositories.py: Remaining missed branches and lines (14 scenarios)
- sandbox/checkpoint.py: Full coverage of CheckpointManager (26 scenarios)
- langgraph/bridge.py: Remaining uncovered lines and branches (10 scenarios)
- cli/commands/config.py: Safety net to maintain 100% coverage (42 scenarios)

Total: 150 new scenarios, 596 steps, all passing.

Also fixed a step definition collision in plan_lifecycle_coverage by renaming
"the delete result should be false" to "the plan delete result should be false".

ISSUES CLOSED: #475
2026-03-01 03:09:51 +00:00

242 lines
8.4 KiB
Python

"""Step definitions for plan_apply_service_branch_coverage feature.
Targets the missed branch at line 426→432 in plan_apply_service.py.
When self._logger.error() raises an exception inside handle_merge_failure,
execution skips the ``return plan`` statement and exits via exception
propagation. This file exercises both the normal (return) and exception
(propagation) paths.
"""
from __future__ import annotations
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 (
PlanApplyService,
)
from cleveragents.domain.models.core.plan import (
PlanPhase,
PlanTimestamps,
ProcessingState,
)
__all__: list[str] = []
# Plan / changeset IDs used across scenarios
_PLAN_ID = "01BRANCHTEST000000000001"
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
class _StubPhase:
"""Lightweight stub that mirrors PlanPhase comparison."""
def __init__(self, phase: PlanPhase) -> None:
self._phase = phase
self.value = phase.value
def __eq__(self, other: object) -> bool:
if isinstance(other, PlanPhase):
return self._phase == other
if isinstance(other, _StubPhase):
return self._phase == other._phase
return NotImplemented
class _StubState:
"""Lightweight stub that mirrors ProcessingState comparison."""
def __init__(self, state: ProcessingState) -> None:
self._state = state
self.value = state.value
def __eq__(self, other: object) -> bool:
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,
error_details: dict[str, str] | None = None,
) -> MagicMock:
"""Create a mock Plan with sensible defaults."""
plan = MagicMock()
plan.identity.plan_id = plan_id
plan.phase = _StubPhase(phase)
plan.processing_state = _StubState(state)
plan.changeset_id = None
plan.validation_summary = None
plan.is_terminal = False
plan.error_details = error_details
plan.timestamps = PlanTimestamps()
plan.sandbox_refs = ["sandbox-ref-branch"]
return plan
def _make_lifecycle_mock(plan: MagicMock) -> MagicMock:
"""Create a mock PlanLifecycleService that returns *plan*."""
lifecycle = MagicMock()
lifecycle.get_plan.return_value = plan
lifecycle._commit_plan = MagicMock()
lifecycle.fail_apply = MagicMock(return_value=plan)
return lifecycle
# ======================================================================
# Scenario: logger.error raises → exception propagates (branch 426→432)
# ======================================================================
@given("pas_branch a service whose logger.error will raise RuntimeError")
def step_service_logger_error_raises(context: Context) -> None:
"""Build a PlanApplyService and patch _logger.error to raise."""
plan = _make_mock_plan(error_details=None)
context.branch_plan = plan
lifecycle = _make_lifecycle_mock(plan)
context.branch_lifecycle = lifecycle
service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
# Replace the logger's .error method so it raises
mock_logger = MagicMock()
mock_logger.error.side_effect = RuntimeError("simulated logger failure")
# Keep other logger methods working
mock_logger.info = MagicMock()
mock_logger.warning = MagicMock()
mock_logger.debug = MagicMock()
service._logger = mock_logger
context.branch_service = service
context.branch_mock_logger = mock_logger
@when("pas_branch I call handle_merge_failure and capture any exception")
def step_call_handle_merge_failure_capture(context: Context) -> None:
"""Call handle_merge_failure and capture any raised exception."""
context.branch_raised_error = None
context.branch_returned_plan = None
try:
context.branch_returned_plan = context.branch_service.handle_merge_failure(
plan_id=_PLAN_ID,
conflict_details="conflicting changes in main.py",
)
except RuntimeError as exc:
context.branch_raised_error = exc
@then("pas_branch a RuntimeError should have been raised")
def step_runtime_error_raised(context: Context) -> None:
"""Assert that a RuntimeError was raised from the logger."""
assert context.branch_raised_error is not None, (
"Expected RuntimeError to propagate from handle_merge_failure, "
"but no exception was raised"
)
assert isinstance(context.branch_raised_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.branch_raised_error).__name__}"
)
assert "simulated logger failure" in str(context.branch_raised_error)
@then("pas_branch the plan error_details should still contain merge_conflict")
def step_plan_has_merge_conflict(context: Context) -> None:
"""Assert error_details were set before the logger blew up."""
details = context.branch_plan.error_details
assert details is not None, "error_details should have been set before the error"
assert "merge_conflict" in details, (
f"Expected 'merge_conflict' in error_details, got keys: {list(details.keys())}"
)
assert details["merge_conflict"] == "conflicting changes in main.py"
assert details["sandbox_rollback"] == "pending"
@then("pas_branch lifecycle _commit_plan should have been invoked before the error")
def step_commit_plan_invoked(context: Context) -> None:
"""Assert _commit_plan was called (it runs before the logger call)."""
context.branch_lifecycle._commit_plan.assert_called_once()
@then("pas_branch lifecycle fail_apply should have been invoked before the error")
def step_fail_apply_invoked(context: Context) -> None:
"""Assert fail_apply was called (it runs before the logger call)."""
context.branch_lifecycle.fail_apply.assert_called_once_with(
_PLAN_ID,
"Merge failed: conflicting changes in main.py",
)
# ======================================================================
# Scenario: logger.error succeeds → normal return (branch 426→431)
# ======================================================================
@given("pas_branch a service whose logger.error will succeed normally")
def step_service_logger_error_succeeds(context: Context) -> None:
"""Build a PlanApplyService with a working (mocked) logger."""
plan = _make_mock_plan(error_details={"pre_existing": "value"})
context.branch_plan = plan
errored_plan = _make_mock_plan(
state=ProcessingState.ERRORED,
error_details={"pre_existing": "value", "merge_conflict": "test"},
)
lifecycle = _make_lifecycle_mock(plan)
lifecycle.fail_apply.return_value = errored_plan
context.branch_lifecycle = lifecycle
service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=None,
)
# Replace logger with a mock that does NOT raise
mock_logger = MagicMock()
service._logger = mock_logger
context.branch_service = service
context.branch_mock_logger = mock_logger
@when("pas_branch I call handle_merge_failure normally")
def step_call_handle_merge_failure_normal(context: Context) -> None:
"""Call handle_merge_failure expecting it to succeed."""
context.branch_returned_plan = context.branch_service.handle_merge_failure(
plan_id=_PLAN_ID,
conflict_details="minor conflict in utils.py",
)
@then("pas_branch the returned plan should not be None")
def step_returned_plan_not_none(context: Context) -> None:
"""Assert the method returned a plan (did not raise)."""
assert context.branch_returned_plan is not None, (
"Expected handle_merge_failure to return a plan, got None"
)
@then(
"pas_branch the service logger.error should have been called with merge failure details"
)
def step_logger_error_called(context: Context) -> None:
"""Assert logger.error was called with the expected arguments."""
context.branch_mock_logger.error.assert_called_once_with(
"Merge failure handled",
plan_id=_PLAN_ID,
conflict_details="minor conflict in utils.py",
)