Files
temp/features/steps/plan_generation_coverage_boost_steps.py
T
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

280 lines
10 KiB
Python

"""Step definitions for plan_generation_coverage_boost.feature.
These steps target specific uncovered lines in plan_generation.py:
- Lines 90-91: BoundedMemorySaver._prune exception fallback in serde.loads_typed
- Lines 428-429: _generate_plan read_text() exception fallback
- Line 491: _find_matching_context returns None when no context matches
- Lines 631-635: _analyze_contexts defensive fallback when agent.invoke raises
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from langchain_community.llms import FakeListLLM
from cleveragents.agents.graphs.plan_generation import (
BoundedMemorySaver,
PlanGenerationGraph,
)
from cleveragents.domain.models.core import Context, Plan, Project
def _default_test_llm() -> FakeListLLM:
"""Create a FakeListLLM for test purposes."""
return FakeListLLM(
responses=[
"Requirements: Add error handling",
"Generated code with error handling",
"Validation passed",
]
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the plan generation coverage module is imported")
def step_coverage_module_imported(context):
"""Ensure the plan_generation module is importable."""
assert BoundedMemorySaver is not None
assert PlanGenerationGraph is not None
# ---------------------------------------------------------------------------
# Scenario: BoundedMemorySaver prune with corrupt checkpoint (lines 90-91)
# ---------------------------------------------------------------------------
@given("a BoundedMemorySaver with max_checkpoints set to 1")
def step_create_bounded_saver(context):
"""Create a BoundedMemorySaver that retains only 1 checkpoint."""
context.saver = BoundedMemorySaver(max_checkpoints=1)
@given("the saver storage contains two checkpoints with corrupt data")
def step_populate_corrupt_storage(context):
"""Directly populate internal storage with two checkpoints.
The second tuple element is metadata bytes and the third is the
parent config; only the first element (checkpoint bytes) matters
for the serde.loads_typed call that we want to fail.
"""
saver = context.saver
thread_id = "test-thread"
ns = ""
# Access internals the same way _prune does
storage = saver.storage # defaultdict(lambda: defaultdict(dict))
# Ensure the nested defaultdicts exist
storage[thread_id][ns]["ckpt-001"] = (b"corrupt-data", b"", None)
storage[thread_id][ns]["ckpt-002"] = (b"also-corrupt", b"", None)
# Seed writes and blobs so pop() calls don't error
saver.writes[(thread_id, ns, "ckpt-001")] = {}
saver.writes[(thread_id, ns, "ckpt-002")] = {}
context.thread_id = thread_id
context.ns = ns
@when("the saver prunes the oldest checkpoint")
def step_prune_checkpoints(context):
"""Monkey-patch serde.loads_typed to raise, then call _prune.
This forces the except branch on lines 90-91.
"""
saver = context.saver
original_loads = saver.serde.loads_typed
def _failing_loads_typed(*args, **kwargs):
raise ValueError("corrupt checkpoint bytes")
saver.serde.loads_typed = _failing_loads_typed
try:
context.prune_error = None
saver._prune(context.thread_id, context.ns)
except Exception as exc:
context.prune_error = exc
finally:
saver.serde.loads_typed = original_loads
@then("the prune should complete without error")
def step_verify_prune_no_error(context):
assert context.prune_error is None, (
f"Expected no error from _prune, got {context.prune_error!r}"
)
@then("the corrupt checkpoint should be removed from storage")
def step_verify_checkpoint_removed(context):
"""After pruning with max_checkpoints=1, only 1 checkpoint should remain."""
remaining = context.saver.storage[context.thread_id][context.ns]
assert len(remaining) == 1, f"Expected 1 remaining checkpoint, got {len(remaining)}"
# ---------------------------------------------------------------------------
# Scenario: _generate_plan read_text exception fallback (lines 428-429)
# ---------------------------------------------------------------------------
@given("a PlanGenerationGraph instance for coverage testing")
def step_create_graph_instance(context):
"""Create a PlanGenerationGraph with a FakeListLLM."""
context.graph = PlanGenerationGraph(llm=_default_test_llm(), max_retries=1)
@given("a generate state with an explicit path pointing to an unreadable file")
def step_prepare_unreadable_file_state(context):
"""Create a temp file and build a state where the prompt uses @<path>.
We will mock Path.read_text to raise PermissionError so the except
branch on lines 428-429 is hit.
"""
# Create a real temp file so Path.exists() returns True and is_dir() returns False
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp:
tmp.write(b"# existing code")
context.temp_file_path = tmp.name
context.unreadable_state = {
"project": Project(id=1, name="test-project", path=Path("/tmp")),
"plan": Plan(id=1, project_id=1, name="plan", prompt="test prompt"),
"contexts": [],
"context_summary": "summary",
"prompt": f"modify @{tmp.name}",
"analyzed_requirements": {
"description": "add error handling",
"operation": "create",
},
"generated_changes": [],
"validation_result": {},
"retry_count": 0,
"error": None,
"context_dependencies": {},
"context_relevance": {},
"context_analysis_error": None,
"actor_name": None,
"actor_options": {},
"actor_graph_descriptor": None,
"actor_initial_context": {},
}
def cleanup():
import os
os.unlink(tmp.name)
context.add_cleanup(cleanup)
@when("_generate_plan is called with the unreadable file state")
def step_call_generate_plan_unreadable(context):
"""Call _generate_plan with Path.read_text patched to raise."""
graph = context.graph
state = context.unreadable_state
with patch.object(Path, "read_text", side_effect=PermissionError("no access")):
context.gen_result = graph._generate_plan(state)
@then("a change should be generated with None as original_content")
def step_verify_original_content_none(context):
changes = context.gen_result.get("generated_changes", [])
assert len(changes) == 1, f"Expected 1 change, got {len(changes)}"
assert changes[0].original_content is None, (
f"Expected original_content=None, got {changes[0].original_content!r}"
)
@then("the change operation should be MODIFY")
def step_verify_operation_modify(context):
from cleveragents.domain.models.core.change import OperationType
changes = context.gen_result["generated_changes"]
assert changes[0].operation == OperationType.MODIFY, (
f"Expected MODIFY, got {changes[0].operation!r}"
)
# ---------------------------------------------------------------------------
# Scenario: _find_matching_context returns None (line 491)
# ---------------------------------------------------------------------------
@given("a list of contexts with non-matching paths")
def step_create_non_matching_contexts(context):
"""Create contexts whose paths don't match the search path."""
context.test_contexts = [
Context(plan_id=1, path="src/foo.py", content="foo content"),
Context(plan_id=1, path="src/bar.py", content="bar content"),
Context(plan_id=1, path="lib/utils.py", content="utils content"),
]
@when("_find_matching_context is called with a path that matches no context")
def step_call_find_matching_no_match(context):
"""Call _find_matching_context with a path that no context has."""
context.match_result = context.graph._find_matching_context(
context.test_contexts, "completely/different/path.py"
)
@then("the matching context result should be None")
def step_verify_match_result_none(context):
assert context.match_result is None, f"Expected None, got {context.match_result!r}"
# ---------------------------------------------------------------------------
# Scenario: _analyze_contexts exception fallback (lines 631-635)
# ---------------------------------------------------------------------------
@given("the ContextAnalysisAgent invoke method is mocked to raise an error")
def step_mock_context_analysis_invoke(context):
"""Prepare a patch that makes ContextAnalysisAgent.invoke raise."""
context.ca_patch = patch(
"cleveragents.agents.graphs.plan_generation.ContextAnalysisAgent"
)
mock_cls = context.ca_patch.start()
mock_instance = MagicMock()
mock_instance.invoke.side_effect = RuntimeError("analysis agent exploded")
mock_cls.return_value = mock_instance
def cleanup():
context.ca_patch.stop()
context.add_cleanup(cleanup)
@when("_analyze_contexts is called with non-empty contexts")
def step_call_analyze_contexts(context):
"""Call _analyze_contexts with real Context objects."""
test_contexts = [
Context(plan_id=1, path="src/main.py", content="print('hello')"),
Context(plan_id=1, path="src/utils.py", content="def helper(): pass"),
]
context.analyze_result = context.graph._analyze_contexts(test_contexts)
@then("the result should contain the fallback summary")
def step_verify_fallback_summary(context):
summary = context.analyze_result.get("context_summary", "")
# The fallback summary is produced by _format_context_summary which includes "File:"
assert "File:" in summary, (
f"Expected fallback summary with 'File:', got: {summary!r}"
)
@then("the result should contain an analysis error message")
def step_verify_analysis_error(context):
error = context.analyze_result.get("context_analysis_error")
assert error is not None, "Expected context_analysis_error to be set"
assert "Context analysis failed" in error, f"Unexpected error message: {error!r}"
assert "analysis agent exploded" in error, (
f"Missing original exception in: {error!r}"
)
@then("dependencies and relevance should be empty dicts")
def step_verify_empty_dicts(context):
deps = context.analyze_result.get("context_dependencies")
rel = context.analyze_result.get("context_relevance")
assert deps == {}, f"Expected empty dependencies, got {deps!r}"
assert rel == {}, f"Expected empty relevance, got {rel!r}"