forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
276 lines
9.9 KiB
Python
276 lines
9.9 KiB
Python
"""Step implementations for decomposition_service_coverage_boost.feature.
|
|
|
|
Targets the uncovered lines in decomposition_service.py:
|
|
- Lines 236-239: language clustering fallback path
|
|
- Lines 242-245: size clustering fallback path
|
|
- Lines 249-264: complete fallback with FALLBACK node + warning log
|
|
- Line 320: record_decisions early return when decision_service is None
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then, when # type: ignore[import-untyped]
|
|
|
|
from cleveragents.application.services.decomposition_models import (
|
|
ClusterStrategy,
|
|
DecompositionConfig,
|
|
DecompositionNode,
|
|
DecompositionResult,
|
|
)
|
|
from cleveragents.application.services.decomposition_service import (
|
|
DecompositionService,
|
|
_reset_counter,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a fresh decomposition service instance")
|
|
def step_given_fresh_service(context: Any) -> None:
|
|
context.svc = DecompositionService()
|
|
context.result = None
|
|
context.error = None
|
|
context.mock_logger = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Givens - file sets with controlled clustering behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
"a file set that directory-clusters into one group but language-clusters into many"
|
|
)
|
|
def step_given_dir_one_lang_many(context: Any) -> None:
|
|
"""Prepare a file list large enough to skip the leaf/min threshold,
|
|
then patch clustering so directory returns 1 cluster and language
|
|
returns multiple clusters."""
|
|
# We need > min_files_per_subplan (default 10) files to reach
|
|
# the clustering section, and the total tokens must exceed
|
|
# max_tokens_per_subplan so the is_leaf check doesn't trigger early.
|
|
context.fake_files = [f"/fake/dir/file_{i:04d}.py" for i in range(30)]
|
|
|
|
# Directory clustering → single cluster (triggers language fallback)
|
|
context._dir_patch_return = [context.fake_files[:]]
|
|
# Language clustering → two clusters (stops fallback chain)
|
|
half = len(context.fake_files) // 2
|
|
context._lang_patch_return = [
|
|
context.fake_files[:half],
|
|
context.fake_files[half:],
|
|
]
|
|
|
|
|
|
@given("a file set where directory and language clustering each yield one group")
|
|
def step_given_dir_one_lang_one_size_many(context: Any) -> None:
|
|
"""Both directory and language clustering produce <= 1 cluster,
|
|
forcing the service to try cluster_by_size."""
|
|
context.fake_files = [f"/fake/dir/file_{i:04d}.py" for i in range(30)]
|
|
|
|
# Directory → single cluster
|
|
context._dir_patch_return = [context.fake_files[:]]
|
|
# Language → single cluster
|
|
context._lang_patch_return = [context.fake_files[:]]
|
|
# Size → two clusters (stops fallback chain)
|
|
half = len(context.fake_files) // 2
|
|
context._size_patch_return = [
|
|
context.fake_files[:half],
|
|
context.fake_files[half:],
|
|
]
|
|
|
|
|
|
@given("a file set where all clustering strategies return empty lists")
|
|
def step_given_all_empty(context: Any) -> None:
|
|
"""All three clustering strategies return empty → FALLBACK node."""
|
|
context.fake_files = [f"/fake/dir/file_{i:04d}.py" for i in range(30)]
|
|
context._dir_patch_return = []
|
|
context._lang_patch_return = []
|
|
context._size_patch_return = []
|
|
|
|
|
|
@given("a decomposition service without a decision service")
|
|
def step_given_svc_no_ds(context: Any) -> None:
|
|
context.svc = DecompositionService(decision_service=None)
|
|
|
|
|
|
@given("a trivial decomposition result")
|
|
def step_given_trivial_result(context: Any) -> None:
|
|
_reset_counter()
|
|
context.trivial_result = DecompositionResult(
|
|
nodes=[
|
|
DecompositionNode(
|
|
node_id="dn-000001",
|
|
parent_id=None,
|
|
depth=0,
|
|
file_paths=["a.py"],
|
|
language=".py",
|
|
directory_prefix="",
|
|
estimated_tokens=10,
|
|
strategy=ClusterStrategy.FALLBACK,
|
|
),
|
|
],
|
|
max_depth_reached=0,
|
|
total_files=1,
|
|
metrics={"skipped": 1},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_LOGGER_PATH = "cleveragents.application.services.decomposition_service.logger"
|
|
|
|
|
|
def _run_with_patches(context: Any, config: DecompositionConfig | None = None) -> None:
|
|
"""Execute decomposition with controlled clustering patches."""
|
|
cfg = config or DecompositionConfig(
|
|
max_files_per_subplan=20,
|
|
min_files_per_subplan=5,
|
|
max_tokens_per_subplan=50, # very low so leaf check won't fire early
|
|
max_depth=4,
|
|
)
|
|
cluster_mod = (
|
|
"cleveragents.application.services.decomposition_service.ClusteringStrategy"
|
|
)
|
|
token_mod = "cleveragents.application.services.decomposition_service.estimate_tokens_for_path"
|
|
|
|
dir_return = getattr(context, "_dir_patch_return", None)
|
|
lang_return = getattr(context, "_lang_patch_return", None)
|
|
size_return = getattr(context, "_size_patch_return", None)
|
|
|
|
def fake_dir(files: Any, max_per: Any, **kw: Any) -> list[list[str]]:
|
|
if dir_return is not None:
|
|
return dir_return
|
|
return [files] # pragma: no cover
|
|
|
|
def fake_lang(files: Any, max_per: Any) -> list[list[str]]:
|
|
if lang_return is not None:
|
|
return lang_return
|
|
return [files] # pragma: no cover
|
|
|
|
def fake_size(files: Any, max_tokens: Any, **kw: Any) -> list[list[str]]:
|
|
if size_return is not None:
|
|
return size_return
|
|
return [files] # pragma: no cover
|
|
|
|
def fake_tokens(path: str) -> int:
|
|
return 100 # each file → 100 tokens
|
|
|
|
with (
|
|
patch(f"{cluster_mod}.cluster_by_directory", side_effect=fake_dir),
|
|
patch(f"{cluster_mod}.cluster_by_language", side_effect=fake_lang),
|
|
patch(f"{cluster_mod}.cluster_by_size", side_effect=fake_size),
|
|
patch(token_mod, side_effect=fake_tokens),
|
|
patch(_LOGGER_PATH) as mock_logger,
|
|
):
|
|
context.mock_logger = mock_logger
|
|
context.result = context.svc.decompose(context.fake_files, cfg)
|
|
|
|
|
|
@when("I run decomposition on those files")
|
|
def step_when_run_decomposition(context: Any) -> None:
|
|
_run_with_patches(context)
|
|
|
|
|
|
@when("I run decomposition on those files and collect metrics")
|
|
def step_when_run_decomposition_metrics(context: Any) -> None:
|
|
_run_with_patches(context)
|
|
|
|
|
|
@when('I call record_decisions with plan id "{plan_id}"')
|
|
def step_when_record_decisions_no_ds(context: Any, plan_id: str) -> None:
|
|
try:
|
|
context.svc.record_decisions(plan_id, context.trivial_result)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I run decomposition with max_depth {depth:d} on those files")
|
|
def step_when_run_decomposition_max_depth(context: Any, depth: int) -> None:
|
|
cfg = DecompositionConfig(
|
|
max_files_per_subplan=20,
|
|
min_files_per_subplan=5,
|
|
max_tokens_per_subplan=50,
|
|
max_depth=depth,
|
|
)
|
|
_run_with_patches(context, config=cfg)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("at least one node should use the language clustering strategy")
|
|
def step_then_language_strategy(context: Any) -> None:
|
|
assert context.result is not None, "decomposition result is None"
|
|
strategies = {n.strategy for n in context.result.nodes}
|
|
assert ClusterStrategy.LANGUAGE in strategies, (
|
|
f"Expected LANGUAGE strategy in {strategies}"
|
|
)
|
|
|
|
|
|
@then("at least one node should use the size clustering strategy")
|
|
def step_then_size_strategy(context: Any) -> None:
|
|
assert context.result is not None, "decomposition result is None"
|
|
strategies = {n.strategy for n in context.result.nodes}
|
|
assert ClusterStrategy.SIZE in strategies, f"Expected SIZE strategy in {strategies}"
|
|
|
|
|
|
@then("the result should contain a fallback strategy node")
|
|
def step_then_fallback_node(context: Any) -> None:
|
|
assert context.result is not None, "decomposition result is None"
|
|
strategies = {n.strategy for n in context.result.nodes}
|
|
assert ClusterStrategy.FALLBACK in strategies, (
|
|
f"Expected FALLBACK strategy in {strategies}"
|
|
)
|
|
|
|
|
|
@then("a decomposition fallback warning should have been logged")
|
|
def step_then_fallback_warning_logged(context: Any) -> None:
|
|
mock_logger = context.mock_logger
|
|
assert mock_logger is not None, "Mock logger was not set"
|
|
assert mock_logger.warning.called, (
|
|
"Expected logger.warning to be called for decomposition fallback"
|
|
)
|
|
args_str = str(mock_logger.warning.call_args_list)
|
|
assert "decomposition_fallback" in args_str, (
|
|
f"Expected 'decomposition_fallback' in warning calls, got: {args_str}"
|
|
)
|
|
|
|
|
|
@then("no exception should be raised from record_decisions")
|
|
def step_then_no_exception(context: Any) -> None:
|
|
assert context.error is None, f"Unexpected exception: {context.error}"
|
|
|
|
|
|
@then("the result total_nodes metric should be present")
|
|
def step_then_total_nodes_metric(context: Any) -> None:
|
|
assert context.result is not None
|
|
assert "total_nodes" in context.result.metrics, (
|
|
f"Missing total_nodes in {context.result.metrics}"
|
|
)
|
|
|
|
|
|
@then("the result leaf_nodes metric should be present")
|
|
def step_then_leaf_nodes_metric(context: Any) -> None:
|
|
assert context.result is not None
|
|
assert "leaf_nodes" in context.result.metrics, (
|
|
f"Missing leaf_nodes in {context.result.metrics}"
|
|
)
|
|
|
|
|
|
@then("the result max_depth_reached should be at most {n:d}")
|
|
def step_then_max_depth_at_most(context: Any, n: int) -> None:
|
|
assert context.result is not None
|
|
assert context.result.max_depth_reached <= n, (
|
|
f"Expected max_depth_reached <= {n}, got {context.result.max_depth_reached}"
|
|
)
|