Files
brewpi/tests/utils/test_config_validate.py
T
jensandClaude Sonnet 5 92b9bdc1ba config: generalize config_validate into a shared schema engine
require_schema(config, schema) is a generic recursive declarative
checker (nested-dict schema, leaves name required type(s), each
intermediate object node gets its own dict-type check so a wrong-typed
section is named directly instead of surfacing as a confusing "missing
child" error). validate_temp_ctrl() is now just require_schema(config,
TEMPCTRL_SCHEMA) against a declarative schema dict, replacing the
hand-rolled nested loops.

utils/sud_validate.py's static structural check ('steps' must be a
list) now goes through the same require_schema()/SUD_SCHEMA - one
shared mechanism for both server config.json and sud.json documents.
The genuinely dynamic per-step check (ramp.rate required only when a
step sets its own temperature) stays hand-written, since it depends on
the document's content, not just its shape.

Added TestRequireSchema, exercising the generic engine directly
against a schema unrelated to TempCtrl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 20:40:32 +02:00

103 lines
3.8 KiB
Python

import unittest
from utils.config_validate import ConfigError, require, require_schema, validate_temp_ctrl
def _valid_temp_ctrl_config():
gains = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
return {
"TempCtrl": {
"pid_type": "Smith",
"Outer": dict(gains),
"Inner": {
"Heat": dict(gains),
"Hold": dict(gains),
"Cool": dict(gains),
},
}
}
class TestRequire(unittest.TestCase):
def test_returns_value_when_present(self):
self.assertEqual(require({"a": {"b": 1}}, "a.b"), 1)
def test_raises_on_missing_leaf(self):
with self.assertRaises(ConfigError):
require({"a": {}}, "a.b")
def test_raises_on_missing_intermediate(self):
with self.assertRaises(ConfigError):
require({}, "a.b")
def test_error_names_full_path_up_to_missing_key(self):
with self.assertRaisesRegex(ConfigError, r"a\.b"):
require({"a": {}}, "a.b.c")
def test_raises_on_wrong_type(self):
with self.assertRaises(ConfigError):
require({"a": "not a number"}, "a", type=(int, float))
def test_accepts_matching_type(self):
self.assertEqual(require({"a": 1.5}, "a", type=(int, float)), 1.5)
class TestRequireSchema(unittest.TestCase):
"""require_schema() is the generic engine both validate_temp_ctrl()
(config.json's TempCtrl section) and utils/sud_validate.py's
validate_sud() (a sud.json's fixed 'steps' shape) are built on - tested
here directly, against a schema unrelated to either, to confirm it's
not accidentally coupled to TempCtrl's specific shape."""
SCHEMA = {'a': {'b': int, 'c': (int, float)}, 'd': str}
def test_valid_document_passes(self):
require_schema({'a': {'b': 1, 'c': 2.5}, 'd': 'x'}, self.SCHEMA)
def test_missing_nested_leaf_raises(self):
with self.assertRaisesRegex(ConfigError, r'a\.b'):
require_schema({'a': {'c': 2.5}, 'd': 'x'}, self.SCHEMA)
def test_missing_top_level_leaf_raises(self):
with self.assertRaisesRegex(ConfigError, r'missing required config key: d$'):
require_schema({'a': {'b': 1, 'c': 2.5}}, self.SCHEMA)
def test_wrong_type_on_nested_object_itself_is_named_directly(self):
# 'a' present but not an object - must be reported as 'a' itself,
# not as a confusing "missing a.b"/"missing a.c".
with self.assertRaisesRegex(ConfigError, r'^config key a must be dict'):
require_schema({'a': 'not an object', 'd': 'x'}, self.SCHEMA)
def test_extra_keys_not_in_schema_are_ignored(self):
require_schema({'a': {'b': 1, 'c': 2.5}, 'd': 'x', 'extra': 'stray'}, self.SCHEMA)
class TestValidateTempCtrl(unittest.TestCase):
def test_valid_config_passes(self):
validate_temp_ctrl(_valid_temp_ctrl_config())
def test_missing_gain_raises(self):
config = _valid_temp_ctrl_config()
del config["TempCtrl"]["Inner"]["Hold"]["kp"]
with self.assertRaisesRegex(ConfigError, r"TempCtrl\.Inner\.Hold\.kp"):
validate_temp_ctrl(config)
def test_missing_pid_type_raises(self):
config = _valid_temp_ctrl_config()
del config["TempCtrl"]["pid_type"]
with self.assertRaises(ConfigError):
validate_temp_ctrl(config)
def test_optional_keys_not_required(self):
config = _valid_temp_ctrl_config()
# Outer.y_hold_min, Inner.Hold.yi_max, and the whole Thresholds
# section all have their own defaults elsewhere and must stay
# optional here too.
validate_temp_ctrl(config)
self.assertNotIn("y_hold_min", config["TempCtrl"]["Outer"])
self.assertNotIn("Thresholds", config["TempCtrl"])
if __name__ == '__main__':
unittest.main()