Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/scp_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
IntegrationResult,
)

# Utilities
from .utils import TierUtils

# Testing utilities
from .testing import GraphFixture, ManifestFixture

__all__ = [
"__version__",
# Models
Expand Down Expand Up @@ -67,4 +73,9 @@
"IntegrationConfig",
"IntegrationValidator",
"IntegrationResult",
# Utilities
"TierUtils",
# Testing
"GraphFixture",
"ManifestFixture",
]
5 changes: 5 additions & 0 deletions src/scp_sdk/testing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Testing utilities for SCP SDK."""

from .fixtures import GraphFixture, ManifestFixture

__all__ = ["GraphFixture", "ManifestFixture"]
184 changes: 184 additions & 0 deletions src/scp_sdk/testing/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Test fixtures for SCP SDK testing.

Provides convenient factory methods for creating test graphs and manifests,
reducing boilerplate in integration tests.
"""

from typing import Literal

from ..core.graph import Graph, SystemNode, DependencyEdge
from ..core.models import (
SCPManifest,
System,
Ownership,
Contact,
Capability,
Dependency,
Classification,
)


class GraphFixture:
"""Factory for test graphs.

Example:
>>> graph = GraphFixture.simple_graph()
>>> assert len(graph) == 1
>>>
>>> graph = GraphFixture.with_dependencies(3)
>>> assert len(list(graph.dependencies())) > 0
"""

@staticmethod
def simple_graph() -> Graph:
"""Create minimal valid graph for testing.

Returns:
Graph with single system, no dependencies
"""
system = SystemNode(urn="urn:scp:test:service-a", name="Test Service A")
return Graph(systems=[system], edges=[])

@staticmethod
def with_dependencies(num_systems: int = 3) -> Graph:
"""Create graph with dependency edges.

Creates a chain: system-a -> system-b -> system-c

Args:
num_systems: Number of systems to create (minimum 2)

Returns:
Graph with systems and dependency edges
"""
if num_systems < 2:
num_systems = 2

systems = []
edges = []

for i in range(num_systems):
urn = f"urn:scp:test:service-{chr(ord('a') + i)}"
name = f"Test Service {chr(ord('A') + i)}"
systems.append(SystemNode(urn=urn, name=name, tier=i % 5 + 1))

# Create edge to next system (except for last)
if i < num_systems - 1:
next_urn = f"urn:scp:test:service-{chr(ord('a') + i + 1)}"
edges.append(DependencyEdge(from_urn=urn, to_urn=next_urn))

return Graph(systems=systems, edges=edges)

@staticmethod
def invalid_graph(
issue: Literal[
"missing_name", "broken_dependency", "orphaned"
] = "missing_name",
) -> Graph:
"""Create graph with specific validation issue.

Args:
issue: Type of validation issue to create

Returns:
Graph with specified validation issue
"""
if issue == "missing_name":
system = SystemNode(urn="urn:scp:test:invalid", name="")
return Graph(systems=[system], edges=[])

elif issue == "broken_dependency":
system = SystemNode(urn="urn:scp:test:service-a", name="Service A")
edge = DependencyEdge(
from_urn="urn:scp:test:service-a", to_urn="urn:scp:test:missing"
)
return Graph(systems=[system], edges=[edge])

elif issue == "orphaned":
system = SystemNode(urn="urn:scp:test:orphan", name="Orphaned System")
return Graph(systems=[system], edges=[])

return GraphFixture.simple_graph()


class ManifestFixture:
"""Factory for test manifests.

Example:
>>> manifest = ManifestFixture.minimal()
>>> assert manifest.system.name is not None
>>>
>>> manifest = ManifestFixture.full_featured()
>>> assert manifest.ownership is not None
"""

@staticmethod
def minimal() -> SCPManifest:
"""Minimal valid manifest.

Returns:
Manifest with only required fields
"""
return SCPManifest(
scp="0.1.0",
system=System(urn="urn:scp:test:minimal", name="Minimal Service"),
)

@staticmethod
def full_featured() -> SCPManifest:
"""Manifest with all optional fields.

Returns:
Manifest with classification, ownership, capabilities, dependencies
"""
return SCPManifest(
scp="0.1.0",
system=System(
urn="urn:scp:test:full",
name="Full Featured Service",
description="A test service with all features",
classification=Classification(
tier=2, domain="test", tags=["api", "backend"]
),
),
ownership=Ownership(
team="platform-team",
contacts=[
Contact(type="email", ref="team@example.com"),
Contact(type="slack", ref="#platform"),
],
escalation=["lead", "manager", "director"],
),
provides=[
Capability(capability="user-api", type="rest"), # type: ignore[call-arg]
Capability(capability="user-events", type="event"), # type: ignore[call-arg]
],
depends=[
Dependency(
system="urn:scp:test:database",
capability="user-data",
type="data",
criticality="required",
failure_mode="fail-fast",
)
],
)

@staticmethod
def with_tier(tier: int) -> SCPManifest:
"""Create manifest with specific tier.

Args:
tier: Tier value (1-5)

Returns:
Manifest with specified tier
"""
return SCPManifest(
scp="0.1.0",
system=System(
urn=f"urn:scp:test:tier-{tier}",
name=f"Tier {tier} Service",
classification=Classification(tier=tier),
),
)
5 changes: 5 additions & 0 deletions src/scp_sdk/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""SDK utilities."""

from .tier import TierUtils

__all__ = ["TierUtils"]
93 changes: 93 additions & 0 deletions src/scp_sdk/utils/tier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Utilities for working with tier classifications.

Tier is a core SCP concept (1-5 scale for criticality). These utilities provide
standardized tier handling and mapping support for integrations.
"""

from typing import Any


class TierUtils:
"""Utilities for working with tier classifications.

Tier scale (1-5):
- 1: Critical (highest priority, mission-critical)
- 2: High (important production systems)
- 3: Medium (standard production systems)
- 4: Low (non-critical systems)
- 5: Planning (experimental/development)
"""

TIER_NAMES = {
1: "Critical",
2: "High",
3: "Medium",
4: "Low",
5: "Planning",
}

@staticmethod
def get_name(tier: int | None) -> str:
"""Get human-readable tier name.

Args:
tier: Tier value (1-5) or None

Returns:
Tier name or "Unknown" if invalid

Example:
>>> TierUtils.get_name(1)
'Critical'
>>> TierUtils.get_name(None)
'Unknown'
"""
if tier is None:
return "Unknown"
return TierUtils.TIER_NAMES.get(tier, "Unknown")

@staticmethod
def validate_tier(tier: int | None) -> bool:
"""Check if tier value is valid.

Args:
tier: Tier value to validate

Returns:
True if valid (1-5) or None, False otherwise

Example:
>>> TierUtils.validate_tier(3)
True
>>> TierUtils.validate_tier(10)
False
"""
return tier is None or (isinstance(tier, int) and 1 <= tier <= 5)

@staticmethod
def map_tier(
tier: int | None, mapping: dict[int, Any], default: Any = "Medium"
) -> Any:
"""Map tier to custom value with fallback.

Useful for integrations that need to map SCP tiers to vendor-specific
values (e.g., ServiceNow criticality strings).

Args:
tier: SCP tier value
mapping: Custom tier mapping dictionary
default: Default value if tier not in mapping

Returns:
Mapped value or default

Example:
>>> mapping = {1: "P0", 2: "P1", 3: "P2"}
>>> TierUtils.map_tier(1, mapping)
'P0'
>>> TierUtils.map_tier(None, mapping, default="P3")
'P3'
"""
if tier is None:
return default
return mapping.get(tier, default)
84 changes: 84 additions & 0 deletions tests/test_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Tests for testing fixtures."""

from scp_sdk import GraphFixture, ManifestFixture, Graph, SCPManifest


class TestGraphFixture:
"""Tests for GraphFixture class."""

def test_simple_graph(self):
"""Should create minimal valid graph."""
graph = GraphFixture.simple_graph()
assert isinstance(graph, Graph)
assert len(graph) == 1
assert len(list(graph.dependencies())) == 0

def test_with_dependencies_default(self):
"""Should create graph with 3 systems by default."""
graph = GraphFixture.with_dependencies()
assert len(graph) == 3
assert len(list(graph.dependencies())) == 2 # Chain: a->b->c

def test_with_dependencies_custom_count(self):
"""Should create graph with specified number of systems."""
graph = GraphFixture.with_dependencies(5)
assert len(graph) == 5
assert len(list(graph.dependencies())) == 4 # Chain: a->b->c->d->e

def test_with_dependencies_minimum_enforced(self):
"""Should enforce minimum of 2 systems."""
graph = GraphFixture.with_dependencies(1)
assert len(graph) >= 2

def test_invalid_graph_missing_name(self):
"""Should create graph with missing name issue."""
graph = GraphFixture.invalid_graph("missing_name")
issues = graph.validate()
# Should have orphaned system (info) but name validation is integration-specific
assert isinstance(issues, list)

def test_invalid_graph_broken_dependency(self):
"""Should create graph with broken dependency."""
graph = GraphFixture.invalid_graph("broken_dependency")
issues = graph.validate()
# Should have warning about missing dependency target
warnings = [i for i in issues if i.severity == "warning"]
assert len(warnings) > 0

def test_invalid_graph_orphaned(self):
"""Should create graph with orphaned system."""
graph = GraphFixture.invalid_graph("orphaned")
issues = graph.validate()
# Should have info about orphaned system
infos = [i for i in issues if i.severity == "info"]
assert len(infos) > 0


class TestManifestFixture:
"""Tests for ManifestFixture class."""

def test_minimal(self):
"""Should create minimal valid manifest."""
manifest = ManifestFixture.minimal()
assert isinstance(manifest, SCPManifest)
assert manifest.system.name is not None
assert manifest.ownership is None
assert manifest.provides is None
assert manifest.depends is None

def test_full_featured(self):
"""Should create manifest with all optional fields."""
manifest = ManifestFixture.full_featured()
assert isinstance(manifest, SCPManifest)
assert manifest.system.classification is not None
assert manifest.ownership is not None
assert manifest.provides is not None
assert manifest.depends is not None
assert len(manifest.provides) > 0
assert len(manifest.depends) > 0

def test_with_tier(self):
"""Should create manifest with specified tier."""
for tier in [1, 2, 3, 4, 5]:
manifest = ManifestFixture.with_tier(tier)
assert manifest.system.classification.tier == tier
Loading