"""Step definitions for correction subtree isolation tests. All step names are prefixed with ``subtree isolat`` to avoid ``AmbiguousStep`` conflicts with existing correction steps. """ from __future__ import annotations from unittest.mock import patch from behave import given, then, when from behave.runner import Context from cleveragents.application.services.correction_service import CorrectionService from cleveragents.core.exceptions import ValidationError from cleveragents.domain.models.core.correction import CorrectionMode, CorrectionStatus _PLAN_ID = "01SUBTREEPLAN000000000000001" def _parse_children(text: str) -> list[str]: """Split a comma-separated children string into a list.""" return [c.strip() for c in text.split(",") if c.strip()] # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a subtree isolation correction service") def step_subtree_service(context: Context) -> None: """Set up a fresh CorrectionService for subtree tests.""" context.subtree_svc = CorrectionService() context.subtree_tree = {} context.subtree_impact = None context.subtree_report = None context.subtree_tier = None context.subtree_isolation_valid = None @given('a subtree isolation decision tree with root "{root}" and children') def step_subtree_tree(context: Context, root: str) -> None: """Build the decision tree from the table.""" tree: dict[str, list[str]] = {} for row in context.table: parent = row["parent"] children = _parse_children(row["children"]) tree[parent] = children context.subtree_tree = tree @given("a subtree isolation deep tree with depth {depth:d}") def step_subtree_deep_tree(context: Context, depth: int) -> None: """Build a linear chain tree with given depth.""" tree: dict[str, list[str]] = {} for i in range(depth): parent = f"depth_{i}" if i > 0 else "root" child = f"depth_{i + 1}" tree[parent] = [child] context.subtree_tree = tree # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when('I subtree isolate target decision "{decision_id}" with mode "{mode}"') def step_subtree_target(context: Context, decision_id: str, mode: str) -> None: """Create a correction request targeting the given decision.""" svc: CorrectionService = context.subtree_svc correction_mode = CorrectionMode(mode) req = svc.request_correction( plan_id=_PLAN_ID, target_decision_id=decision_id, mode=correction_mode, ) context.subtree_correction_id = req.correction_id @when("I subtree isolate analyze the impact") def step_subtree_analyze(context: Context) -> None: """Analyze impact for the current correction.""" svc: CorrectionService = context.subtree_svc impact = svc.analyze_impact( context.subtree_correction_id, context.subtree_tree, ) context.subtree_impact = impact @when("I subtree isolate generate a dry-run report") def step_subtree_dry_run(context: Context) -> None: """Generate a dry-run report for the current correction.""" svc: CorrectionService = context.subtree_svc report = svc.generate_dry_run_report( context.subtree_correction_id, context.subtree_tree, ) context.subtree_report = report context.subtree_impact = report.impact @when('I subtree isolate compute rollback tier for "{decision_id}"') def step_subtree_compute_tier(context: Context, decision_id: str) -> None: """Compute the rollback tier for a decision.""" svc: CorrectionService = context.subtree_svc context.subtree_tier = svc.compute_rollback_tier( decision_id, _PLAN_ID, context.subtree_tree ) @when('I subtree isolate validate isolation for target "{decision_id}"') def step_subtree_validate(context: Context, decision_id: str) -> None: """Validate subtree isolation for a target decision.""" svc: CorrectionService = context.subtree_svc context.subtree_isolation_valid = svc.validate_subtree_isolation( decision_id, context.subtree_tree ) # --------------------------------------------------------------------------- # Then steps: affected / excluded decisions # --------------------------------------------------------------------------- @then('the subtree isolate affected decisions should be "{expected}"') def step_subtree_affected_exact(context: Context, expected: str) -> None: """Check that affected decisions match exactly.""" impact = context.subtree_impact expected_list = _parse_children(expected) assert impact.affected_decisions == expected_list, ( f"Expected affected={expected_list}, got {impact.affected_decisions}" ) @then('the subtree isolate affected decisions should contain "{decision_id}"') def step_subtree_affected_contains(context: Context, decision_id: str) -> None: """Check that a specific decision is in the affected set.""" impact = context.subtree_impact assert decision_id in impact.affected_decisions, ( f"Expected '{decision_id}' in affected={impact.affected_decisions}" ) @then('the subtree isolate excluded decisions should contain "{decision_id}"') def step_subtree_excluded_contains(context: Context, decision_id: str) -> None: """Check that a specific decision is in the excluded set.""" impact = context.subtree_impact assert decision_id in impact.excluded_decisions, ( f"Expected '{decision_id}' in excluded={impact.excluded_decisions}" ) @then("the subtree isolate excluded decisions should be empty") def step_subtree_excluded_empty(context: Context) -> None: """Check that excluded decisions is empty.""" impact = context.subtree_impact assert len(impact.excluded_decisions) == 0, ( f"Expected empty excluded, got {impact.excluded_decisions}" ) # --------------------------------------------------------------------------- # Then steps: rollback tier # --------------------------------------------------------------------------- @then("the subtree isolate rollback tier depth should be {expected:d}") def step_subtree_tier_depth(context: Context, expected: int) -> None: """Check the rollback tier depth from impact analysis.""" impact = context.subtree_impact assert impact.rollback_tier_depth == expected, ( f"Expected tier_depth={expected}, got {impact.rollback_tier_depth}" ) @then("the subtree isolate rollback tier should be {expected:d}") def step_subtree_tier_value(context: Context, expected: int) -> None: """Check the rollback tier from direct computation.""" assert context.subtree_tier == expected, ( f"Expected tier={expected}, got {context.subtree_tier}" ) # --------------------------------------------------------------------------- # Then steps: dry-run report # --------------------------------------------------------------------------- @then('the subtree isolate dry-run warnings should contain "{text}"') def step_subtree_warnings_contain(context: Context, text: str) -> None: """Check that dry-run warnings contain specific text.""" report = context.subtree_report all_warnings = " ".join(report.warnings) assert text in all_warnings, f"Expected '{text}' in warnings: {report.warnings}" @then("the subtree isolate dry-run report should include excluded decisions") def step_subtree_report_has_excluded(context: Context) -> None: """Check that the dry-run report includes excluded decisions via impact.""" report = context.subtree_report assert len(report.impact.excluded_decisions) > 0, ( f"Expected non-empty excluded_decisions, got {report.impact.excluded_decisions}" ) @then("the subtree isolate dry-run report rollback tier depth should be {expected:d}") def step_subtree_report_tier(context: Context, expected: int) -> None: """Check the rollback tier depth in the dry-run report via impact.""" report = context.subtree_report assert report.impact.rollback_tier_depth == expected, ( f"Expected report tier_depth={expected}, got {report.impact.rollback_tier_depth}" ) @then( 'the subtree isolate dry-run report excluded decisions should contain "{decision_id}"' ) def step_subtree_report_excluded_contains(context: Context, decision_id: str) -> None: """Check the dry-run report's excluded decisions list via impact.""" report = context.subtree_report assert decision_id in report.impact.excluded_decisions, ( f"Expected '{decision_id}' in report excluded={report.impact.excluded_decisions}" ) # --------------------------------------------------------------------------- # Then steps: subtree isolation validation # --------------------------------------------------------------------------- @then("the subtree isolate isolation should be valid") def step_subtree_isolation_valid(context: Context) -> None: """Check that subtree isolation validation passed.""" assert context.subtree_isolation_valid is True, ( "Expected isolation to be valid, but it was not" ) # --------------------------------------------------------------------------- # Edge-case steps for additional coverage # --------------------------------------------------------------------------- @given("a subtree isolation empty tree") def step_subtree_empty_tree(context: Context) -> None: """Set the decision tree to empty for edge-case testing.""" context.subtree_tree = {} @when('I subtree isolate check parent of "{decision_id}"') def step_subtree_check_parent(context: Context, decision_id: str) -> None: """Check the parent of a decision in the tree.""" parent = CorrectionService._find_parent(decision_id, context.subtree_tree) context.subtree_parent_result = parent @then("the subtree isolate parent should be none") def step_subtree_parent_none(context: Context) -> None: """Verify that the parent lookup returned None.""" assert context.subtree_parent_result is None, ( f"Expected None, got {context.subtree_parent_result}" ) # --------------------------------------------------------------------------- # Influence DAG steps # --------------------------------------------------------------------------- @given('a subtree isolate influence edge from "{src}" to "{dst}"') def step_subtree_influence_edge(context: Context, src: str, dst: str) -> None: """Set up a single influence edge.""" context.subtree_influence_edges = {src: [dst]} @given('a subtree isolate influence cycle from "{src}" to "{mid}" back to "{back}"') def step_subtree_influence_cycle( context: Context, src: str, mid: str, back: str, ) -> None: """Set up cyclic influence edges.""" context.subtree_influence_edges = {src: [mid], mid: [back]} @when("I subtree isolate analyze the impact with influence edges") def step_subtree_analyze_with_influence(context: Context) -> None: """Analyze impact using both tree and influence edges.""" svc: CorrectionService = context.subtree_svc impact = svc.analyze_impact( context.subtree_correction_id, context.subtree_tree, influence_edges=context.subtree_influence_edges, ) context.subtree_impact = impact @when( 'I subtree isolate validate isolation for target "{decision_id}"' " with influence edges" ) def step_subtree_validate_with_influence( context: Context, decision_id: str, ) -> None: """Validate subtree isolation with influence edges.""" svc: CorrectionService = context.subtree_svc context.subtree_isolation_valid = svc.validate_subtree_isolation( decision_id, context.subtree_tree, influence_edges=context.subtree_influence_edges, ) # --------------------------------------------------------------------------- # Append mode steps # --------------------------------------------------------------------------- @when("I subtree isolate execute append correction") def step_subtree_execute_append(context: Context) -> None: """Execute an append correction.""" svc: CorrectionService = context.subtree_svc result = svc.execute_append(context.subtree_correction_id) context.subtree_append_result = result @then("the subtree isolate append result should have a spawned child plan") def step_subtree_append_has_child_plan(context: Context) -> None: """Check that the append result has a spawned child plan.""" result = context.subtree_append_result assert result.spawned_child_plan_id is not None, ( "Expected spawned_child_plan_id, got None" ) @then('the subtree isolate append result status should be "{status}"') def step_subtree_append_status(context: Context, status: str) -> None: """Check the append result status.""" result = context.subtree_append_result assert result.status.value == status, ( f"Expected status='{status}', got '{result.status.value}'" ) # --------------------------------------------------------------------------- # Dry-run enforcement steps # --------------------------------------------------------------------------- @when( 'I subtree isolate target decision "{decision_id}" with mode "{mode}" and dry_run' ) def step_subtree_target_dry_run( context: Context, decision_id: str, mode: str, ) -> None: """Create a dry-run correction request.""" svc: CorrectionService = context.subtree_svc correction_mode = CorrectionMode(mode) req = svc.request_correction( plan_id=_PLAN_ID, target_decision_id=decision_id, mode=correction_mode, dry_run=True, ) context.subtree_correction_id = req.correction_id @then("subtree isolate executing the correction should raise a dry-run error") def step_subtree_execute_raises_dry_run(context: Context) -> None: """Verify that executing a dry-run correction raises ValidationError.""" svc: CorrectionService = context.subtree_svc raised = False try: svc.execute_correction(context.subtree_correction_id, context.subtree_tree) except ValidationError as exc: raised = True assert "dry-run" in str(exc).lower(), ( f"Expected 'dry-run' in error message, got: {exc}" ) assert raised, "Expected ValidationError for dry-run execution, but none was raised" # --------------------------------------------------------------------------- # Negative isolation validation steps # --------------------------------------------------------------------------- @given("a subtree isolation tree where child structurally reaches root") def step_subtree_cyclic_tree(context: Context) -> None: """Build a tree where a child has the root as structural child.""" tree: dict[str, list[str]] = {} for row in context.table: parent = row["parent"] children = _parse_children(row["children"]) tree[parent] = children context.subtree_tree = tree @then("the subtree isolate isolation should be invalid") def step_subtree_isolation_invalid(context: Context) -> None: """Check that subtree isolation validation failed.""" assert context.subtree_isolation_valid is False, ( "Expected isolation to be invalid, but it was valid" ) # --------------------------------------------------------------------------- # Execute revert (non-dry-run) steps # --------------------------------------------------------------------------- @when("I subtree isolate execute revert correction") def step_subtree_execute_revert(context: Context) -> None: """Execute a revert correction.""" svc: CorrectionService = context.subtree_svc result = svc.execute_revert( context.subtree_correction_id, context.subtree_tree, ) context.subtree_revert_result = result @then('the subtree isolate revert result status should be "{status}"') def step_subtree_revert_status(context: Context, status: str) -> None: """Check the revert result status.""" result = context.subtree_revert_result assert result.status.value == status, ( f"Expected status='{status}', got '{result.status.value}'" ) @then( 'the subtree isolate revert result should contain reverted decisions "{expected}"' ) def step_subtree_revert_decisions(context: Context, expected: str) -> None: """Check the reverted decisions list.""" result = context.subtree_revert_result expected_list = sorted(_parse_children(expected)) actual_list = sorted(result.reverted_decisions) assert actual_list == expected_list, ( f"Expected reverted={expected_list}, got {actual_list}" ) @then("the subtree isolate revert result should have archived artifacts") def step_subtree_revert_has_artifacts(context: Context) -> None: """Check that archived artifacts is non-empty.""" result = context.subtree_revert_result assert len(result.archived_artifacts) > 0, ( f"Expected non-empty archived_artifacts, got {result.archived_artifacts}" ) # --------------------------------------------------------------------------- # Status guard enforcement steps # --------------------------------------------------------------------------- @then("subtree isolate executing the correction again should raise a status error") def step_subtree_execute_again_raises(context: Context) -> None: """Verify re-executing an applied correction raises ValidationError.""" svc: CorrectionService = context.subtree_svc raised = False try: svc.execute_correction(context.subtree_correction_id, context.subtree_tree) except ValidationError: raised = True assert raised, "Expected ValidationError when re-executing an applied correction" @when("I subtree isolate cancel the correction") def step_subtree_cancel(context: Context) -> None: """Cancel the current correction.""" svc: CorrectionService = context.subtree_svc svc.cancel_correction(context.subtree_correction_id) @then("subtree isolate executing the correction should raise a status error") def step_subtree_execute_cancelled_raises(context: Context) -> None: """Verify executing a cancelled correction raises ValidationError.""" svc: CorrectionService = context.subtree_svc raised = False try: svc.execute_correction(context.subtree_correction_id, context.subtree_tree) except ValidationError: raised = True assert raised, "Expected ValidationError when executing a cancelled correction" # --------------------------------------------------------------------------- # Mode mismatch enforcement steps # --------------------------------------------------------------------------- @then("subtree isolate calling execute_revert should raise a mode error") def step_subtree_revert_on_append_raises(context: Context) -> None: """Verify calling execute_revert on an append correction raises.""" svc: CorrectionService = context.subtree_svc raised = False try: svc.execute_revert(context.subtree_correction_id, context.subtree_tree) except ValidationError as exc: raised = True assert "REVERT" in str(exc), f"Expected 'REVERT' in error, got: {exc}" assert raised, "Expected ValidationError for mode mismatch" @then("subtree isolate calling execute_append should raise a mode error") def step_subtree_append_on_revert_raises(context: Context) -> None: """Verify calling execute_append on a revert correction raises.""" svc: CorrectionService = context.subtree_svc raised = False try: svc.execute_append(context.subtree_correction_id) except ValidationError as exc: raised = True assert "APPEND" in str(exc), f"Expected 'APPEND' in error, got: {exc}" assert raised, "Expected ValidationError for mode mismatch" # --------------------------------------------------------------------------- # Single-node tree steps # --------------------------------------------------------------------------- @given("a subtree isolation single node tree") def step_subtree_single_node_tree(context: Context) -> None: """Set a tree with only a root node and no children.""" context.subtree_tree = {"root": []} # --------------------------------------------------------------------------- # Terminal state guard steps # --------------------------------------------------------------------------- @then("subtree isolate re-analyzing the impact should raise a terminal state error") def step_subtree_reanalyze_raises(context: Context) -> None: """Verify re-analyzing an applied correction raises ValidationError.""" svc: CorrectionService = context.subtree_svc raised = False try: svc.analyze_impact(context.subtree_correction_id, context.subtree_tree) except ValidationError as exc: raised = True assert "terminal" in str(exc).lower(), ( f"Expected 'terminal' in error, got: {exc}" ) assert raised, "Expected ValidationError when re-analyzing in terminal state" # --------------------------------------------------------------------------- # Exact-match affected count steps # --------------------------------------------------------------------------- @then("the subtree isolate affected decisions count should be {expected:d}") def step_subtree_affected_count(context: Context, expected: int) -> None: """Check the exact count of affected decisions.""" impact = context.subtree_impact assert len(impact.affected_decisions) == expected, ( f"Expected {expected} affected decisions, " f"got {len(impact.affected_decisions)}: {impact.affected_decisions}" ) # --------------------------------------------------------------------------- # Convergent (diamond) DAG topology steps # --------------------------------------------------------------------------- @given( 'a subtree isolate diamond influence where "{src1}" and "{src2}" both target "{dst}"' ) def step_subtree_diamond_influence( context: Context, src1: str, src2: str, dst: str, ) -> None: """Set up a convergent (diamond) influence DAG.""" context.subtree_influence_edges = {src1: [dst], src2: [dst]} @then( 'the subtree isolate affected decision "{decision_id}" should appear exactly once' ) def step_subtree_affected_appears_once(context: Context, decision_id: str) -> None: """Verify a decision appears exactly once in the affected list.""" impact = context.subtree_impact count = impact.affected_decisions.count(decision_id) assert count == 1, ( f"Expected '{decision_id}' exactly once in affected, " f"found {count} times: {impact.affected_decisions}" ) # --------------------------------------------------------------------------- # Dry-run exception recovery steps # --------------------------------------------------------------------------- @when("I subtree isolate generate a dry-run report that fails internally") def step_subtree_dry_run_fails(context: Context) -> None: """Generate a dry-run report where analyze_impact raises internally.""" svc: CorrectionService = context.subtree_svc try: with patch.object( CorrectionService, "_compute_affected_subtree", side_effect=RuntimeError("simulated BFS failure"), ): svc.generate_dry_run_report( context.subtree_correction_id, context.subtree_tree, ) except RuntimeError: pass # Expected — the exception propagates from analyze_impact @then('the subtree isolate correction status should be "{expected_status}"') def step_subtree_correction_status(context: Context, expected_status: str) -> None: """Verify the correction request's current status.""" svc: CorrectionService = context.subtree_svc request = svc.get_correction(context.subtree_correction_id) expected = CorrectionStatus(expected_status) assert request.status == expected, ( f"Expected status='{expected}', got '{request.status}'" ) # --------------------------------------------------------------------------- # Execute revert with influence edges steps # --------------------------------------------------------------------------- @when("I subtree isolate execute revert correction with influence edges") def step_subtree_execute_revert_with_influence(context: Context) -> None: """Execute a revert correction with influence edges.""" svc: CorrectionService = context.subtree_svc result = svc.execute_revert( context.subtree_correction_id, context.subtree_tree, influence_edges=context.subtree_influence_edges, ) context.subtree_revert_result = result @then( 'the subtree isolate revert result should contain reverted decision "{decision_id}"' ) def step_subtree_revert_contains_decision( context: Context, decision_id: str, ) -> None: """Check that a specific decision is in the reverted set.""" result = context.subtree_revert_result assert decision_id in result.reverted_decisions, ( f"Expected '{decision_id}' in reverted={result.reverted_decisions}" ) # --------------------------------------------------------------------------- # DAG-only nodes in excluded set steps # --------------------------------------------------------------------------- @given('a subtree isolate influence edge from "{src}" to "{dst}" only in DAG') def step_subtree_dag_only_influence(context: Context, src: str, dst: str) -> None: """Set up influence edges with nodes that are NOT in the structural tree.""" context.subtree_influence_edges = {src: [dst]}