host.controller_web_editor.tests.test_config_roundtrip
Round-trip tests for the JSON <-> FullConfig conversion.
Generated by Claude
Confirms the web editor can load any of the committed YAML configs and serialize them back without losing information. Also exercises the path allowlist to catch traversal mistakes early.
1"""Round-trip tests for the JSON <-> FullConfig conversion. 2 3# Generated by Claude 4 5Confirms the web editor can load any of the committed YAML configs and 6serialize them back without losing information. Also exercises the 7path allowlist to catch traversal mistakes early. 8""" 9 10import os 11import sys 12 13import pytest 14 15 16# Make ``host.controller_web_editor`` importable when pytest is run from any cwd. 17_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) 18if _REPO_ROOT not in sys.path: 19 sys.path.insert(0, _REPO_ROOT) 20 21 22from host.controller_web_editor.paths import ( # noqa: E402 23 PathNotAllowedError, 24 list_yaml_configs, 25 safe_resolve, 26) 27from host.controller_web_editor.routes.config import ( # noqa: E402 28 config_from_json, 29 config_to_json, 30 load_as_json, 31 save_from_json, 32) 33from utils.controller.config_io import load_config # noqa: E402 34 35 36_DATA_INPUTS = os.path.join(_REPO_ROOT, "data", "inputs") 37 38 39def _config_paths() -> list[str]: 40 return [ 41 os.path.join(_DATA_INPUTS, name) 42 for name in os.listdir(_DATA_INPUTS) 43 if name.endswith(".yaml") 44 ] 45 46 47class TestPathAllowlist: 48 def test_allows_data_paths(self): 49 resolved = safe_resolve("data/inputs/controller.yaml") 50 assert resolved.is_file() 51 52 def test_rejects_outside_paths(self): 53 with pytest.raises(PathNotAllowedError): 54 safe_resolve("pyproject.toml") 55 56 def test_rejects_traversal(self): 57 with pytest.raises(PathNotAllowedError): 58 safe_resolve("data/../pyproject.toml") 59 60 def test_rejects_empty(self): 61 with pytest.raises(PathNotAllowedError): 62 safe_resolve("") 63 64 def test_list_includes_controller_yaml(self): 65 configs = list_yaml_configs() 66 assert "data/inputs/controller.yaml" in configs 67 68 69class TestJsonRoundtrip: 70 @pytest.mark.parametrize("path", _config_paths()) 71 def test_load_returns_jsonable(self, path): 72 payload = load_as_json(path) 73 # All values must survive json.dumps without a custom encoder. 74 import json 75 json.dumps(payload) 76 assert payload["version"] 77 assert isinstance(payload["actions"], dict) 78 assert isinstance(payload["controllers"], dict) 79 80 @pytest.mark.parametrize("path", _config_paths()) 81 def test_roundtrip_preserves_actions(self, path): 82 original = load_config(path) 83 payload = config_to_json(original) 84 rebuilt = config_from_json(payload) 85 86 assert set(rebuilt.actions) == set(original.actions) 87 for qname, action in original.actions.items(): 88 other = rebuilt.actions[qname] 89 assert other.input_type == action.input_type 90 assert other.trigger_mode == action.trigger_mode 91 assert other.description == action.description 92 assert other.group == action.group 93 assert other.deadband == action.deadband 94 assert other.threshold == action.threshold 95 assert other.inversion == action.inversion 96 assert other.slew_rate == action.slew_rate 97 assert other.scale == action.scale 98 assert other.extra == action.extra 99 100 @pytest.mark.parametrize("path", _config_paths()) 101 def test_roundtrip_preserves_controllers(self, path): 102 original = load_config(path) 103 rebuilt = config_from_json(config_to_json(original)) 104 assert set(rebuilt.controllers) == set(original.controllers) 105 for port, ctrl in original.controllers.items(): 106 other = rebuilt.controllers[port] 107 assert other.name == ctrl.name 108 assert other.controller_type == ctrl.controller_type 109 assert other.bindings == ctrl.bindings 110 111 def test_save_then_load_matches(self, tmp_path): 112 src = os.path.join(_DATA_INPUTS, "controller.yaml") 113 payload = load_as_json(src) 114 dst = tmp_path / "out.yaml" 115 save_from_json(dst, payload) 116 reloaded = load_as_json(dst) 117 # Compare the substantive fields; version comes from CONFIG_VERSION 118 # either way so equality is safe. 119 assert reloaded["actions"] == payload["actions"] 120 assert reloaded["controllers"] == payload["controllers"] 121 assert set(reloaded["empty_groups"]) == set(payload["empty_groups"])
class
TestPathAllowlist:
48class TestPathAllowlist: 49 def test_allows_data_paths(self): 50 resolved = safe_resolve("data/inputs/controller.yaml") 51 assert resolved.is_file() 52 53 def test_rejects_outside_paths(self): 54 with pytest.raises(PathNotAllowedError): 55 safe_resolve("pyproject.toml") 56 57 def test_rejects_traversal(self): 58 with pytest.raises(PathNotAllowedError): 59 safe_resolve("data/../pyproject.toml") 60 61 def test_rejects_empty(self): 62 with pytest.raises(PathNotAllowedError): 63 safe_resolve("") 64 65 def test_list_includes_controller_yaml(self): 66 configs = list_yaml_configs() 67 assert "data/inputs/controller.yaml" in configs
class
TestJsonRoundtrip:
70class TestJsonRoundtrip: 71 @pytest.mark.parametrize("path", _config_paths()) 72 def test_load_returns_jsonable(self, path): 73 payload = load_as_json(path) 74 # All values must survive json.dumps without a custom encoder. 75 import json 76 json.dumps(payload) 77 assert payload["version"] 78 assert isinstance(payload["actions"], dict) 79 assert isinstance(payload["controllers"], dict) 80 81 @pytest.mark.parametrize("path", _config_paths()) 82 def test_roundtrip_preserves_actions(self, path): 83 original = load_config(path) 84 payload = config_to_json(original) 85 rebuilt = config_from_json(payload) 86 87 assert set(rebuilt.actions) == set(original.actions) 88 for qname, action in original.actions.items(): 89 other = rebuilt.actions[qname] 90 assert other.input_type == action.input_type 91 assert other.trigger_mode == action.trigger_mode 92 assert other.description == action.description 93 assert other.group == action.group 94 assert other.deadband == action.deadband 95 assert other.threshold == action.threshold 96 assert other.inversion == action.inversion 97 assert other.slew_rate == action.slew_rate 98 assert other.scale == action.scale 99 assert other.extra == action.extra 100 101 @pytest.mark.parametrize("path", _config_paths()) 102 def test_roundtrip_preserves_controllers(self, path): 103 original = load_config(path) 104 rebuilt = config_from_json(config_to_json(original)) 105 assert set(rebuilt.controllers) == set(original.controllers) 106 for port, ctrl in original.controllers.items(): 107 other = rebuilt.controllers[port] 108 assert other.name == ctrl.name 109 assert other.controller_type == ctrl.controller_type 110 assert other.bindings == ctrl.bindings 111 112 def test_save_then_load_matches(self, tmp_path): 113 src = os.path.join(_DATA_INPUTS, "controller.yaml") 114 payload = load_as_json(src) 115 dst = tmp_path / "out.yaml" 116 save_from_json(dst, payload) 117 reloaded = load_as_json(dst) 118 # Compare the substantive fields; version comes from CONFIG_VERSION 119 # either way so equality is safe. 120 assert reloaded["actions"] == payload["actions"] 121 assert reloaded["controllers"] == payload["controllers"] 122 assert set(reloaded["empty_groups"]) == set(payload["empty_groups"])
@pytest.mark.parametrize('path', _config_paths())
def
test_load_returns_jsonable(self, path):
71 @pytest.mark.parametrize("path", _config_paths()) 72 def test_load_returns_jsonable(self, path): 73 payload = load_as_json(path) 74 # All values must survive json.dumps without a custom encoder. 75 import json 76 json.dumps(payload) 77 assert payload["version"] 78 assert isinstance(payload["actions"], dict) 79 assert isinstance(payload["controllers"], dict)
@pytest.mark.parametrize('path', _config_paths())
def
test_roundtrip_preserves_actions(self, path):
81 @pytest.mark.parametrize("path", _config_paths()) 82 def test_roundtrip_preserves_actions(self, path): 83 original = load_config(path) 84 payload = config_to_json(original) 85 rebuilt = config_from_json(payload) 86 87 assert set(rebuilt.actions) == set(original.actions) 88 for qname, action in original.actions.items(): 89 other = rebuilt.actions[qname] 90 assert other.input_type == action.input_type 91 assert other.trigger_mode == action.trigger_mode 92 assert other.description == action.description 93 assert other.group == action.group 94 assert other.deadband == action.deadband 95 assert other.threshold == action.threshold 96 assert other.inversion == action.inversion 97 assert other.slew_rate == action.slew_rate 98 assert other.scale == action.scale 99 assert other.extra == action.extra
@pytest.mark.parametrize('path', _config_paths())
def
test_roundtrip_preserves_controllers(self, path):
101 @pytest.mark.parametrize("path", _config_paths()) 102 def test_roundtrip_preserves_controllers(self, path): 103 original = load_config(path) 104 rebuilt = config_from_json(config_to_json(original)) 105 assert set(rebuilt.controllers) == set(original.controllers) 106 for port, ctrl in original.controllers.items(): 107 other = rebuilt.controllers[port] 108 assert other.name == ctrl.name 109 assert other.controller_type == ctrl.controller_type 110 assert other.bindings == ctrl.bindings
def
test_save_then_load_matches(self, tmp_path):
112 def test_save_then_load_matches(self, tmp_path): 113 src = os.path.join(_DATA_INPUTS, "controller.yaml") 114 payload = load_as_json(src) 115 dst = tmp_path / "out.yaml" 116 save_from_json(dst, payload) 117 reloaded = load_as_json(dst) 118 # Compare the substantive fields; version comes from CONFIG_VERSION 119 # either way so equality is safe. 120 assert reloaded["actions"] == payload["actions"] 121 assert reloaded["controllers"] == payload["controllers"] 122 assert set(reloaded["empty_groups"]) == set(payload["empty_groups"])