""" 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"])