Files
temp/features/steps/plan_service_coverage_boost_r2_steps.py
freemo 051ee7c290 test(coverage): add Behave BDD tests to improve coverage across 52 source files
Added 52 new .feature files and corresponding _steps.py files targeting
previously uncovered code paths in the following areas:

- TUI layer: app, commands, persona (state/schema/registry), widgets,
  input (shell_exec, reference_parser)
- Application services: plan lifecycle/service/executor, session,
  project, repo indexing, correction, checkpoint, actor, llm_actors,
  strategy coordinator, resource file watcher, service retry wiring
- CLI commands: session, resource, repl, plan, db, automation_profile
- Domain models: retry_policy, resource_type, cost_budget,
  docker_compose_analyzer, detail_level, _sql_string_aware,
  _postgresql_helpers
- Core: circuit_breaker, retry_service_patterns
- Infrastructure: repositories, transaction_sandbox, strategy_registry,
  plugins/loader, container
- Config: settings
- Agents: plan_generation, context_analysis, auto_debug
- A2A: facade

All new tests follow the Behave/Gherkin BDD standard. Resolved step
definition collisions with unique prefixes. Fixed Alembic fileConfig
logger disabling issue (disable_existing_loggers=False).

ISSUES CLOSED: #1068
2026-03-20 21:22:10 +00:00

415 lines
15 KiB
Python

"""Step definitions for plan_service_coverage_boost_r2.feature.
Targets uncovered lines in plan_service.py:
- Lines 851, 853: auto_debug_build exception path formats code context from
contexts with content (list comprehension filtering)
- Lines 922-923, 925-929, 931-932, 935: auto_debug_build successful fix branch
updates plan prompt and continues to retry
- Line 1036: apply_changes generic exception handler extracts operation.value
- Lines 1413-1416: _coerce_change_list defensive guard for invalid dict entry
"""
from __future__ import annotations
import tempfile
import warnings
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context as BehaveContext
from cleveragents.application.services.plan_service import PlanService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core import Change, OperationType, PlanStatus
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_settings() -> MagicMock:
"""Create a minimal Settings mock."""
settings = MagicMock(spec=Settings)
settings.is_langsmith_enabled = False
settings.langsmith_tags = []
settings.database_url = "sqlite://:memory:"
settings.provider_configuration_diagnostics.return_value = {}
settings.configured_provider_names.return_value = []
settings.provider_expected_env_vars.return_value = []
return settings
def _make_plan(
plan_id: int = 1,
project_id: int = 1,
prompt: str | None = "Build a feature",
) -> MagicMock:
"""Create a mock Plan with minimal attributes."""
plan = MagicMock()
plan.id = plan_id
plan.project_id = project_id
plan.prompt = prompt
plan.name = "test-plan"
plan.status = PlanStatus.PENDING
plan.updated_at = datetime.now()
return plan
def _make_project(project_id: int = 1, tmp_dir: str = "/tmp/test-proj") -> MagicMock:
"""Create a mock Project."""
project = MagicMock()
project.id = project_id
project.name = "test-project"
project.path = Path(tmp_dir)
return project
def _make_context_obj(plan_id: int, path: str, content: str | None) -> SimpleNamespace:
"""Create a lightweight context object mimicking the Context domain model."""
return SimpleNamespace(
id=1,
plan_id=plan_id,
path=path,
content=content,
)
def _make_uow_with_plan(plan: MagicMock) -> MagicMock:
"""Create a UoW mock whose transaction ctx returns the given plan."""
uow = MagicMock()
ctx = MagicMock()
ctx.plans.get_current_for_project.return_value = plan
ctx.plans.update.return_value = None
ctx.changes.get_for_plan.return_value = []
# debug_attempts.add returns an object with an id
added_attempt = MagicMock()
added_attempt.id = 100
ctx.debug_attempts.add.return_value = added_attempt
ctx.debug_attempts.get.return_value = None
ctx.debug_attempts.update.return_value = None
# contexts
ctx.contexts.get_for_plan.return_value = []
# Make the transaction context manager work
tx_cm = MagicMock()
tx_cm.__enter__ = MagicMock(return_value=ctx)
tx_cm.__exit__ = MagicMock(return_value=False)
uow.transaction.return_value = tx_cm
return uow, ctx
def _build_service(
uow: MagicMock,
llm: MagicMock | None = None,
) -> PlanService:
"""Build a PlanService suppressing the deprecation warning."""
settings = _make_settings()
actor_service = MagicMock()
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
svc = PlanService(
settings=settings,
unit_of_work=uow,
actor_service=actor_service,
llm=llm or MagicMock(),
)
return svc
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("I have a plan service configured for r2 coverage testing")
def step_plan_service_r2(context: BehaveContext) -> None:
"""Set up default plan and service for each scenario."""
context.r2_plan = _make_plan()
context.r2_uow, context.r2_ctx = _make_uow_with_plan(context.r2_plan)
context.r2_service = _build_service(context.r2_uow)
context.r2_project = _make_project()
context.r2_error = None
context.r2_result = None
# ---------------------------------------------------------------------------
# Given: auto_debug_build contexts
# ---------------------------------------------------------------------------
@given("the current plan has contexts with file content")
def step_plan_with_contexts(context: BehaveContext) -> None:
"""Populate the plan with contexts that have content (hits lines 851, 853)."""
contexts = [
_make_context_obj(1, "src/app.py", "print('hello')"),
_make_context_obj(1, "src/util.py", "def helper(): pass"),
# One context without content — should be filtered out by `if c.content`
_make_context_obj(1, "src/empty.py", None),
]
context.r2_ctx.contexts.get_for_plan.return_value = contexts
@given("the current plan has contexts where some lack content")
def step_plan_with_mixed_contexts(context: BehaveContext) -> None:
"""Populate with contexts where only some have content."""
contexts = [
_make_context_obj(1, "src/main.py", "import os"),
_make_context_obj(1, "src/no_content.py", None),
_make_context_obj(1, "src/also_empty.py", ""),
]
context.r2_ctx.contexts.get_for_plan.return_value = contexts
@given("the current plan has no prompt set")
def step_plan_no_prompt(context: BehaveContext) -> None:
"""Create a plan with no prompt so the prompt-append branch is skipped."""
context.r2_plan.prompt = None
# Re-wire contexts (same as with-content scenario)
contexts = [
_make_context_obj(1, "src/app.py", "print('hello')"),
]
context.r2_ctx.contexts.get_for_plan.return_value = contexts
# ---------------------------------------------------------------------------
# Given: build_plan behavior
# ---------------------------------------------------------------------------
@given("build_plan raises an exception on every attempt")
def step_build_always_fails(context: BehaveContext) -> None:
"""Patch build_plan to always raise."""
context.r2_service.build_plan = MagicMock(
side_effect=RuntimeError("Simulated build failure")
)
@given("build_plan fails on the first attempt then succeeds")
def step_build_fail_then_succeed(context: BehaveContext) -> None:
"""Patch build_plan to fail once then succeed."""
call_count = {"n": 0}
def _build_plan(project: Any, callback: Any = None) -> list:
call_count["n"] += 1
if call_count["n"] == 1:
raise RuntimeError("First attempt build failure")
return [] # empty changes = success
context.r2_service.build_plan = MagicMock(side_effect=_build_plan)
# ---------------------------------------------------------------------------
# Given: AutoDebugAgent behavior
# ---------------------------------------------------------------------------
@given("the auto debug agent returns an unsuccessful fix")
def step_agent_no_fix(context: BehaveContext) -> None:
"""Configure the mock agent to return an unsuccessful result."""
context.r2_agent_result = {
"error_message": "Simulated build failure",
"code_context": "",
"attempted_fixes": [],
"current_fix": {},
"fix_validated": False,
"messages": [],
"context": {},
"result": {"success": False, "fix": {}},
"error": None,
"metadata": {},
}
@given("the auto debug agent returns a successful fix")
def step_agent_successful_fix(context: BehaveContext) -> None:
"""Configure the mock agent to return a successful result (hits 922-935)."""
context.r2_agent_result = {
"error_message": "First attempt build failure",
"code_context": "",
"attempted_fixes": [],
"current_fix": {},
"fix_validated": True,
"messages": [],
"context": {},
"result": {
"success": True,
"fix": {
"description": "Fixed the import statement",
"code": "import os\nimport sys",
},
},
"error": None,
"metadata": {},
}
# ---------------------------------------------------------------------------
# Given: apply_changes setup
# ---------------------------------------------------------------------------
@given("the current plan has a pending CREATE change with a poison path")
def step_pending_change_poison(context: BehaveContext) -> None:
"""Set up a pending change that will trigger a generic exception on apply."""
change = MagicMock(spec=Change)
change.id = 10
change.plan_id = 1
change.file_path = "src/poison.py"
change.operation = OperationType.CREATE
change.new_content = "content"
change.new_path = None
change.applied = False
context.r2_ctx.changes.get_for_plan.return_value = [change]
# Update the plan mock for apply_changes
context.r2_plan.status = PlanStatus.BUILT
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when("I call auto_debug_build")
def step_call_auto_debug_build(context: BehaveContext) -> None:
"""Execute auto_debug_build with mocked AutoDebugAgent."""
mock_agent_cls = MagicMock()
mock_agent_instance = MagicMock()
mock_agent_instance.invoke.return_value = context.r2_agent_result
mock_agent_cls.return_value = mock_agent_instance
with (
patch(
"cleveragents.agents.AutoDebugAgent",
mock_agent_cls,
),
patch(
"cleveragents.agents.AutoDebugState",
dict,
),
):
# Also patch _prepare_langsmith_config to return empty dict
context.r2_service._prepare_langsmith_config = MagicMock(return_value={})
try:
result = context.r2_service.auto_debug_build(
context.r2_project, max_attempts=3
)
context.r2_result = result
except Exception as exc:
context.r2_error = exc
@when("I call apply_changes and expect an error")
def step_call_apply_changes_error(context: BehaveContext) -> None:
"""Call apply_changes with a change that triggers a generic exception.
Uses a real temporary directory for path resolution, and patches only
Path.write_text to raise OSError (not PlanError) so the generic
exception handler on line 1036 is reached.
"""
with tempfile.TemporaryDirectory() as tmpdir:
context.r2_project.path = Path(tmpdir)
with patch.object(Path, "write_text", side_effect=OSError("disk full")):
try:
context.r2_service.apply_changes(context.r2_project)
context.r2_error = None
except PlanError as exc:
context.r2_error = exc
except Exception as exc:
context.r2_error = exc
@when("I call _coerce_change_list with a dict that fails Change validation")
def step_call_coerce_change_list_invalid(context: BehaveContext) -> None:
"""Call _coerce_change_list with a dict that will fail Change(**entry_dict).
An empty dict lacks required fields (plan_id, file_path, operation),
triggering a validation error inside the try/except guard (lines 1413-1416).
"""
try:
context.r2_service._coerce_change_list(
raw_changes=[{"not_a_valid_field": True}],
plan_name="test-plan",
provider_name="test-provider",
)
context.r2_error = None
except PlanError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then("the auto_debug_build should return failure with the build error")
def step_assert_auto_debug_failure(context: BehaveContext) -> None:
"""Verify auto_debug_build returned (False, changes, error_msg)."""
assert context.r2_error is None, f"Unexpected exception: {context.r2_error!r}"
assert context.r2_result is not None, "Expected a result tuple"
success, _changes, error_msg = context.r2_result
assert success is False, f"Expected failure, got success={success}"
assert error_msg is not None, "Expected an error message"
assert "Simulated build failure" in error_msg
@then("the auto_debug_build should return success")
def step_assert_auto_debug_success(context: BehaveContext) -> None:
"""Verify auto_debug_build returned (True, changes, None)."""
assert context.r2_error is None, f"Unexpected exception: {context.r2_error!r}"
assert context.r2_result is not None, "Expected a result tuple"
success, _changes, error_msg = context.r2_result
assert success is True, f"Expected success, got success={success}"
assert error_msg is None, f"Expected no error, got: {error_msg}"
@then("the plan prompt should contain the auto-debug fix description")
def step_assert_prompt_updated(context: BehaveContext) -> None:
"""Verify the plan prompt was updated with the fix description (lines 926-929)."""
prompt = context.r2_plan.prompt
assert prompt is not None, "Expected plan prompt to be set"
assert "Auto-debug fix attempt" in prompt, (
f"Expected 'Auto-debug fix attempt' in prompt, got: {prompt!r}"
)
assert "Fixed the import statement" in prompt, (
f"Expected fix description in prompt, got: {prompt!r}"
)
@then("the apply error should be a PlanError with operation value in details")
def step_assert_apply_error(context: BehaveContext) -> None:
"""Verify apply_changes raised a PlanError (line 1036)."""
err = context.r2_error
assert err is not None, "Expected an error from apply_changes"
assert isinstance(err, PlanError), (
f"Expected PlanError, got {type(err).__name__}: {err}"
)
assert "Failed to apply change" in err.message, (
f"Expected 'Failed to apply change' in message: {err.message}"
)
assert "operation" in (err.details or {}), (
f"Expected 'operation' in details: {err.details}"
)
@then("a PlanError should be raised about unable to parse streamed change entry")
def step_assert_coerce_error(context: BehaveContext) -> None:
"""Verify _coerce_change_list raised PlanError (lines 1413-1416)."""
err = context.r2_error
assert err is not None, "Expected a PlanError"
assert isinstance(err, PlanError), (
f"Expected PlanError, got {type(err).__name__}: {err}"
)
assert "Unable to parse streamed change entry" in err.message, (
f"Expected parse error message, got: {err.message}"
)