Files
brewpi/tests/utils/test_config_validate.py
T
jensandClaude Sonnet 5 0171cb20c7 config: fail fast on missing TempCtrl PID gains
Config access was unchecked dict[key] everywhere, so a missing/
misnamed gain (we've hit this once already: config.json.sim's gain/
Model nesting mismatch) surfaced as a bare KeyError from deep inside
some component's __init__ or first process() tick, with no indication
which config key was wrong.

utils/config_validate.py adds a small stdlib-only dotted-path checker
(no schema library - matches this project's stdlib-first leaning) and
validate_temp_ctrl(), scoped to the section that actually caused an
incident: TempCtrl.pid_type plus every kp/ki/kd/kt gain under
TempCtrl.Outer and TempCtrl.Inner.{Heat,Hold,Cool}. Called once in
server/brewpi.py right after json.load(), before any factory runs.
Optional-with-default keys (Outer.y_hold_min, Inner.Hold.yi_max,
Thresholds) stay optional here too.

Heater/Stirrer/TempSensor/Pot sections and unknown-key warnings are
deliberately left for later - see components/pid/TODO.md.

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

73 lines
2.3 KiB
Python

import unittest
from utils.config_validate import ConfigError, require, 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 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()