"""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}" )