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>
232 lines
7.8 KiB
Python
232 lines
7.8 KiB
Python
"""
|
|
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
|