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

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