""" Tests for compile_control_plan.py Validates that: 1. Control plan files compile without errors 2. Generated files have correct structure (def logic, while True, time.sleep) 3. Warmup sleep is included when specified 4. Threading is used when >1 task 5. Expected patterns (set, add) appear in output """ import json import tempfile from pathlib import Path import pytest from models.control_plan import ControlPlan from tools.compile_control_plan import compile_control_plan, compile_hil, validate_control_plan from tools.safe_eval import safe_eval, validate_expression, UnsafeExpressionError, extract_variable_names # ============================================================================= # Test fixtures paths # ============================================================================= FIXTURES_DIR = Path(__file__).parent / "fixtures" def load_fixture(name: str) -> ControlPlan: """Load a test fixture as ControlPlan.""" path = FIXTURES_DIR / name data = json.loads(path.read_text(encoding="utf-8")) return ControlPlan.model_validate(data) # ============================================================================= # safe_eval tests # ============================================================================= class TestSafeEval: """Test the safe expression evaluator.""" def test_basic_arithmetic(self): assert safe_eval("x + 1", {"x": 5}) == 6 assert safe_eval("x * y", {"x": 3, "y": 4}) == 12 assert safe_eval("x - y", {"x": 10, "y": 3}) == 7 assert safe_eval("x / y", {"x": 10, "y": 2}) == 5.0 def test_comparison(self): assert safe_eval("x > 5", {"x": 10}) == True assert safe_eval("x < 5", {"x": 10}) == False assert safe_eval("x == y", {"x": 5, "y": 5}) == True def test_ternary(self): assert safe_eval("x if x > 0 else y", {"x": 5, "y": 10}) == 5 assert safe_eval("x if x > 0 else y", {"x": -1, "y": 10}) == 10 def test_boolean_ops(self): assert safe_eval("x and y", {"x": True, "y": True}) == True assert safe_eval("x or y", {"x": False, "y": True}) == True assert safe_eval("not x", {"x": False}) == True def test_clamp(self): assert safe_eval("clamp(x, 0, 10)", {"x": 15}) == 10 assert safe_eval("clamp(x, 0, 10)", {"x": -5}) == 0 assert safe_eval("clamp(x, 0, 10)", {"x": 5}) == 5 def test_min_max(self): assert safe_eval("min(x, y)", {"x": 5, "y": 10}) == 5 assert safe_eval("max(x, y)", {"x": 5, "y": 10}) == 10 def test_unsafe_import_rejected(self): with pytest.raises(UnsafeExpressionError): validate_expression("__import__('os')") def test_unsafe_attribute_rejected(self): with pytest.raises(UnsafeExpressionError): validate_expression("x.__class__") def test_extract_variables(self): vars = extract_variable_names("a + b * c") assert vars == {"a", "b", "c"} vars = extract_variable_names("clamp(x, 0, max(y, z))") assert vars == {"x", "y", "z"} # ============================================================================= # ControlPlan schema tests # ============================================================================= class TestControlPlanSchema: """Test ControlPlan Pydantic schema.""" def test_load_bottle_fixture(self): plan = load_fixture("control_plan_bottle_like.json") assert plan.version == "v0.1" assert len(plan.hils) == 1 assert plan.hils[0].name == "physical_io_hil" def test_load_electrical_fixture(self): plan = load_fixture("control_plan_electrical_like.json") assert plan.version == "v0.1" assert len(plan.hils) == 1 assert plan.hils[0].name == "power_grid_hil" # Has 2 tasks (loop + playback) assert len(plan.hils[0].tasks) == 2 def test_load_ied_fixture(self): plan = load_fixture("control_plan_ied_like.json") assert plan.version == "v0.1" assert len(plan.hils) == 1 assert plan.hils[0].name == "ied_simulator" # Has 4 tasks assert len(plan.hils[0].tasks) == 4 # ============================================================================= # Compiler tests # ============================================================================= class TestCompiler: """Test the control plan compiler.""" def test_compile_bottle_fixture(self): plan = load_fixture("control_plan_bottle_like.json") result = compile_control_plan(plan) assert "physical_io_hil" in result code = result["physical_io_hil"] # Check required patterns assert "def logic(physical_values):" in code assert "while True:" in code assert "time.sleep(" in code # Check warmup delay assert "time.sleep(3.0)" in code # Check initialization (uses setdefault for validator compatibility) assert "physical_values.setdefault('tank_level', 500)" in code def test_compile_electrical_fixture(self): plan = load_fixture("control_plan_electrical_like.json") result = compile_control_plan(plan) assert "power_grid_hil" in result code = result["power_grid_hil"] # Has threading (2 tasks) assert "import threading" in code assert "threading.Thread" in code def test_compile_ied_fixture(self): plan = load_fixture("control_plan_ied_like.json") result = compile_control_plan(plan) assert "ied_simulator" in result code = result["ied_simulator"] # Has threading (4 tasks) assert "import threading" in code # Check protection logic task assert "_task_protection_logic" in code # Check playback tasks assert "_task_current_a_sim" in code assert "_task_current_b_sim" in code assert "_task_current_c_sim" in code def test_single_task_no_threading(self): plan = load_fixture("control_plan_bottle_like.json") result = compile_control_plan(plan) code = result["physical_io_hil"] # Single task should NOT use threading assert "import threading" not in code def test_warmup_included(self): plan = load_fixture("control_plan_bottle_like.json") assert plan.hils[0].warmup_s == 3.0 code = compile_hil(plan.hils[0]) assert "time.sleep(3.0)" in code def test_no_warmup_when_not_specified(self): """Create a plan without warmup and check it's not included.""" plan_dict = { "version": "v0.1", "hils": [{ "name": "test_hil", "init": {"x": 0}, "tasks": [{ "type": "loop", "name": "main", "dt_s": 0.1, "actions": [{"set": ["x", "x + 1"]}] }] }] } plan = ControlPlan.model_validate(plan_dict) code = compile_hil(plan.hils[0]) # Should NOT have warmup delay line assert "Warmup delay" not in code # ============================================================================= # Validation tests # ============================================================================= class TestValidation: """Test control plan validation.""" def test_validate_bottle_fixture(self): plan = load_fixture("control_plan_bottle_like.json") errors = validate_control_plan(plan) assert errors == [] def test_validate_electrical_fixture(self): plan = load_fixture("control_plan_electrical_like.json") errors = validate_control_plan(plan) assert errors == [] def test_validate_ied_fixture(self): plan = load_fixture("control_plan_ied_like.json") errors = validate_control_plan(plan) assert errors == [] def test_undefined_variable_detected(self): """Plan with undefined variable should fail validation.""" plan_dict = { "version": "v0.1", "hils": [{ "name": "test_hil", "init": {"x": 0}, "tasks": [{ "type": "loop", "name": "main", "dt_s": 0.1, "actions": [{"set": ["x", "x + undefined_var"]}] }] }] } plan = ControlPlan.model_validate(plan_dict) errors = validate_control_plan(plan) assert len(errors) == 1 assert "undefined_var" in errors[0] # ============================================================================= # File output tests # ============================================================================= class TestFileOutput: """Test that compiled output can be written to files.""" def test_write_to_temp_dir(self): plan = load_fixture("control_plan_bottle_like.json") result = compile_control_plan(plan) with tempfile.TemporaryDirectory() as tmpdir: out_path = Path(tmpdir) / "physical_io_hil.py" out_path.write_text(result["physical_io_hil"], encoding="utf-8") assert out_path.exists() # Read back and verify content = out_path.read_text(encoding="utf-8") assert "def logic(physical_values):" in content def test_all_fixtures_compile_to_valid_python(self): """Ensure all fixtures compile to syntactically valid Python.""" fixtures = [ "control_plan_bottle_like.json", "control_plan_electrical_like.json", "control_plan_ied_like.json", ] for fixture_name in fixtures: plan = load_fixture(fixture_name) result = compile_control_plan(plan) for hil_name, code in result.items(): # Try to compile the generated code try: compile(code, f"<{hil_name}>", "exec") except SyntaxError as e: pytest.fail(f"Syntax error in compiled {fixture_name}/{hil_name}: {e}")