forked from HAL9000/cleveragents-core
2eb31a598c
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
242 lines
8.4 KiB
Python
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",
|
|
)
|