diff --git a/src/scp_sdk/__init__.py b/src/scp_sdk/__init__.py index 714f32b..cfa203e 100644 --- a/src/scp_sdk/__init__.py +++ b/src/scp_sdk/__init__.py @@ -37,6 +37,12 @@ IntegrationResult, ) +# Utilities +from .utils import TierUtils + +# Testing utilities +from .testing import GraphFixture, ManifestFixture + __all__ = [ "__version__", # Models @@ -67,4 +73,9 @@ "IntegrationConfig", "IntegrationValidator", "IntegrationResult", + # Utilities + "TierUtils", + # Testing + "GraphFixture", + "ManifestFixture", ] diff --git a/src/scp_sdk/testing/__init__.py b/src/scp_sdk/testing/__init__.py new file mode 100644 index 0000000..16c2eda --- /dev/null +++ b/src/scp_sdk/testing/__init__.py @@ -0,0 +1,5 @@ +"""Testing utilities for SCP SDK.""" + +from .fixtures import GraphFixture, ManifestFixture + +__all__ = ["GraphFixture", "ManifestFixture"] diff --git a/src/scp_sdk/testing/fixtures.py b/src/scp_sdk/testing/fixtures.py new file mode 100644 index 0000000..f392390 --- /dev/null +++ b/src/scp_sdk/testing/fixtures.py @@ -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), + ), + ) diff --git a/src/scp_sdk/utils/__init__.py b/src/scp_sdk/utils/__init__.py new file mode 100644 index 0000000..9539284 --- /dev/null +++ b/src/scp_sdk/utils/__init__.py @@ -0,0 +1,5 @@ +"""SDK utilities.""" + +from .tier import TierUtils + +__all__ = ["TierUtils"] diff --git a/src/scp_sdk/utils/tier.py b/src/scp_sdk/utils/tier.py new file mode 100644 index 0000000..cd26d99 --- /dev/null +++ b/src/scp_sdk/utils/tier.py @@ -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) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..39ee4aa --- /dev/null +++ b/tests/test_fixtures.py @@ -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 diff --git a/tests/test_tier.py b/tests/test_tier.py new file mode 100644 index 0000000..7523c54 --- /dev/null +++ b/tests/test_tier.py @@ -0,0 +1,73 @@ +"""Tests for tier utilities.""" + +from scp_sdk import TierUtils + + +class TestTierUtils: + """Tests for TierUtils class.""" + + def test_tier_names(self): + """Should have correct tier name mappings.""" + assert TierUtils.TIER_NAMES[1] == "Critical" + assert TierUtils.TIER_NAMES[2] == "High" + assert TierUtils.TIER_NAMES[3] == "Medium" + assert TierUtils.TIER_NAMES[4] == "Low" + assert TierUtils.TIER_NAMES[5] == "Planning" + + def test_get_name_valid(self): + """Should return correct names for valid tiers.""" + assert TierUtils.get_name(1) == "Critical" + assert TierUtils.get_name(3) == "Medium" + assert TierUtils.get_name(5) == "Planning" + + def test_get_name_none(self): + """Should return Unknown for None.""" + assert TierUtils.get_name(None) == "Unknown" + + def test_get_name_invalid(self): + """Should return Unknown for invalid tiers.""" + assert TierUtils.get_name(0) == "Unknown" + assert TierUtils.get_name(10) == "Unknown" + assert TierUtils.get_name(-1) == "Unknown" + + def test_validate_tier_valid(self): + """Should validate correct tier values.""" + assert TierUtils.validate_tier(1) is True + assert TierUtils.validate_tier(3) is True + assert TierUtils.validate_tier(5) is True + + def test_validate_tier_none(self): + """Should accept None as valid.""" + assert TierUtils.validate_tier(None) is True + + def test_validate_tier_invalid(self): + """Should reject invalid tier values.""" + assert TierUtils.validate_tier(0) is False + assert TierUtils.validate_tier(6) is False + assert TierUtils.validate_tier(10) is False + assert TierUtils.validate_tier(-1) is False + + def test_map_tier_with_mapping(self): + """Should map tier using custom mapping.""" + mapping = {1: "P0", 2: "P1", 3: "P2"} + assert TierUtils.map_tier(1, mapping) == "P0" + assert TierUtils.map_tier(2, mapping) == "P1" + assert TierUtils.map_tier(3, mapping) == "P2" + + def test_map_tier_default(self): + """Should use default for unmapped tiers.""" + mapping = {1: "P0", 2: "P1"} + assert TierUtils.map_tier(3, mapping, default="P3") == "P3" + assert TierUtils.map_tier(None, mapping, default="P3") == "P3" + + def test_map_tier_servicenow_pattern(self): + """Should work with ServiceNow-style criticality mapping.""" + mapping = { + 1: "1 - Critical", + 2: "2 - High", + 3: "3 - Medium", + 4: "4 - Low", + 5: "5 - Planning", + } + assert TierUtils.map_tier(1, mapping) == "1 - Critical" + assert TierUtils.map_tier(3, mapping) == "3 - Medium"