sud: fail fast on malformed sud.json documents, one test per fixture

Sud.load() now calls utils/sud_validate.py's validate_sud() before
parsing: requires a non-empty 'steps' list, and for every resolved
step that sets its own 'temperature', a numeric ramp.rate - the one
genuinely unguarded crash site (ramp['rate'], no .get() fallback) hit
in tasks/sud.py, components/sud_forecast.py and demo_sud.py the moment
that step is reached, which for SudForecastEstimator can be as early
as the Load itself. A refused load is now logged with the specific
reason (previously silent). validate_sud() reuses Sud._parse_data()
directly rather than reimplementing default-merging/step-building.

tests/utils/test_sud_validate_fixtures.py covers 8 fixture docs plus
all 6 real sude/*.json files; tests/components/sud/test_sud_load.py
exercises the Sud.load() wiring itself (rejects, keeps prior schedule).

Also reworked both this and test_config_fixtures.py to generate one
dedicated test method per fixture file (dynamic setattr) instead of
looping with subTest inside a single method - subTest iterations don't
show up individually in `unittest -v`/IDE test runners, which made it
look like far fewer fixtures were covered than actually are.

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 13:27:20 +02:00
co-authored by Claude Sonnet 5
parent 5a874e3869
commit 4d59e11c1e
14 changed files with 408 additions and 11 deletions
@@ -0,0 +1,17 @@
{
"Name": "EmptySteps",
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": []
}
@@ -0,0 +1,24 @@
{
"Name": "MissingRampRate",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"hold": {
"duration": 0
},
"ramp": {}
}
},
"steps": [
{
"descr": "Heat up",
"temperature": 65
}
]
}
@@ -0,0 +1,16 @@
{
"Name": "NoSteps",
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
}
}
@@ -0,0 +1,29 @@
{
"Name": "WrongType",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Heat up",
"temperature": 65,
"ramp": {
"rate": "fast"
}
}
]
}
@@ -0,0 +1,19 @@
{
"Name": "StepNotDict",
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
"not a step object"
]
}
@@ -0,0 +1,25 @@
{
"Name": "Minimal",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Only step"
}
]
}
@@ -0,0 +1,43 @@
{
"Name": "MultiStep",
"pot": {
"grain_mass": 0,
"water_mass": 22
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Aufheizen",
"temperature": 57,
"ramp": {
"rate": 1.5
}
},
{
"descr": "Rast",
"temperature": 63,
"hold": {
"duration": 40
}
},
{
"descr": "Ausmaischen",
"temperature": 78,
"ramp": {
"rate": 1.0
}
}
]
}
@@ -0,0 +1,32 @@
{
"Name": "WithTarget",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Heat up",
"temperature": 65
},
{
"descr": "Hold",
"hold": {
"duration": 20
}
}
]
}
+23 -9
View File
@@ -1,5 +1,6 @@
import json
import os
import re
import unittest
from utils.config_validate import ConfigError, validate_temp_ctrl
@@ -27,20 +28,33 @@ def _load(name):
return json.load(f)
def _test_name(filename):
return 'test_' + re.sub(r'\W', '_', filename[:-len('.json')])
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)
def _make_fixture_test(name, expected_error):
def test(self):
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)
return test
# One dedicated test method per fixture file - not a single method looping
# over CASES with subTest - so `unittest discover -v` (and any IDE test
# runner) lists and can re-run each fixture individually instead of folding
# them all into one opaque "did any of these fail" result.
for _name, _expected_error in CASES.items():
setattr(TestConfigFixtures, _test_name(_name), _make_fixture_test(_name, _expected_error))
if __name__ == '__main__':
+79
View File
@@ -0,0 +1,79 @@
import json
import os
import re
import unittest
from utils.config_validate import ConfigError
from utils.sud_validate import validate_sud
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'sud_fixtures')
SUDE_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'sude')
# filename -> None if the fixture should pass validation, otherwise a regex
# matched against the raised ConfigError's message.
CASES = {
'valid_minimal.json': None,
'valid_with_target.json': None,
'valid_multi_step.json': None,
'missing_steps_key.json': r"missing required config key: steps",
'empty_steps_list.json': r"'steps' must be a non-empty list",
'missing_ramp_rate.json': r'step 1 \(Heat up\): .*ramp\.rate',
'ramp_rate_wrong_type.json': r'step 1 \(Heat up\): .*ramp\.rate',
'step_not_dict.json': r'malformed sud document',
}
def _load(directory, name):
with open(os.path.join(directory, name)) as f:
return json.load(f)
def _test_name(filename):
return 'test_' + re.sub(r'\W', '_', filename[:-len('.json')])
class TestSudValidateFixtures(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), "sud_fixtures/ and CASES have drifted apart")
def _make_fixture_test(name, expected_error):
def test(self):
data = _load(FIXTURES_DIR, name)
if expected_error is None:
validate_sud(data) # must not raise
else:
with self.assertRaisesRegex(ConfigError, expected_error):
validate_sud(data)
return test
# One dedicated test method per fixture file - not a single method looping
# over CASES with subTest - so `unittest discover -v` (and any IDE test
# runner) lists and can re-run each fixture individually instead of folding
# them all into one opaque "did any of these fail" result.
for _name, _expected_error in CASES.items():
setattr(TestSudValidateFixtures, _test_name(_name), _make_fixture_test(_name, _expected_error))
class TestRealSudFiles(unittest.TestCase):
"""The actual sude/*.json schedules must always pass - a failure here
means either a real schedule is broken, or validate_sud() itself has
drifted from what Sud._parse_data() actually accepts."""
def _make_real_file_test(name):
def test(self):
validate_sud(_load(SUDE_DIR, name))
return test
_real_sud_files = [f for f in os.listdir(SUDE_DIR) if f.endswith('.json')]
assert _real_sud_files, "expected at least one sude/*.json file"
for _name in _real_sud_files:
setattr(TestRealSudFiles, _test_name(_name), _make_real_file_test(_name))
if __name__ == '__main__':
unittest.main()