Files
Atomizer/tests/test_spec_manager.py

395 lines
13 KiB
Python
Raw Normal View History

"""
Unit tests for SpecManager
Tests for AtomizerSpec v2.0 core functionality:
- Loading and saving specs
- Patching spec values
- Node operations (add/remove)
- Custom function support
- Validation
P4.4: Spec unit tests
"""
import json
import pytest
import tempfile
from pathlib import Path
from datetime import datetime
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from optimization_engine.config.spec_models import (
AtomizerSpec,
DesignVariable,
Extractor,
Objective,
Constraint,
)
from optimization_engine.config.spec_validator import SpecValidator, SpecValidationError
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def minimal_spec() -> dict:
"""Minimal valid AtomizerSpec."""
return {
"meta": {
"version": "2.0",
"created": datetime.now().isoformat() + "Z",
"modified": datetime.now().isoformat() + "Z",
"created_by": "api",
"modified_by": "api",
"study_name": "test_study"
},
"model": {
"sim": {
"path": "model.sim",
"solver": "nastran"
}
},
"design_variables": [
{
"id": "dv_001",
"name": "thickness",
"expression_name": "thickness",
"type": "continuous",
"bounds": {"min": 1.0, "max": 10.0},
"baseline": 5.0,
"enabled": True,
"canvas_position": {"x": 50, "y": 100}
}
],
"extractors": [
{
"id": "ext_001",
"name": "Mass Extractor",
"type": "mass",
"builtin": True,
"outputs": [{"name": "mass", "units": "kg"}],
"canvas_position": {"x": 740, "y": 100}
}
],
"objectives": [
{
"id": "obj_001",
"name": "mass",
"direction": "minimize",
"source": {
"extractor_id": "ext_001",
"output_name": "mass"
},
"canvas_position": {"x": 1020, "y": 100}
}
],
"constraints": [],
"optimization": {
"algorithm": {"type": "TPE"},
"budget": {"max_trials": 100}
},
"canvas": {
"edges": [
{"source": "dv_001", "target": "model"},
{"source": "model", "target": "solver"},
{"source": "solver", "target": "ext_001"},
{"source": "ext_001", "target": "obj_001"},
{"source": "obj_001", "target": "optimization"}
],
"layout_version": "2.0"
}
}
@pytest.fixture
def temp_study_dir(minimal_spec):
"""Create temporary study directory with spec."""
with tempfile.TemporaryDirectory() as tmpdir:
study_path = Path(tmpdir) / "test_study"
study_path.mkdir()
setup_path = study_path / "1_setup"
setup_path.mkdir()
spec_path = study_path / "atomizer_spec.json"
with open(spec_path, "w") as f:
json.dump(minimal_spec, f, indent=2)
yield study_path
# ============================================================================
# Spec Model Tests
# ============================================================================
class TestSpecModels:
"""Tests for Pydantic spec models."""
def test_design_variable_valid(self):
"""Test valid design variable creation."""
dv = DesignVariable(
id="dv_001",
name="thickness",
expression_name="thickness",
type="continuous",
bounds={"min": 1.0, "max": 10.0}
)
assert dv.id == "dv_001"
assert dv.bounds.min == 1.0
assert dv.bounds.max == 10.0
assert dv.enabled is True # Default
def test_design_variable_invalid_bounds(self):
"""Test design variable with min > max raises error."""
with pytest.raises(Exception): # Pydantic validation error
DesignVariable(
id="dv_001",
name="thickness",
expression_name="thickness",
type="continuous",
bounds={"min": 10.0, "max": 1.0} # Invalid: min > max
)
def test_extractor_valid(self):
"""Test valid extractor creation."""
ext = Extractor(
id="ext_001",
name="Mass",
type="mass",
builtin=True,
outputs=[{"name": "mass", "units": "kg"}]
)
assert ext.id == "ext_001"
assert ext.type == "mass"
assert len(ext.outputs) == 1
def test_objective_valid(self):
"""Test valid objective creation."""
obj = Objective(
id="obj_001",
name="mass",
direction="minimize",
source={"extractor_id": "ext_001", "output_name": "mass"}
)
assert obj.direction == "minimize"
assert obj.source.extractor_id == "ext_001"
def test_full_spec_valid(self, minimal_spec):
"""Test full spec validation."""
spec = AtomizerSpec(**minimal_spec)
assert spec.meta.version == "2.0"
assert len(spec.design_variables) == 1
assert len(spec.extractors) == 1
assert len(spec.objectives) == 1
# ============================================================================
# Spec Validator Tests
# ============================================================================
class TestSpecValidator:
"""Tests for spec validation."""
def test_validate_valid_spec(self, minimal_spec):
"""Test validation of valid spec."""
validator = SpecValidator()
report = validator.validate(minimal_spec, strict=False)
# Valid spec should have no errors (may have warnings)
assert report.valid is True
assert len(report.errors) == 0
def test_validate_missing_meta(self, minimal_spec):
"""Test validation catches missing meta."""
del minimal_spec["meta"]
validator = SpecValidator()
report = validator.validate(minimal_spec, strict=False)
assert len(report.errors) > 0
def test_validate_invalid_objective_reference(self, minimal_spec):
"""Test validation catches invalid extractor reference."""
minimal_spec["objectives"][0]["source"]["extractor_id"] = "nonexistent"
validator = SpecValidator()
report = validator.validate(minimal_spec, strict=False)
# Should catch the reference error
assert any("unknown extractor" in str(e.message).lower() for e in report.errors)
def test_validate_invalid_bounds(self, minimal_spec):
"""Test validation catches invalid bounds."""
minimal_spec["design_variables"][0]["bounds"] = {"min": 10, "max": 1}
validator = SpecValidator()
report = validator.validate(minimal_spec, strict=False)
assert len(report.errors) > 0
def test_validate_empty_extractors(self, minimal_spec):
"""Test validation catches empty extractors with objectives."""
minimal_spec["extractors"] = []
validator = SpecValidator()
report = validator.validate(minimal_spec, strict=False)
# Should catch missing extractor for objective
assert len(report.errors) > 0
# ============================================================================
# SpecManager Tests (if available)
# ============================================================================
class TestSpecManagerOperations:
"""Tests for SpecManager operations (if spec_manager is importable)."""
@pytest.fixture
def spec_manager(self, temp_study_dir):
"""Get SpecManager instance."""
try:
sys.path.insert(0, str(Path(__file__).parent.parent / "atomizer-dashboard" / "backend"))
from api.services.spec_manager import SpecManager
return SpecManager(temp_study_dir)
except ImportError:
pytest.skip("SpecManager not available")
def test_load_spec(self, spec_manager):
"""Test loading spec from file."""
spec = spec_manager.load()
assert spec.meta.study_name == "test_study"
assert len(spec.design_variables) == 1
def test_save_spec(self, spec_manager, minimal_spec, temp_study_dir):
"""Test saving spec to file."""
# Modify and save
minimal_spec["meta"]["study_name"] = "modified_study"
spec_manager.save(minimal_spec)
# Reload and verify
spec = spec_manager.load()
assert spec.meta.study_name == "modified_study"
def test_patch_spec(self, spec_manager):
"""Test patching spec values."""
spec_manager.patch("design_variables[0].bounds.max", 20.0)
spec = spec_manager.load()
assert spec.design_variables[0].bounds.max == 20.0
def test_add_design_variable(self, spec_manager):
"""Test adding a design variable."""
new_dv = {
"name": "width",
"expression_name": "width",
"type": "continuous",
"bounds": {"min": 5.0, "max": 15.0},
"baseline": 10.0,
"enabled": True
}
try:
node_id = spec_manager.add_node("designVar", new_dv)
spec = spec_manager.load()
assert len(spec.design_variables) == 2
assert any(dv.name == "width" for dv in spec.design_variables)
except SpecValidationError:
# Strict validation may reject - that's acceptable
pytest.skip("Strict validation rejects partial DV data")
def test_remove_design_variable(self, spec_manager):
"""Test removing a design variable."""
# First add a second DV so we can remove one without emptying
new_dv = {
"name": "height",
"expression_name": "height",
"type": "continuous",
"bounds": {"min": 1.0, "max": 10.0},
"baseline": 5.0,
"enabled": True
}
try:
spec_manager.add_node("designVar", new_dv)
# Now remove the original
spec_manager.remove_node("dv_001")
spec = spec_manager.load()
assert len(spec.design_variables) == 1
assert spec.design_variables[0].name == "height"
except SpecValidationError:
pytest.skip("Strict validation prevents removal")
def test_get_hash(self, spec_manager):
"""Test hash computation."""
hash1 = spec_manager.get_hash()
assert isinstance(hash1, str)
assert len(hash1) > 0
# Hash should change after modification
spec_manager.patch("meta.study_name", "new_name")
hash2 = spec_manager.get_hash()
assert hash1 != hash2
# ============================================================================
# Custom Extractor Tests
# ============================================================================
class TestCustomExtractor:
"""Tests for custom Python extractor support."""
def test_validate_custom_extractor_code(self):
"""Test custom extractor code validation."""
from optimization_engine.extractors.custom_extractor_loader import validate_extractor_code
valid_code = '''
def extract(op2_path, bdf_path=None, params=None, working_dir=None):
import numpy as np
return {"result": 42.0}
'''
is_valid, errors = validate_extractor_code(valid_code, "extract")
assert is_valid is True
assert len(errors) == 0
def test_reject_dangerous_code(self):
"""Test that dangerous code patterns are rejected."""
from optimization_engine.extractors.custom_extractor_loader import (
validate_extractor_code,
ExtractorSecurityError
)
dangerous_code = '''
def extract(op2_path, bdf_path=None, params=None, working_dir=None):
import os
os.system("rm -rf /")
return {"result": 0}
'''
with pytest.raises(ExtractorSecurityError):
validate_extractor_code(dangerous_code, "extract")
def test_reject_exec_code(self):
"""Test that exec/eval are rejected."""
from optimization_engine.extractors.custom_extractor_loader import (
validate_extractor_code,
ExtractorSecurityError
)
exec_code = '''
def extract(op2_path, bdf_path=None, params=None, working_dir=None):
exec("malicious_code")
return {"result": 0}
'''
with pytest.raises(ExtractorSecurityError):
validate_extractor_code(exec_code, "extract")
def test_require_function_signature(self):
"""Test that function must have valid signature."""
from optimization_engine.extractors.custom_extractor_loader import validate_extractor_code
wrong_signature = '''
def extract(x, y, z):
return x + y + z
'''
is_valid, errors = validate_extractor_code(wrong_signature, "extract")
assert is_valid is False
assert len(errors) > 0
# ============================================================================
# Run Tests
# ============================================================================
if __name__ == "__main__":
pytest.main([__file__, "-v"])