202 lines
7.9 KiB
Python
202 lines
7.9 KiB
Python
"""
|
|
Regression test: HIL initialization must be detectable by validate_logic --check-hil-init.
|
|
|
|
This test verifies that:
|
|
1. tools.compile_control_plan generates HIL code with physical_values.setdefault() calls
|
|
2. tools.validate_logic --check-hil-init passes on the generated code
|
|
|
|
Root cause of original bug:
|
|
- Compiler used alias `pv = physical_values` then `pv['key'] = value`
|
|
- Validator AST parser only detected `physical_values[...]` (literal name, not aliases)
|
|
|
|
Fix:
|
|
- Emit explicit `physical_values.setdefault('<key>', <default>)` at TOP of logic()
|
|
- BEFORE any alias or threads
|
|
"""
|
|
|
|
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
|
|
from services.validation.logic_validation import validate_logic_against_config
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
|
EXAMPLES_DIR = Path(__file__).parent.parent / "examples" / "control_plans"
|
|
|
|
|
|
class TestHilInitValidation:
|
|
"""Regression tests for HIL initialization detection."""
|
|
|
|
def test_compiled_hil_passes_init_validation(self):
|
|
"""
|
|
Compile bottle_line_v0.1.json and verify validate_logic --check-hil-init passes.
|
|
|
|
This test would FAIL before the fix because:
|
|
- Compiler emitted: pv['water_tank_level'] = 500
|
|
- Validator looked for: physical_values['water_tank_level'] = ...
|
|
|
|
After the fix:
|
|
- Compiler emits: physical_values.setdefault('water_tank_level', 500)
|
|
- Validator detects this via AST
|
|
"""
|
|
# Load control plan
|
|
plan_path = EXAMPLES_DIR / "bottle_line_v0.1.json"
|
|
plan_dict = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
plan = ControlPlan.model_validate(plan_dict)
|
|
|
|
# Load config fixture (defines HIL physical_values)
|
|
config_path = FIXTURES_DIR / "config_hil_bottle_like.json"
|
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
# Compile with config to ensure ALL physical_values are initialized
|
|
hil_code = compile_control_plan(plan, config=config)
|
|
|
|
# Write to temp directory
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
logic_dir = Path(tmpdir)
|
|
|
|
for hil_name, code in hil_code.items():
|
|
safe_name = hil_name.replace(" ", "_").replace("-", "_")
|
|
out_file = logic_dir / f"{safe_name}.py"
|
|
out_file.write_text(code, encoding="utf-8")
|
|
|
|
# Run validation with --check-hil-init
|
|
issues = validate_logic_against_config(
|
|
config_path=str(config_path),
|
|
logic_dir=str(logic_dir),
|
|
check_hil_init=True,
|
|
)
|
|
|
|
# Filter for HIL_INIT issues only (ignore MAPPING for PLCs etc.)
|
|
hil_init_issues = [i for i in issues if i.kind == "HIL_INIT"]
|
|
|
|
# Should be no HIL_INIT issues
|
|
assert hil_init_issues == [], (
|
|
f"HIL initialization validation failed:\n"
|
|
+ "\n".join(f" - {i.file}: {i.key} -> {i.message}" for i in hil_init_issues)
|
|
)
|
|
|
|
def test_compiled_hil_contains_setdefault_calls(self):
|
|
"""
|
|
Verify generated code contains physical_values.setdefault() calls (not alias).
|
|
"""
|
|
plan_path = EXAMPLES_DIR / "bottle_line_v0.1.json"
|
|
plan_dict = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
plan = ControlPlan.model_validate(plan_dict)
|
|
|
|
config_path = FIXTURES_DIR / "config_hil_bottle_like.json"
|
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
hil_code = compile_control_plan(plan, config=config)
|
|
|
|
# Check water_hil
|
|
water_code = hil_code["water_hil"]
|
|
assert "physical_values.setdefault('water_tank_level'" in water_code
|
|
assert "physical_values.setdefault('tank_input_valve'" in water_code
|
|
assert "physical_values.setdefault('tank_output_valve'" in water_code
|
|
|
|
# Check filler_hil
|
|
filler_code = hil_code["filler_hil"]
|
|
assert "physical_values.setdefault('bottle_fill_level'" in filler_code
|
|
assert "physical_values.setdefault('bottle_at_filler'" in filler_code
|
|
assert "physical_values.setdefault('bottle_distance'" in filler_code
|
|
assert "physical_values.setdefault('conveyor_cmd'" in filler_code
|
|
assert "physical_values.setdefault('fill_valve'" in filler_code
|
|
|
|
def test_setdefault_before_alias(self):
|
|
"""
|
|
Verify setdefault calls appear BEFORE the pv alias is created.
|
|
|
|
This is critical because the validator does AST analysis and needs
|
|
to see physical_values[...] assignments, not pv[...] assignments.
|
|
"""
|
|
plan_path = EXAMPLES_DIR / "bottle_line_v0.1.json"
|
|
plan_dict = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
plan = ControlPlan.model_validate(plan_dict)
|
|
|
|
config_path = FIXTURES_DIR / "config_hil_bottle_like.json"
|
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
hil_code = compile_control_plan(plan, config=config)
|
|
|
|
for hil_name, code in hil_code.items():
|
|
# Find positions
|
|
setdefault_pos = code.find("physical_values.setdefault(")
|
|
alias_pos = code.find("pv = physical_values")
|
|
|
|
assert setdefault_pos != -1, f"{hil_name}: no setdefault found"
|
|
assert alias_pos != -1, f"{hil_name}: no alias found"
|
|
assert setdefault_pos < alias_pos, (
|
|
f"{hil_name}: setdefault must appear BEFORE alias. "
|
|
f"setdefault at {setdefault_pos}, alias at {alias_pos}"
|
|
)
|
|
|
|
def test_init_value_preserved_from_plan(self):
|
|
"""
|
|
Verify that init values from plan.init are used (not just 0).
|
|
"""
|
|
plan_path = EXAMPLES_DIR / "bottle_line_v0.1.json"
|
|
plan_dict = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
plan = ControlPlan.model_validate(plan_dict)
|
|
|
|
config_path = FIXTURES_DIR / "config_hil_bottle_like.json"
|
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
hil_code = compile_control_plan(plan, config=config)
|
|
|
|
# water_hil init: water_tank_level=500
|
|
water_code = hil_code["water_hil"]
|
|
assert "physical_values.setdefault('water_tank_level', 500)" in water_code
|
|
|
|
# filler_hil init: bottle_fill_level=0, bottle_at_filler=1, bottle_distance=0
|
|
filler_code = hil_code["filler_hil"]
|
|
assert "physical_values.setdefault('bottle_at_filler', 1)" in filler_code
|
|
|
|
def test_config_only_keys_initialized_with_default(self):
|
|
"""
|
|
If config has physical_values not in plan.init, they should be initialized to 0.
|
|
|
|
This ensures validate_logic --check-hil-init passes even when the config
|
|
declares more keys than the plan initializes.
|
|
"""
|
|
# Create a plan with fewer init keys than config
|
|
plan_dict = {
|
|
"version": "v0.1",
|
|
"hils": [{
|
|
"name": "test_hil",
|
|
"init": {"x": 100}, # Only x, not y
|
|
"tasks": [{
|
|
"type": "loop",
|
|
"name": "main",
|
|
"dt_s": 0.1,
|
|
"actions": [{"set": ["x", "x + 1"]}]
|
|
}]
|
|
}]
|
|
}
|
|
plan = ControlPlan.model_validate(plan_dict)
|
|
|
|
# Config has both x and y
|
|
config = {
|
|
"hils": [{
|
|
"name": "test_hil",
|
|
"logic": "test_hil.py",
|
|
"physical_values": [
|
|
{"name": "x", "io": "output"},
|
|
{"name": "y", "io": "output"}, # Extra key not in plan.init
|
|
]
|
|
}]
|
|
}
|
|
|
|
hil_code = compile_control_plan(plan, config=config)
|
|
code = hil_code["test_hil"]
|
|
|
|
# x should use plan.init value (100)
|
|
assert "physical_values.setdefault('x', 100)" in code
|
|
|
|
# y should use default (0) since not in plan.init
|
|
assert "physical_values.setdefault('y', 0)" in code
|