293 lines
9.9 KiB
Python
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}")
|