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>
411 lines
14 KiB
Python
411 lines
14 KiB
Python
"""
|
|
Unit tests for dependency tracking models.
|
|
|
|
Tests EdgeType, DependencyEdge, DependencyGraph operations,
|
|
cycle detection, and topological sort.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
|
|
from markitect.prompts.dependencies.models import (
|
|
EdgeType,
|
|
DependencyEdge,
|
|
DependencyGraph,
|
|
CircularDependencyError,
|
|
)
|
|
|
|
|
|
class TestEdgeType:
|
|
"""Tests for EdgeType enum."""
|
|
|
|
def test_edge_type_values(self):
|
|
"""Test edge type enum values."""
|
|
assert EdgeType.REQUIRES.value == "requires"
|
|
assert EdgeType.GENERATES.value == "generates"
|
|
assert EdgeType.INCLUDES.value == "includes"
|
|
|
|
def test_edge_type_from_value(self):
|
|
"""Test creating edge type from string value."""
|
|
assert EdgeType("requires") == EdgeType.REQUIRES
|
|
assert EdgeType("generates") == EdgeType.GENERATES
|
|
assert EdgeType("includes") == EdgeType.INCLUDES
|
|
|
|
def test_edge_type_invalid_value(self):
|
|
"""Test invalid edge type raises ValueError."""
|
|
with pytest.raises(ValueError):
|
|
EdgeType("invalid")
|
|
|
|
|
|
class TestDependencyEdge:
|
|
"""Tests for persistent DependencyEdge."""
|
|
|
|
def test_create_edge(self):
|
|
"""Test creating a dependency edge via factory method."""
|
|
edge = DependencyEdge.create(
|
|
source_artifact_id="artifact-1",
|
|
target_artifact_id="artifact-2",
|
|
run_id="run-1",
|
|
edge_type=EdgeType.REQUIRES,
|
|
)
|
|
assert edge.id # UUID generated
|
|
assert edge.source_artifact_id == "artifact-1"
|
|
assert edge.target_artifact_id == "artifact-2"
|
|
assert edge.run_id == "run-1"
|
|
assert edge.edge_type == EdgeType.REQUIRES
|
|
assert isinstance(edge.created_at, datetime)
|
|
|
|
def test_create_edge_unique_ids(self):
|
|
"""Test each created edge gets a unique ID."""
|
|
edge1 = DependencyEdge.create(
|
|
source_artifact_id="a",
|
|
target_artifact_id="b",
|
|
run_id="r1",
|
|
edge_type=EdgeType.REQUIRES,
|
|
)
|
|
edge2 = DependencyEdge.create(
|
|
source_artifact_id="a",
|
|
target_artifact_id="b",
|
|
run_id="r1",
|
|
edge_type=EdgeType.REQUIRES,
|
|
)
|
|
assert edge1.id != edge2.id
|
|
|
|
def test_edge_to_dict(self):
|
|
"""Test edge serialization to dict."""
|
|
edge = DependencyEdge.create(
|
|
source_artifact_id="artifact-1",
|
|
target_artifact_id="artifact-2",
|
|
run_id="run-1",
|
|
edge_type=EdgeType.GENERATES,
|
|
)
|
|
data = edge.to_dict()
|
|
|
|
assert data["id"] == edge.id
|
|
assert data["source_artifact_id"] == "artifact-1"
|
|
assert data["target_artifact_id"] == "artifact-2"
|
|
assert data["run_id"] == "run-1"
|
|
assert data["edge_type"] == "generates"
|
|
assert "created_at" in data
|
|
|
|
def test_edge_from_dict(self):
|
|
"""Test edge deserialization from dict."""
|
|
data = {
|
|
"id": "edge-123",
|
|
"source_artifact_id": "artifact-1",
|
|
"target_artifact_id": "artifact-2",
|
|
"run_id": "run-1",
|
|
"edge_type": "includes",
|
|
"created_at": "2026-01-01T00:00:00",
|
|
}
|
|
edge = DependencyEdge.from_dict(data)
|
|
|
|
assert edge.id == "edge-123"
|
|
assert edge.source_artifact_id == "artifact-1"
|
|
assert edge.target_artifact_id == "artifact-2"
|
|
assert edge.run_id == "run-1"
|
|
assert edge.edge_type == EdgeType.INCLUDES
|
|
assert edge.created_at == datetime(2026, 1, 1)
|
|
|
|
def test_edge_roundtrip(self):
|
|
"""Test serialization roundtrip preserves data."""
|
|
original = DependencyEdge.create(
|
|
source_artifact_id="src",
|
|
target_artifact_id="tgt",
|
|
run_id="run",
|
|
edge_type=EdgeType.REQUIRES,
|
|
)
|
|
restored = DependencyEdge.from_dict(original.to_dict())
|
|
|
|
assert restored.id == original.id
|
|
assert restored.source_artifact_id == original.source_artifact_id
|
|
assert restored.target_artifact_id == original.target_artifact_id
|
|
assert restored.run_id == original.run_id
|
|
assert restored.edge_type == original.edge_type
|
|
|
|
|
|
class TestDependencyGraph:
|
|
"""Tests for DependencyGraph."""
|
|
|
|
def test_empty_graph(self):
|
|
"""Test empty graph properties."""
|
|
graph = DependencyGraph()
|
|
assert len(graph.nodes) == 0
|
|
assert graph.edge_count == 0
|
|
assert not graph.has_cycle()
|
|
|
|
def test_add_edge(self):
|
|
"""Test adding an edge to the graph."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B", EdgeType.REQUIRES)
|
|
|
|
assert "A" in graph.nodes
|
|
assert "B" in graph.nodes
|
|
assert graph.edge_count == 1
|
|
assert graph.has_edge("A", "B")
|
|
assert not graph.has_edge("B", "A")
|
|
|
|
def test_add_multiple_edges(self):
|
|
"""Test adding multiple edges."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "C")
|
|
graph.add_edge("A", "C")
|
|
|
|
assert len(graph.nodes) == 3
|
|
assert graph.edge_count == 3
|
|
|
|
def test_get_successors(self):
|
|
"""Test getting direct successors."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("A", "C")
|
|
graph.add_edge("B", "C")
|
|
|
|
assert graph.get_successors("A") == {"B", "C"}
|
|
assert graph.get_successors("B") == {"C"}
|
|
assert graph.get_successors("C") == set()
|
|
|
|
def test_get_predecessors(self):
|
|
"""Test getting direct predecessors."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("A", "C")
|
|
graph.add_edge("B", "C")
|
|
|
|
assert graph.get_predecessors("A") == set()
|
|
assert graph.get_predecessors("B") == {"A"}
|
|
assert graph.get_predecessors("C") == {"A", "B"}
|
|
|
|
def test_get_successors_nonexistent_node(self):
|
|
"""Test getting successors of non-existent node."""
|
|
graph = DependencyGraph()
|
|
assert graph.get_successors("X") == set()
|
|
|
|
def test_get_predecessors_nonexistent_node(self):
|
|
"""Test getting predecessors of non-existent node."""
|
|
graph = DependencyGraph()
|
|
assert graph.get_predecessors("X") == set()
|
|
|
|
def test_get_edge_type(self):
|
|
"""Test getting edge type between nodes."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B", EdgeType.REQUIRES)
|
|
graph.add_edge("B", "C", EdgeType.GENERATES)
|
|
|
|
assert graph.get_edge_type("A", "B") == EdgeType.REQUIRES
|
|
assert graph.get_edge_type("B", "C") == EdgeType.GENERATES
|
|
assert graph.get_edge_type("A", "C") is None
|
|
|
|
def test_has_edge(self):
|
|
"""Test edge existence check."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
|
|
assert graph.has_edge("A", "B")
|
|
assert not graph.has_edge("B", "A")
|
|
assert not graph.has_edge("A", "C")
|
|
|
|
|
|
class TestDependencyGraphCycleDetection:
|
|
"""Tests for DependencyGraph cycle detection."""
|
|
|
|
def test_no_cycle_linear(self):
|
|
"""Test no cycle in linear graph A -> B -> C."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "C")
|
|
|
|
assert not graph.has_cycle()
|
|
assert graph.detect_cycles() == []
|
|
|
|
def test_no_cycle_diamond(self):
|
|
"""Test no cycle in diamond graph A -> B,C -> D."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("A", "C")
|
|
graph.add_edge("B", "D")
|
|
graph.add_edge("C", "D")
|
|
|
|
assert not graph.has_cycle()
|
|
|
|
def test_simple_cycle(self):
|
|
"""Test detecting simple cycle A -> B -> A."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "A")
|
|
|
|
assert graph.has_cycle()
|
|
cycles = graph.detect_cycles()
|
|
assert len(cycles) > 0
|
|
|
|
def test_three_node_cycle(self):
|
|
"""Test detecting 3-node cycle A -> B -> C -> A."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "C")
|
|
graph.add_edge("C", "A")
|
|
|
|
assert graph.has_cycle()
|
|
cycles = graph.detect_cycles()
|
|
assert len(cycles) > 0
|
|
# The cycle should contain A, B, C
|
|
cycle_nodes = set(cycles[0][:-1]) # Last element repeats the start
|
|
assert cycle_nodes == {"A", "B", "C"}
|
|
|
|
def test_self_loop(self):
|
|
"""Test detecting self-loop A -> A."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "A")
|
|
|
|
assert graph.has_cycle()
|
|
|
|
def test_cycle_with_acyclic_parts(self):
|
|
"""Test cycle detection in graph with both cyclic and acyclic parts."""
|
|
graph = DependencyGraph()
|
|
# Acyclic part
|
|
graph.add_edge("X", "Y")
|
|
# Cyclic part
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "C")
|
|
graph.add_edge("C", "A")
|
|
|
|
assert graph.has_cycle()
|
|
|
|
|
|
class TestDependencyGraphTopologicalSort:
|
|
"""Tests for DependencyGraph topological sort."""
|
|
|
|
def test_topological_sort_linear(self):
|
|
"""Test topological sort of linear graph."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "C")
|
|
|
|
order = graph.topological_sort()
|
|
assert order.index("A") < order.index("B")
|
|
assert order.index("B") < order.index("C")
|
|
|
|
def test_topological_sort_diamond(self):
|
|
"""Test topological sort of diamond graph."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("A", "C")
|
|
graph.add_edge("B", "D")
|
|
graph.add_edge("C", "D")
|
|
|
|
order = graph.topological_sort()
|
|
assert order.index("A") < order.index("B")
|
|
assert order.index("A") < order.index("C")
|
|
assert order.index("B") < order.index("D")
|
|
assert order.index("C") < order.index("D")
|
|
|
|
def test_topological_sort_single_node(self):
|
|
"""Test topological sort with single node."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
|
|
order = graph.topological_sort()
|
|
assert len(order) == 2
|
|
assert order[0] == "A"
|
|
assert order[1] == "B"
|
|
|
|
def test_topological_sort_disconnected(self):
|
|
"""Test topological sort with disconnected components."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("C", "D")
|
|
|
|
order = graph.topological_sort()
|
|
assert len(order) == 4
|
|
assert order.index("A") < order.index("B")
|
|
assert order.index("C") < order.index("D")
|
|
|
|
def test_topological_sort_with_cycle_raises_error(self):
|
|
"""Test topological sort raises error on cycle."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
graph.add_edge("B", "C")
|
|
graph.add_edge("C", "A")
|
|
|
|
with pytest.raises(CircularDependencyError) as exc_info:
|
|
graph.topological_sort()
|
|
|
|
assert exc_info.value.cycle is not None
|
|
assert len(exc_info.value.cycle) > 0
|
|
|
|
def test_topological_sort_empty_graph(self):
|
|
"""Test topological sort of empty graph."""
|
|
graph = DependencyGraph()
|
|
assert graph.topological_sort() == []
|
|
|
|
|
|
class TestDependencyGraphSubgraph:
|
|
"""Tests for DependencyGraph subgraph extraction."""
|
|
|
|
def test_subgraph_extraction(self):
|
|
"""Test extracting a subgraph."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B", EdgeType.REQUIRES)
|
|
graph.add_edge("B", "C", EdgeType.GENERATES)
|
|
graph.add_edge("C", "D", EdgeType.INCLUDES)
|
|
|
|
subgraph = graph.get_subgraph({"A", "B", "C"})
|
|
|
|
assert subgraph.has_edge("A", "B")
|
|
assert subgraph.has_edge("B", "C")
|
|
assert not subgraph.has_edge("C", "D")
|
|
assert "D" not in subgraph.nodes
|
|
|
|
def test_subgraph_preserves_edge_types(self):
|
|
"""Test subgraph preserves edge types."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B", EdgeType.REQUIRES)
|
|
graph.add_edge("B", "C", EdgeType.GENERATES)
|
|
|
|
subgraph = graph.get_subgraph({"A", "B", "C"})
|
|
|
|
assert subgraph.get_edge_type("A", "B") == EdgeType.REQUIRES
|
|
assert subgraph.get_edge_type("B", "C") == EdgeType.GENERATES
|
|
|
|
def test_subgraph_isolated_nodes(self):
|
|
"""Test subgraph includes isolated nodes."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
|
|
subgraph = graph.get_subgraph({"A", "B", "C"})
|
|
|
|
assert "C" in subgraph.nodes
|
|
assert subgraph.get_successors("C") == set()
|
|
|
|
def test_subgraph_empty(self):
|
|
"""Test extracting empty subgraph."""
|
|
graph = DependencyGraph()
|
|
graph.add_edge("A", "B")
|
|
|
|
subgraph = graph.get_subgraph(set())
|
|
assert len(subgraph.nodes) == 0
|
|
|
|
|
|
class TestCircularDependencyError:
|
|
"""Tests for CircularDependencyError."""
|
|
|
|
def test_error_with_cycle(self):
|
|
"""Test error with cycle information."""
|
|
error = CircularDependencyError(
|
|
"Cycle detected",
|
|
cycle=["A", "B", "C", "A"],
|
|
)
|
|
assert str(error) == "Cycle detected"
|
|
assert error.cycle == ["A", "B", "C", "A"]
|
|
|
|
def test_error_without_cycle(self):
|
|
"""Test error without cycle information."""
|
|
error = CircularDependencyError("Cycle detected")
|
|
assert error.cycle == []
|
|
|
|
def test_error_is_exception(self):
|
|
"""Test that CircularDependencyError is an Exception."""
|
|
with pytest.raises(CircularDependencyError):
|
|
raise CircularDependencyError("test")
|