""" 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('', )` 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