test: add fixture-driven suite for config_validate

Complements the inline-dict unit tests in test_config_validate.py with
10 real JSON config files under tests/utils/fixtures/ (valid sim/real/
Normal configs, one with legacy unknown keys that must NOT fail, and
six broken variants covering each distinct failure mode: missing gain,
missing Inner branch, missing pid_type, missing TempCtrl entirely,
wrong-typed gain, TempCtrl not a dict).

test_config_fixtures.py drives all of them from one CASES table
(fixture name -> expected pass/ConfigError-regex), plus a guard test
that fails if fixtures/ and CASES ever drift apart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
This commit is contained in:
2026-07-11 12:59:38 +02:00
co-authored by Claude Sonnet 5
parent 0171cb20c7
commit 5a874e3869
11 changed files with 623 additions and 0 deletions
@@ -0,0 +1,78 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
},
"Kalman": {
"var_P": 1.0,
"var_Q": 0.01,
"var_R": 0.1
},
"Model": {
"kn": 0.0,
"gain": 0.8
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
},
"Plant": {
"kn": 0.0,
"gain": 0.8
}
}
@@ -0,0 +1,58 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,64 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,64 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,25 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Normal",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "hendi",
"port": "/dev/ttyUSB0",
"speed": "115200"
},
"Stirrer": {
"type": "pololu1376",
"port": "/dev/ttyACM0",
"speed": "115200"
},
"TempSensor": {
"type": "max31865",
"temp_offset": -0.15
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,65 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": "0.08",
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,26 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": "Smith",
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+47
View File
@@ -0,0 +1,47 @@
import json
import os
import unittest
from utils.config_validate import ConfigError, validate_temp_ctrl
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures')
# filename -> None if the fixture should pass validation, otherwise a regex
# matched against the raised ConfigError's message.
CASES = {
'valid_sim.json': None,
'valid_real.json': None,
'valid_normal.json': None,
'legacy_unknown_keys.json': None,
'missing_outer_kp.json': r'TempCtrl\.Outer\.kp',
'missing_inner_hold_branch.json': r'TempCtrl\.Inner\.Hold',
'missing_pid_type.json': r'TempCtrl\.pid_type',
'missing_tempctrl_section.json': r'missing required config key: TempCtrl$',
'wrong_type_kp_string.json': r'TempCtrl\.Inner\.Heat\.kp',
'wrong_type_tempctrl_not_dict.json': r'TempCtrl must be dict',
}
def _load(name):
with open(os.path.join(FIXTURES_DIR, name)) as f:
return json.load(f)
class TestConfigFixtures(unittest.TestCase):
def test_every_fixture_file_is_covered(self):
on_disk = {f for f in os.listdir(FIXTURES_DIR) if f.endswith('.json')}
self.assertEqual(on_disk, set(CASES), "fixtures/ and CASES have drifted apart")
def test_fixtures_match_expected_outcome(self):
for name, expected_error in CASES.items():
with self.subTest(fixture=name):
config = _load(name)
if expected_error is None:
validate_temp_ctrl(config) # must not raise
else:
with self.assertRaisesRegex(ConfigError, expected_error):
validate_temp_ctrl(config)
if __name__ == '__main__':
unittest.main()