e10463419e
CI / push-validation (pull_request) Successful in 9s
CI / helm (pull_request) Successful in 25s
CI / lint (pull_request) Failing after 40s
CI / typecheck (pull_request) Successful in 55s
CI / build (pull_request) Successful in 3m18s
CI / quality (pull_request) Successful in 3m41s
CI / integration_tests (pull_request) Successful in 4m0s
CI / security (pull_request) Successful in 4m27s
CI / coverage (pull_request) Has been skipped
CI / unit_tests (pull_request) Failing after 5m43s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 7m43s
CI / status-check (pull_request) Failing after 2s
- Capture started_at timestamp using datetime.now(UTC) before service call - Add timing.started field to JSON envelope with ISO 8601 format - Update step definitions to verify timing.started is present and valid - Remove @tdd_expected_fail tag from plan_prompt_command.feature scenario Fixes #9353
294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""Step definitions for cross_plan_correction_coverage_boost.feature.
|
|
|
|
Targets uncovered lines in cross_plan_correction_service.py:
|
|
- Lines 60, 76, 92: Protocol method bodies (the ``...`` stubs)
|
|
- Line 127: ``raise ValidationError`` for unrecognised ChildPlanState
|
|
- Line 276: ``else []`` branch when rejection is None on rejected cascade
|
|
- Lines 412-416: ``except`` handler in ``_rollback_completed_actions``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.application.services.cross_plan_correction_service import (
|
|
ChildPlanCanceller,
|
|
ChildPlanLookup,
|
|
CrossPlanCorrectionService,
|
|
SandboxRollbacker,
|
|
classify_cascade_action,
|
|
)
|
|
from cleveragents.core.exceptions import ValidationError
|
|
from cleveragents.domain.models.core.correction import (
|
|
CascadeAction,
|
|
CascadeResult,
|
|
ChildPlanState,
|
|
)
|
|
|
|
# -------------------------------------------------------------------
|
|
# Protocol-body subclasses — call super() to execute the ``...`` stub
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
class _LookupSubclass(ChildPlanLookup):
|
|
"""Calls super() to exercise ChildPlanLookup protocol body."""
|
|
|
|
def get_child_plan_state(self, child_plan_id: str) -> ChildPlanState:
|
|
result = super().get_child_plan_state(child_plan_id) # type: ignore[safe-super]
|
|
return result # type: ignore[return-value]
|
|
|
|
|
|
class _CancellerSubclass(ChildPlanCanceller):
|
|
"""Calls super() to exercise ChildPlanCanceller protocol body."""
|
|
|
|
def cancel_child_plan(self, child_plan_id: str) -> None:
|
|
return super().cancel_child_plan(child_plan_id) # type: ignore[safe-super]
|
|
|
|
|
|
class _RollbackerSubclass(SandboxRollbacker):
|
|
"""Calls super() to exercise SandboxRollbacker protocol body."""
|
|
|
|
def rollback_child_plan_sandbox(self, child_plan_id: str) -> None:
|
|
return super().rollback_child_plan_sandbox(child_plan_id) # type: ignore[safe-super]
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Simple mock implementations for service construction
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
class _SimpleLookup:
|
|
"""Minimal lookup returning NOT_STARTED for any plan."""
|
|
|
|
def get_child_plan_state(self, child_plan_id: str) -> ChildPlanState:
|
|
return ChildPlanState.NOT_STARTED
|
|
|
|
|
|
class _SimpleCanceller:
|
|
"""Minimal canceller that records calls."""
|
|
|
|
def __init__(self) -> None:
|
|
self.cancelled: list[str] = []
|
|
|
|
def cancel_child_plan(self, child_plan_id: str) -> None:
|
|
self.cancelled.append(child_plan_id)
|
|
|
|
|
|
class _SimpleRollbacker:
|
|
"""Minimal rollbacker that records calls."""
|
|
|
|
def __init__(self) -> None:
|
|
self.rolled_back: list[str] = []
|
|
|
|
def rollback_child_plan_sandbox(self, child_plan_id: str) -> None:
|
|
self.rolled_back.append(child_plan_id)
|
|
|
|
|
|
# ===================================================================
|
|
# Protocol body coverage — lines 60, 76, 92
|
|
# ===================================================================
|
|
|
|
|
|
@when("I call ChildPlanLookup protocol body through a subclass")
|
|
def step_call_lookup_protocol_body(context: object) -> None:
|
|
instance = _LookupSubclass()
|
|
context.protocol_result = instance.get_child_plan_state("test-plan") # type: ignore[attr-defined]
|
|
|
|
|
|
@then("the protocol body should return None")
|
|
def step_protocol_body_none(context: object) -> None:
|
|
# The ``...`` (Ellipsis) expression is the protocol body. It
|
|
# evaluates as an expression statement and the method implicitly
|
|
# returns None.
|
|
assert context.protocol_result is None, ( # type: ignore[attr-defined]
|
|
f"Expected None, got {context.protocol_result}" # type: ignore[attr-defined]
|
|
)
|
|
|
|
|
|
@when("I call ChildPlanCanceller protocol body through a subclass")
|
|
def step_call_canceller_protocol_body(context: object) -> None:
|
|
instance = _CancellerSubclass()
|
|
context.canceller_protocol_result = instance.cancel_child_plan("test-plan") # type: ignore[attr-defined]
|
|
|
|
|
|
@then("the canceller protocol body should return None")
|
|
def step_canceller_protocol_body_none(context: object) -> None:
|
|
assert context.canceller_protocol_result is None, ( # type: ignore[attr-defined]
|
|
f"Expected None, got {context.canceller_protocol_result}" # type: ignore[attr-defined]
|
|
)
|
|
|
|
|
|
@when("I call SandboxRollbacker protocol body through a subclass")
|
|
def step_call_rollbacker_protocol_body(context: object) -> None:
|
|
instance = _RollbackerSubclass()
|
|
context.rollbacker_protocol_result = instance.rollback_child_plan_sandbox(
|
|
"test-plan"
|
|
) # type: ignore[attr-defined]
|
|
|
|
|
|
@then("the rollbacker protocol body should return None")
|
|
def step_rollbacker_protocol_body_none(context: object) -> None:
|
|
assert context.rollbacker_protocol_result is None, ( # type: ignore[attr-defined]
|
|
f"Expected None, got {context.rollbacker_protocol_result}" # type: ignore[attr-defined]
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# classify_cascade_action with unrecognised state — line 127
|
|
# ===================================================================
|
|
|
|
|
|
@when("I classify cascade action for an unrecognised state value")
|
|
def step_classify_unrecognised_state(context: object) -> None:
|
|
try:
|
|
# Pass a plain string that does not match any ChildPlanState member.
|
|
classify_cascade_action("totally_unknown_state") # type: ignore[arg-type]
|
|
context.boost_error = None # type: ignore[attr-defined]
|
|
except ValidationError as exc:
|
|
context.boost_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@then('a cascade ValidationError should be raised mentioning "{fragment}"')
|
|
def step_cascade_validation_error_with_fragment(context: object, fragment: str) -> None:
|
|
assert context.boost_error is not None, (
|
|
"Expected ValidationError but none was raised"
|
|
) # type: ignore[attr-defined]
|
|
assert isinstance(context.boost_error, ValidationError), ( # type: ignore[attr-defined]
|
|
f"Expected ValidationError, got {type(context.boost_error)}" # type: ignore[attr-defined]
|
|
)
|
|
assert fragment in str(context.boost_error), ( # type: ignore[attr-defined]
|
|
f"'{fragment}' not found in error message: {context.boost_error}" # type: ignore[attr-defined]
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# execute_cascade — rejected=True, rejection=None — line 276
|
|
# ===================================================================
|
|
|
|
|
|
@given("a cross-plan correction service for coverage boost")
|
|
def step_create_boost_service(context: object) -> None:
|
|
lookup = _SimpleLookup()
|
|
canceller = _SimpleCanceller()
|
|
rollbacker = _SimpleRollbacker()
|
|
context.boost_lookup = lookup # type: ignore[attr-defined]
|
|
context.boost_canceller = canceller # type: ignore[attr-defined]
|
|
context.boost_rollbacker = rollbacker # type: ignore[attr-defined]
|
|
context.boost_service = CrossPlanCorrectionService( # type: ignore[attr-defined]
|
|
plan_lookup=lookup,
|
|
plan_canceller=canceller,
|
|
sandbox_rollbacker=rollbacker,
|
|
)
|
|
context.boost_cascade_result = None # type: ignore[attr-defined]
|
|
context.boost_error = None # type: ignore[attr-defined]
|
|
|
|
|
|
@given("evaluate_cascade is patched to return rejected with no rejection object")
|
|
def step_patch_evaluate_cascade(context: object) -> None:
|
|
"""Prepare a patched evaluate_cascade that returns rejected=True
|
|
but rejection=None, which normally never happens but the code
|
|
defensively handles it (line 276 else branch)."""
|
|
fake_result = CascadeResult(
|
|
correction_id="C-BOOST",
|
|
cascade_actions=[],
|
|
rejected=True,
|
|
rejection=None,
|
|
all_cancelled_plan_ids=[],
|
|
all_rolled_back_plan_ids=[],
|
|
)
|
|
context.boost_fake_result = fake_result # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I execute a cascade for correction "{cid}" with child plans "{plans}"')
|
|
def step_execute_cascade_boost(context: object, cid: str, plans: str) -> None:
|
|
plan_ids = [p.strip() for p in plans.split(",")]
|
|
service = context.boost_service # type: ignore[attr-defined]
|
|
|
|
# If a fake result was prepared, patch evaluate_cascade to return it
|
|
if hasattr(context, "boost_fake_result") and context.boost_fake_result is not None: # type: ignore[attr-defined]
|
|
original_evaluate = service.evaluate_cascade
|
|
|
|
def patched_evaluate(
|
|
correction_id: str, affected_child_plan_ids: list[str]
|
|
) -> CascadeResult:
|
|
return context.boost_fake_result # type: ignore[attr-defined]
|
|
|
|
service.evaluate_cascade = patched_evaluate # type: ignore[attr-defined]
|
|
try:
|
|
context.boost_cascade_result = service.execute_cascade(cid, plan_ids) # type: ignore[attr-defined]
|
|
finally:
|
|
service.evaluate_cascade = original_evaluate # type: ignore[attr-defined]
|
|
else:
|
|
context.boost_cascade_result = service.execute_cascade(cid, plan_ids) # type: ignore[attr-defined]
|
|
|
|
|
|
@then("the cascade result should be rejected with no rejection details")
|
|
def step_cascade_rejected_no_rejection(context: object) -> None:
|
|
result = context.boost_cascade_result # type: ignore[attr-defined]
|
|
assert result is not None, "No cascade result captured"
|
|
assert result.rejected is True, "Expected cascade to be rejected"
|
|
assert result.rejection is None, (
|
|
f"Expected rejection to be None, got {result.rejection}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# _rollback_completed_actions exception handler — lines 412-416
|
|
# ===================================================================
|
|
|
|
|
|
@given("the module logger info method is patched to raise an error")
|
|
def step_patch_logger_to_raise(context: object) -> None:
|
|
"""Mark that the logger should be patched to raise during rollback."""
|
|
context.boost_patch_logger = True # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I call rollback_completed_actions with one completed action")
|
|
def step_call_rollback_with_failing_logger(context: object) -> None:
|
|
service = context.boost_service # type: ignore[attr-defined]
|
|
action = CascadeAction(
|
|
child_plan_id="CP-ROLLBACK-TEST",
|
|
child_plan_state=ChildPlanState.NOT_STARTED,
|
|
action="cancel",
|
|
sandbox_rolled_back=False,
|
|
)
|
|
|
|
# Patch the module-level logger so that logger.info raises during
|
|
# _rollback_completed_actions, hitting lines 412-416.
|
|
mock_logger = MagicMock()
|
|
mock_logger.info.side_effect = RuntimeError("Simulated logging failure")
|
|
# logger.error must still work so the except block can log the error
|
|
mock_logger.error = MagicMock()
|
|
|
|
with patch(
|
|
"cleveragents.application.services.cross_plan_correction_service.logger",
|
|
mock_logger,
|
|
):
|
|
try:
|
|
service._rollback_completed_actions([action])
|
|
context.boost_rollback_error = None # type: ignore[attr-defined]
|
|
except Exception as exc:
|
|
context.boost_rollback_error = exc # type: ignore[attr-defined]
|
|
|
|
# Store the mock for assertion
|
|
context.boost_mock_logger = mock_logger # type: ignore[attr-defined]
|
|
|
|
|
|
@then("the rollback should complete without raising")
|
|
def step_rollback_no_raise(context: object) -> None:
|
|
assert context.boost_rollback_error is None, ( # type: ignore[attr-defined]
|
|
f"Expected no error, but got: {context.boost_rollback_error}" # type: ignore[attr-defined]
|
|
)
|
|
# Verify the except handler called logger.error (line 413-416)
|
|
mock_logger = context.boost_mock_logger # type: ignore[attr-defined]
|
|
assert mock_logger.error.called, (
|
|
"Expected logger.error to be called in the except handler"
|
|
)
|
|
# Check that the error log included the expected event key
|
|
call_args = mock_logger.error.call_args
|
|
assert "cross_plan_correction.rollback_action_failed" in str(call_args), (
|
|
f"Expected rollback_action_failed event, got: {call_args}"
|
|
)
|