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
103 lines
3.8 KiB
Python
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()
|