ics-simlab-config-gen-claude/tests/test_compile_control_plan.py

293 lines
9.9 KiB
Python

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