feat(prompts): implement Phase 5 - Dependency Tracking (FR-6)

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>
This commit is contained in:
2026-02-09 13:18:18 +01:00
parent c56c92c815
commit 9ce157400e
13 changed files with 3021 additions and 0 deletions

View File

@@ -0,0 +1,410 @@
"""
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")

View File

@@ -0,0 +1,324 @@
"""
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 == []

View File

@@ -0,0 +1,281 @@
"""
Unit tests for dependency repository.
Tests CRUD operations, duplicate handling, and query by
source, target, run, and type.
"""
import pytest
import tempfile
from pathlib import Path
from markitect.prompts.dependencies.models import DependencyEdge, EdgeType
from markitect.prompts.dependencies.repository import (
SQLiteDependencyRepository,
DuplicateDependencyError,
)
@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 sample_edge():
"""Create sample dependency edge for testing."""
return DependencyEdge.create(
source_artifact_id="artifact-1",
target_artifact_id="artifact-2",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
class TestSQLiteDependencyRepository:
"""Tests for SQLiteDependencyRepository."""
def test_create_edge(self, repository, sample_edge):
"""Test creating a dependency edge."""
created = repository.create(sample_edge)
assert created.id == sample_edge.id
assert created.source_artifact_id == "artifact-1"
assert created.target_artifact_id == "artifact-2"
def test_create_duplicate_edge_raises_error(self, repository):
"""Test creating duplicate edge raises error."""
edge1 = DependencyEdge.create(
source_artifact_id="a",
target_artifact_id="b",
run_id="r1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge1)
edge2 = DependencyEdge.create(
source_artifact_id="a",
target_artifact_id="b",
run_id="r1",
edge_type=EdgeType.REQUIRES,
)
with pytest.raises(DuplicateDependencyError, match="already exists"):
repository.create(edge2)
def test_same_edge_different_runs_allowed(self, repository):
"""Test same source/target with different runs is allowed."""
edge1 = DependencyEdge.create(
source_artifact_id="a",
target_artifact_id="b",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
edge2 = DependencyEdge.create(
source_artifact_id="a",
target_artifact_id="b",
run_id="run-2",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge1)
repository.create(edge2) # Should not raise
def test_get_by_id(self, repository, sample_edge):
"""Test retrieving edge by ID."""
repository.create(sample_edge)
retrieved = repository.get_by_id(sample_edge.id)
assert retrieved is not None
assert retrieved.id == sample_edge.id
assert retrieved.source_artifact_id == sample_edge.source_artifact_id
assert retrieved.edge_type == sample_edge.edge_type
def test_get_by_id_not_found(self, repository):
"""Test retrieving non-existent edge returns None."""
assert repository.get_by_id("nonexistent") is None
def test_get_by_source(self, repository):
"""Test querying edges by source artifact."""
edge1 = DependencyEdge.create(
source_artifact_id="src-1",
target_artifact_id="tgt-1",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
edge2 = DependencyEdge.create(
source_artifact_id="src-1",
target_artifact_id="tgt-2",
run_id="run-1",
edge_type=EdgeType.GENERATES,
)
edge3 = DependencyEdge.create(
source_artifact_id="src-2",
target_artifact_id="tgt-3",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge1)
repository.create(edge2)
repository.create(edge3)
edges = repository.get_by_source("src-1")
assert len(edges) == 2
assert all(e.source_artifact_id == "src-1" for e in edges)
def test_get_by_target(self, repository):
"""Test querying edges by target artifact."""
edge1 = DependencyEdge.create(
source_artifact_id="src-1",
target_artifact_id="tgt-1",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
edge2 = DependencyEdge.create(
source_artifact_id="src-2",
target_artifact_id="tgt-1",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge1)
repository.create(edge2)
edges = repository.get_by_target("tgt-1")
assert len(edges) == 2
assert all(e.target_artifact_id == "tgt-1" for e in edges)
def test_get_by_run(self, repository):
"""Test querying edges by run ID."""
edge1 = DependencyEdge.create(
source_artifact_id="a",
target_artifact_id="b",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
edge2 = DependencyEdge.create(
source_artifact_id="c",
target_artifact_id="d",
run_id="run-1",
edge_type=EdgeType.GENERATES,
)
edge3 = DependencyEdge.create(
source_artifact_id="e",
target_artifact_id="f",
run_id="run-2",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge1)
repository.create(edge2)
repository.create(edge3)
edges = repository.get_by_run("run-1")
assert len(edges) == 2
assert all(e.run_id == "run-1" for e in edges)
def test_get_by_type(self, repository):
"""Test querying edges by type."""
edge1 = DependencyEdge.create(
source_artifact_id="a",
target_artifact_id="b",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
edge2 = DependencyEdge.create(
source_artifact_id="c",
target_artifact_id="d",
run_id="run-1",
edge_type=EdgeType.GENERATES,
)
edge3 = DependencyEdge.create(
source_artifact_id="e",
target_artifact_id="f",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge1)
repository.create(edge2)
repository.create(edge3)
requires_edges = repository.get_by_type(EdgeType.REQUIRES)
assert len(requires_edges) == 2
assert all(e.edge_type == EdgeType.REQUIRES for e in requires_edges)
generates_edges = repository.get_by_type(EdgeType.GENERATES)
assert len(generates_edges) == 1
def test_get_all(self, repository):
"""Test getting all edges."""
for i in range(3):
edge = DependencyEdge.create(
source_artifact_id=f"src-{i}",
target_artifact_id=f"tgt-{i}",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge)
all_edges = repository.get_all()
assert len(all_edges) == 3
def test_get_all_empty(self, repository):
"""Test getting all edges from empty repository."""
assert repository.get_all() == []
def test_delete_edge(self, repository, sample_edge):
"""Test deleting an edge."""
repository.create(sample_edge)
assert repository.delete(sample_edge.id) is True
assert repository.get_by_id(sample_edge.id) is None
def test_delete_nonexistent_edge(self, repository):
"""Test deleting non-existent edge returns False."""
assert repository.delete("nonexistent") is False
def test_delete_by_run(self, repository):
"""Test deleting all edges for a run."""
for i in range(3):
edge = DependencyEdge.create(
source_artifact_id=f"src-{i}",
target_artifact_id=f"tgt-{i}",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge)
other_edge = DependencyEdge.create(
source_artifact_id="other-src",
target_artifact_id="other-tgt",
run_id="run-2",
edge_type=EdgeType.REQUIRES,
)
repository.create(other_edge)
deleted = repository.delete_by_run("run-1")
assert deleted == 3
assert len(repository.get_by_run("run-1")) == 0
assert len(repository.get_by_run("run-2")) == 1
def test_delete_by_run_no_matches(self, repository):
"""Test deleting by run with no matches returns 0."""
assert repository.delete_by_run("nonexistent") == 0
def test_edge_type_preserved(self, repository):
"""Test that edge type is correctly stored and retrieved."""
for edge_type in EdgeType:
edge = DependencyEdge.create(
source_artifact_id=f"src-{edge_type.value}",
target_artifact_id=f"tgt-{edge_type.value}",
run_id="run-1",
edge_type=edge_type,
)
repository.create(edge)
retrieved = repository.get_by_id(edge.id)
assert retrieved.edge_type == edge_type

View File

@@ -0,0 +1,231 @@
"""
Unit tests for GraphBuilder.
Tests extracting edges from manifest, persisting them,
building graphs, and artifact-scoped subgraphs.
"""
import pytest
import tempfile
from pathlib import Path
from markitect.prompts.dependencies.models import (
DependencyEdge,
DependencyGraph,
EdgeType,
)
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.dependencies.graph import GraphBuilder
from markitect.prompts.execution.manifest import (
DependencyEdge as ManifestDependencyEdge,
RunManifest,
)
@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 builder(repository):
"""Create GraphBuilder instance."""
return GraphBuilder(repository)
@pytest.fixture
def sample_manifest():
"""Create a sample RunManifest with dependency edges."""
manifest = RunManifest.create(
run_id="run-1",
template_id="template-1",
template_name="test-template",
template_digest="abc123",
)
manifest.add_dependency_edge("artifact-1", "artifact-2", "requires")
manifest.add_dependency_edge("artifact-2", "artifact-3", "generates")
manifest.add_dependency_edge("artifact-1", "artifact-3", "includes")
return manifest
class TestGraphBuilderExtract:
"""Tests for extracting edges from manifests."""
def test_extract_edges_from_manifest(self, builder, sample_manifest):
"""Test extracting persistent edges from a manifest."""
edges = builder.extract_edges(sample_manifest)
assert len(edges) == 3
assert all(isinstance(e, DependencyEdge) for e in edges)
def test_extracted_edge_fields(self, builder, sample_manifest):
"""Test extracted edges have correct fields."""
edges = builder.extract_edges(sample_manifest)
# First edge: artifact-1 -> artifact-2, requires
edge = edges[0]
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 edge.id # UUID assigned
def test_extracted_edges_have_unique_ids(self, builder, sample_manifest):
"""Test each extracted edge gets a unique ID."""
edges = builder.extract_edges(sample_manifest)
ids = [e.id for e in edges]
assert len(set(ids)) == len(ids)
def test_extract_edges_empty_manifest(self, builder):
"""Test extracting from manifest with no edges."""
manifest = RunManifest.create(
run_id="run-empty",
template_id="template-1",
template_name="empty",
template_digest="xyz",
)
edges = builder.extract_edges(manifest)
assert edges == []
def test_extract_edges_preserves_types(self, builder, sample_manifest):
"""Test all edge types are correctly mapped."""
edges = builder.extract_edges(sample_manifest)
types = [e.edge_type for e in edges]
assert EdgeType.REQUIRES in types
assert EdgeType.GENERATES in types
assert EdgeType.INCLUDES in types
class TestGraphBuilderPersist:
"""Tests for persisting edges from manifests."""
def test_persist_edges(self, builder, repository, sample_manifest):
"""Test persisting edges to repository."""
persisted = builder.persist_edges(sample_manifest)
assert len(persisted) == 3
# Verify in repository
all_edges = repository.get_all()
assert len(all_edges) == 3
def test_persisted_edges_retrievable(self, builder, repository, sample_manifest):
"""Test persisted edges can be retrieved from repository."""
builder.persist_edges(sample_manifest)
edges_from_source = repository.get_by_source("artifact-1")
assert len(edges_from_source) == 2 # artifact-1 -> artifact-2 and artifact-3
edges_from_run = repository.get_by_run("run-1")
assert len(edges_from_run) == 3
class TestGraphBuilderBuildGraph:
"""Tests for building dependency graphs."""
def test_build_graph_from_all_edges(self, builder, repository):
"""Test building graph from all stored edges."""
# Manually create some edges
for src, tgt in [("A", "B"), ("B", "C"), ("A", "C")]:
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge)
graph = builder.build_graph()
assert len(graph.nodes) == 3
assert graph.has_edge("A", "B")
assert graph.has_edge("B", "C")
assert graph.has_edge("A", "C")
def test_build_graph_scoped(self, builder, repository):
"""Test building scoped graph with subset of artifacts."""
for src, tgt in [("A", "B"), ("B", "C"), ("C", "D")]:
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge)
graph = builder.build_graph(artifact_ids={"A", "B", "C"})
assert graph.has_edge("A", "B")
assert graph.has_edge("B", "C")
assert not graph.has_edge("C", "D")
assert "D" not in graph.nodes
def test_build_graph_scoped_includes_isolated_nodes(self, builder, repository):
"""Test scoped graph includes nodes with no edges in scope."""
edge = DependencyEdge.create(
source_artifact_id="A",
target_artifact_id="B",
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge)
graph = builder.build_graph(artifact_ids={"A", "B", "C"})
assert "C" in graph.nodes
def test_build_graph_empty(self, builder):
"""Test building graph from empty repository."""
graph = builder.build_graph()
assert len(graph.nodes) == 0
assert graph.edge_count == 0
def test_build_graph_for_run(self, builder, repository):
"""Test building graph from a specific run's edges."""
# Run 1 edges
for src, tgt in [("A", "B"), ("B", "C")]:
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
repository.create(edge)
# Run 2 edges
edge = DependencyEdge.create(
source_artifact_id="X",
target_artifact_id="Y",
run_id="run-2",
edge_type=EdgeType.GENERATES,
)
repository.create(edge)
graph = builder.build_graph_for_run("run-1")
assert graph.has_edge("A", "B")
assert graph.has_edge("B", "C")
assert not graph.has_edge("X", "Y")
def test_build_graph_preserves_edge_types(self, builder, repository):
"""Test built graph preserves edge types."""
edge = DependencyEdge.create(
source_artifact_id="A",
target_artifact_id="B",
run_id="run-1",
edge_type=EdgeType.GENERATES,
)
repository.create(edge)
graph = builder.build_graph()
assert graph.get_edge_type("A", "B") == EdgeType.GENERATES