395 lines
13 KiB
Python
395 lines
13 KiB
Python
|
|
"""
|
||
|
|
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"])
|