Add directed dependency graph with cycle detection, topological sort, and query service for finding dependents/dependencies transitively. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""
|
|
Unit tests for DependencyQueryService.
|
|
|
|
Tests direct/transitive dependents and dependencies, dependency chains,
|
|
cycle validation, would_create_cycle, and build order.
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from markitect.prompts.dependencies.models import (
|
|
DependencyEdge,
|
|
DependencyGraph,
|
|
EdgeType,
|
|
CircularDependencyError,
|
|
)
|
|
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
|
|
from markitect.prompts.dependencies.queries import DependencyQueryService
|
|
|
|
|
|
@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 repository(temp_db):
|
|
"""Create repository instance with temp database."""
|
|
return SQLiteDependencyRepository(temp_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def query_service(repository):
|
|
"""Create DependencyQueryService instance."""
|
|
return DependencyQueryService(repository)
|
|
|
|
|
|
def _create_edge(repository, 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 repository.create(edge)
|
|
|
|
|
|
class TestFindDependents:
|
|
"""Tests for find_dependents (who depends on X)."""
|
|
|
|
def test_find_direct_dependents(self, repository, query_service):
|
|
"""Test finding direct dependents of an artifact."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "C", "B")
|
|
|
|
dependents = query_service.find_dependents("B")
|
|
assert dependents == {"A", "C"}
|
|
|
|
def test_find_dependents_none(self, repository, query_service):
|
|
"""Test finding dependents when none exist."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
dependents = query_service.find_dependents("A")
|
|
assert dependents == set()
|
|
|
|
def test_find_dependents_nonexistent(self, query_service):
|
|
"""Test finding dependents of non-existent artifact."""
|
|
dependents = query_service.find_dependents("nonexistent")
|
|
assert dependents == set()
|
|
|
|
|
|
class TestFindDependencies:
|
|
"""Tests for find_dependencies (what X depends on)."""
|
|
|
|
def test_find_direct_dependencies(self, repository, query_service):
|
|
"""Test finding direct dependencies of an artifact."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "A", "C")
|
|
|
|
dependencies = query_service.find_dependencies("A")
|
|
assert dependencies == {"B", "C"}
|
|
|
|
def test_find_dependencies_none(self, repository, query_service):
|
|
"""Test finding dependencies when none exist."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
dependencies = query_service.find_dependencies("B")
|
|
assert dependencies == set()
|
|
|
|
|
|
class TestTransitiveDependents:
|
|
"""Tests for find_transitive_dependents."""
|
|
|
|
def test_transitive_dependents(self, repository, query_service):
|
|
"""Test finding transitive dependents (upstream impact)."""
|
|
# A -> B -> C
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
|
|
# Who is transitively affected by C?
|
|
transitive = query_service.find_transitive_dependents("C")
|
|
assert transitive == {"A", "B"}
|
|
|
|
def test_transitive_dependents_diamond(self, repository, query_service):
|
|
"""Test transitive dependents in diamond graph."""
|
|
# A -> B -> D, A -> C -> D
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "A", "C")
|
|
_create_edge(repository, "B", "D")
|
|
_create_edge(repository, "C", "D")
|
|
|
|
transitive = query_service.find_transitive_dependents("D")
|
|
assert transitive == {"A", "B", "C"}
|
|
|
|
def test_transitive_dependents_none(self, repository, query_service):
|
|
"""Test transitive dependents when none exist."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
transitive = query_service.find_transitive_dependents("A")
|
|
assert transitive == set()
|
|
|
|
|
|
class TestTransitiveDependencies:
|
|
"""Tests for find_transitive_dependencies."""
|
|
|
|
def test_transitive_dependencies(self, repository, query_service):
|
|
"""Test finding transitive dependencies (full dep tree)."""
|
|
# A -> B -> C
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
|
|
# What does A transitively depend on?
|
|
transitive = query_service.find_transitive_dependencies("A")
|
|
assert transitive == {"B", "C"}
|
|
|
|
def test_transitive_dependencies_complex(self, repository, query_service):
|
|
"""Test transitive dependencies in complex graph."""
|
|
# A -> B -> D, A -> C -> D, D -> E
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "A", "C")
|
|
_create_edge(repository, "B", "D")
|
|
_create_edge(repository, "C", "D")
|
|
_create_edge(repository, "D", "E")
|
|
|
|
transitive = query_service.find_transitive_dependencies("A")
|
|
assert transitive == {"B", "C", "D", "E"}
|
|
|
|
def test_transitive_dependencies_leaf(self, repository, query_service):
|
|
"""Test transitive dependencies of a leaf node."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
transitive = query_service.find_transitive_dependencies("B")
|
|
assert transitive == set()
|
|
|
|
|
|
class TestDependencyChain:
|
|
"""Tests for get_dependency_chain (BFS path finding)."""
|
|
|
|
def test_direct_chain(self, repository, query_service):
|
|
"""Test finding a direct dependency chain."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
chain = query_service.get_dependency_chain("A", "B")
|
|
assert chain == ["A", "B"]
|
|
|
|
def test_multi_hop_chain(self, repository, query_service):
|
|
"""Test finding a multi-hop dependency chain."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
_create_edge(repository, "C", "D")
|
|
|
|
chain = query_service.get_dependency_chain("A", "D")
|
|
assert chain is not None
|
|
assert chain[0] == "A"
|
|
assert chain[-1] == "D"
|
|
assert len(chain) == 4
|
|
|
|
def test_no_chain_exists(self, repository, query_service):
|
|
"""Test when no chain exists between artifacts."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "C", "D")
|
|
|
|
chain = query_service.get_dependency_chain("A", "D")
|
|
assert chain is None
|
|
|
|
def test_same_source_target(self, query_service):
|
|
"""Test chain from node to itself."""
|
|
chain = query_service.get_dependency_chain("A", "A")
|
|
assert chain == ["A"]
|
|
|
|
def test_shortest_chain(self, repository, query_service):
|
|
"""Test BFS finds shortest chain."""
|
|
# A -> B -> C (length 3)
|
|
# A -> C (length 2, shorter)
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
_create_edge(repository, "A", "C")
|
|
|
|
chain = query_service.get_dependency_chain("A", "C")
|
|
assert chain == ["A", "C"]
|
|
|
|
|
|
class TestCircularDependencyDetection:
|
|
"""Tests for detect_circular_dependencies."""
|
|
|
|
def test_no_cycles(self, repository, query_service):
|
|
"""Test detection when no cycles exist."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
|
|
cycles = query_service.detect_circular_dependencies()
|
|
assert cycles == []
|
|
|
|
def test_detect_simple_cycle(self, repository, query_service):
|
|
"""Test detecting a simple cycle."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "A")
|
|
|
|
cycles = query_service.detect_circular_dependencies()
|
|
assert len(cycles) > 0
|
|
|
|
def test_detect_three_node_cycle(self, repository, query_service):
|
|
"""Test detecting a 3-node cycle."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
_create_edge(repository, "C", "A")
|
|
|
|
cycles = query_service.detect_circular_dependencies()
|
|
assert len(cycles) > 0
|
|
|
|
|
|
class TestWouldCreateCycle:
|
|
"""Tests for would_create_cycle pre-validation."""
|
|
|
|
def test_would_not_create_cycle(self, repository, query_service):
|
|
"""Test edge that would not create a cycle."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
assert query_service.would_create_cycle("B", "C") is False
|
|
|
|
def test_would_create_cycle_direct(self, repository, query_service):
|
|
"""Test edge that would create a direct cycle."""
|
|
_create_edge(repository, "A", "B")
|
|
|
|
assert query_service.would_create_cycle("B", "A") is True
|
|
|
|
def test_would_create_cycle_transitive(self, repository, query_service):
|
|
"""Test edge that would create a transitive cycle."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
|
|
assert query_service.would_create_cycle("C", "A") is True
|
|
|
|
def test_self_reference_creates_cycle(self, query_service):
|
|
"""Test self-reference always creates a cycle."""
|
|
assert query_service.would_create_cycle("A", "A") is True
|
|
|
|
def test_safe_parallel_edge(self, repository, query_service):
|
|
"""Test parallel forward edge is safe."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
|
|
# Adding A -> C doesn't create a cycle
|
|
assert query_service.would_create_cycle("A", "C") is False
|
|
|
|
|
|
class TestBuildOrder:
|
|
"""Tests for get_build_order (topological sort)."""
|
|
|
|
def test_simple_build_order(self, repository, query_service):
|
|
"""Test simple build order (dependencies before dependents)."""
|
|
# A requires B, B requires C → build C first, then B, then A
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
|
|
order = query_service.get_build_order()
|
|
assert order.index("C") < order.index("B")
|
|
assert order.index("B") < order.index("A")
|
|
|
|
def test_build_order_diamond(self, repository, query_service):
|
|
"""Test build order with diamond dependencies."""
|
|
# A requires B and C, B and C require D → build D first, then B/C, then A
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "A", "C")
|
|
_create_edge(repository, "B", "D")
|
|
_create_edge(repository, "C", "D")
|
|
|
|
order = query_service.get_build_order()
|
|
assert order.index("D") < order.index("B")
|
|
assert order.index("D") < order.index("C")
|
|
assert order.index("B") < order.index("A")
|
|
assert order.index("C") < order.index("A")
|
|
|
|
def test_build_order_scoped(self, repository, query_service):
|
|
"""Test build order with scoped artifact set."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
_create_edge(repository, "C", "D")
|
|
|
|
order = query_service.get_build_order(artifact_ids={"A", "B", "C"})
|
|
assert len(order) == 3
|
|
assert "D" not in order
|
|
assert order.index("C") < order.index("B")
|
|
assert order.index("B") < order.index("A")
|
|
|
|
def test_build_order_with_cycle_raises_error(self, repository, query_service):
|
|
"""Test build order raises error on cycle."""
|
|
_create_edge(repository, "A", "B")
|
|
_create_edge(repository, "B", "C")
|
|
_create_edge(repository, "C", "A")
|
|
|
|
with pytest.raises(CircularDependencyError):
|
|
query_service.get_build_order()
|
|
|
|
def test_build_order_empty(self, query_service):
|
|
"""Test build order with no edges."""
|
|
order = query_service.get_build_order()
|
|
assert order == []
|