""" Unit tests for IncrementalExecutionEngine. Tests recompute flow, depth control, circular suppression, and budget limits. """ import pytest import tempfile from pathlib import Path from unittest.mock import MagicMock from markitect.prompts.dependencies.models import DependencyEdge, EdgeType from markitect.prompts.dependencies.repository import SQLiteDependencyRepository from markitect.prompts.dependencies.queries import DependencyQueryService from markitect.prompts.execution.models import PromptRun, RunConfig, RunStatus from markitect.prompts.incremental.engine import IncrementalExecutionEngine from markitect.prompts.incremental.models import ( ArtifactChange, ChangeType, ImpactDebt, RecomputeConfig, ) @pytest.fixture def temp_db(): """Create temporary database for testing.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: db_path = f.name yield db_path Path(db_path).unlink(missing_ok=True) @pytest.fixture def dep_repo(temp_db): """Create dependency repository.""" return SQLiteDependencyRepository(temp_db) @pytest.fixture def query_service(dep_repo): """Create DependencyQueryService.""" return DependencyQueryService(dep_repo) @pytest.fixture def engine(temp_db, query_service): """Create IncrementalExecutionEngine.""" return IncrementalExecutionEngine(temp_db, query_service) def _create_edge(repo, src, tgt, run_id="run-1", edge_type=EdgeType.REQUIRES): """Helper to create and persist a dependency edge.""" edge = DependencyEdge.create( source_artifact_id=src, target_artifact_id=tgt, run_id=run_id, edge_type=edge_type, ) return repo.create(edge) def _make_change(artifact_id="art-1"): """Helper to create a test ArtifactChange.""" return ArtifactChange.create( artifact_id=artifact_id, old_digest="old-digest", new_digest="new-digest", change_type=ChangeType.MODIFIED, ) class TestFindDependentsAtDepth: """Tests for BFS depth-controlled dependent finding.""" def test_depth_1_direct_only(self, dep_repo, engine): """Test depth=1 finds only direct dependents.""" # A -> B -> C (A depends on B, B depends on C) _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "C") # Dependents of C at depth 1: only B dependents = engine.find_dependents_at_depth("C", max_depth=1) assert dependents == {"B"} def test_depth_2_transitive(self, dep_repo, engine): """Test depth=2 finds two levels of dependents.""" # A -> B -> C _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "C") # Dependents of C at depth 2: B and A dependents = engine.find_dependents_at_depth("C", max_depth=2) assert dependents == {"A", "B"} def test_depth_0_returns_empty(self, dep_repo, engine): """Test depth=0 returns no dependents.""" _create_edge(dep_repo, "A", "B") dependents = engine.find_dependents_at_depth("B", max_depth=0) assert dependents == set() def test_no_dependents(self, engine): """Test artifact with no dependents.""" dependents = engine.find_dependents_at_depth("isolated", max_depth=5) assert dependents == set() def test_diamond_dependents(self, dep_repo, engine): """Test diamond-shaped dependency graph.""" # A -> C, B -> C, D -> A, D -> B _create_edge(dep_repo, "A", "C") _create_edge(dep_repo, "B", "C") _create_edge(dep_repo, "D", "A") _create_edge(dep_repo, "D", "B") dependents = engine.find_dependents_at_depth("C", max_depth=2) assert dependents == {"A", "B", "D"} class TestRecompute: """Tests for the recompute orchestration flow.""" def test_basic_recompute(self, dep_repo, engine): """Test basic recompute with execution callback.""" _create_edge(dep_repo, "A", "B") change = _make_change("B") mock_run = PromptRun.create( template_id="template-1", input_bundle_hash="hash-1", ) def callback(run_id): return mock_run result = engine.recompute( change, config=RecomputeConfig(max_depth=1), execution_callback=callback, old_content="old", new_content="new", ) assert result.changed_artifact_id == "B" assert result.total_dependents == 1 assert result.recomputed_count == 1 assert result.suppressed_count == 0 assert len(result.executed_run_ids) == 1 def test_dry_run_no_callback(self, dep_repo, engine): """Test recompute without callback records what would be recomputed.""" _create_edge(dep_repo, "A", "B") change = _make_change("B") result = engine.recompute( change, config=RecomputeConfig(max_depth=1), old_content="old", new_content="new", ) assert result.recomputed_count == 1 assert result.executed_run_ids == ["A"] def test_no_dependents(self, engine): """Test recompute with no dependents.""" change = _make_change("isolated") result = engine.recompute(change) assert result.total_dependents == 0 assert result.recomputed_count == 0 assert result.suppressed_count == 0 def test_depth_control(self, dep_repo, engine): """Test depth limiting controls recompute scope.""" # A -> B -> C _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "C") change = _make_change("C") # Depth 1: only B result1 = engine.recompute( change, config=RecomputeConfig(max_depth=1), old_content="old", new_content="new", ) assert result1.total_dependents == 1 assert result1.recomputed_count == 1 # Depth 2: B and A result2 = engine.recompute( change, config=RecomputeConfig(max_depth=2), old_content="old", new_content="new", ) assert result2.total_dependents == 2 assert result2.recomputed_count == 2 class TestBudgetLimits: """Tests for recompute budget exhaustion.""" def test_budget_exhaustion(self, dep_repo, engine): """Test budget limit suppresses excess recomputes.""" # Create 5 dependents of C for i in range(5): _create_edge(dep_repo, f"dep-{i}", "C") change = _make_change("C") result = engine.recompute( change, config=RecomputeConfig(max_depth=1, max_recomputes=3), old_content="old", new_content="new", ) assert result.total_dependents == 5 assert result.recomputed_count == 3 assert result.suppressed_count == 2 assert all( d.suppression_reason == "budget_exhausted" for d in result.suppressed ) def test_budget_zero_suppresses_all(self, dep_repo, engine): """Test zero budget suppresses all recomputes.""" _create_edge(dep_repo, "A", "B") change = _make_change("B") result = engine.recompute( change, config=RecomputeConfig(max_depth=1, max_recomputes=0), old_content="old", new_content="new", ) assert result.recomputed_count == 0 assert result.suppressed_count == 1 class TestCircularSuppression: """Tests for circular dependency suppression.""" def test_circular_dependency_suppressed(self, dep_repo, engine): """Test circular dependency is suppressed.""" # A -> B and B -> A (circular) _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "A") change = _make_change("B") result = engine.recompute( change, config=RecomputeConfig(max_depth=1, suppress_circular=True), old_content="old", new_content="new", ) assert result.total_dependents == 1 # A is a dependent of B # A depends on B, and B depends on A — would_create_cycle(A, B) is True assert result.suppressed_count == 1 assert result.suppressed[0].suppression_reason == "circular_dependency" def test_circular_suppression_disabled(self, dep_repo, engine): """Test circular suppression can be disabled.""" _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "A") change = _make_change("B") result = engine.recompute( change, config=RecomputeConfig(max_depth=1, suppress_circular=False), old_content="old", new_content="new", ) # With suppression disabled, circular deps are still recomputed assert result.recomputed_count == 1 assert result.suppressed_count == 0 class TestThresholdSuppression: """Tests for impact threshold suppression.""" def test_below_threshold_suppressed(self, dep_repo, engine): """Test below-threshold changes are suppressed.""" _create_edge(dep_repo, "A", "B") change = _make_change("B") # High threshold, small change result = engine.recompute( change, config=RecomputeConfig(max_depth=1, impact_threshold=0.9), old_content="hello world", new_content="hello World", # small change ) assert result.suppressed_count == 1 assert result.suppressed[0].suppression_reason == "below_threshold" def test_above_threshold_recomputed(self, dep_repo, engine): """Test above-threshold changes trigger recompute.""" _create_edge(dep_repo, "A", "B") change = _make_change("B") result = engine.recompute( change, config=RecomputeConfig(max_depth=1, impact_threshold=0.1), old_content="completely old content here", new_content="entirely new different stuff", ) assert result.recomputed_count == 1 assert result.suppressed_count == 0 class TestDebtPersistence: """Tests for impact debt persistence.""" def test_debt_recorded_in_db(self, dep_repo, engine): """Test suppressed recomputes are persisted as debt.""" _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "A") change = _make_change("B") engine.recompute( change, config=RecomputeConfig(max_depth=1, suppress_circular=True), old_content="old", new_content="new", ) debt = engine.get_debt_for_artifact("B") assert len(debt) == 1 assert debt[0].suppression_reason == "circular_dependency" def test_get_all_debt(self, dep_repo, engine): """Test retrieving all debt records.""" # Create two separate suppressed recomputes _create_edge(dep_repo, "A", "B") _create_edge(dep_repo, "B", "A") _create_edge(dep_repo, "C", "D") _create_edge(dep_repo, "D", "C") change1 = _make_change("B") engine.recompute( change1, config=RecomputeConfig(max_depth=1, suppress_circular=True), old_content="old", new_content="new", ) change2 = _make_change("D") engine.recompute( change2, config=RecomputeConfig(max_depth=1, suppress_circular=True), old_content="old", new_content="new", ) all_debt = engine.get_all_debt() assert len(all_debt) == 2