Files
github-actions[bot] f9fcc7adab sunnypilot v2026.002.000 release
date: 2026-06-28T09:48:35
master commit: da6313dbe95b3f24bb5d8018b0e5f950f5823ca7
2026-06-28 09:49:29 +08:00

195 lines
7.3 KiB
Python

"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
Tests for the settings_ui_src/ -> settings_ui.json compiler. Covers:
- Roundtrip: compiled output matches the checked-in settings_ui.json
- $ref macro resolution semantics (list-splice, scalar-substitute, depth, cycles)
- Per-page tree integrity (every page has id; vehicle page emits to vehicle_settings)
Does not cover device-side generator (test_settings_schema.py) or per-bug
regression (test_settings_changes.py); those continue to validate the
compiled output once the compiler has produced it.
"""
from __future__ import annotations
import difflib
import json
import os
import pytest
import yaml
from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import (
CompileError,
DEFAULT_OUT,
DEFAULT_SRC,
_resolve_refs,
compile_schema,
)
@pytest.fixture(scope="module")
def compiled() -> dict:
return compile_schema(DEFAULT_SRC)
@pytest.fixture(scope="module")
def committed() -> dict:
with open(DEFAULT_OUT) as f:
return json.load(f)
class TestRoundtrip:
def test_compiled_matches_committed(self, compiled, committed):
"""Compiled output must match the checked-in JSON."""
if compiled == committed:
return
diff = "\n".join(difflib.unified_diff(
json.dumps(committed, indent=2).splitlines(),
json.dumps(compiled, indent=2).splitlines(),
fromfile="settings_ui.json (committed)",
tofile="settings_ui.json (freshly compiled)",
lineterm="",
))
pytest.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}")
def test_committed_file_is_canonical(self):
"""Compiled output must byte-match the checked-in file (including trailing newline).
Drift means someone edited settings_ui.json by hand instead of editing settings_ui_src/."""
schema = compile_schema(DEFAULT_SRC)
rendered = json.dumps(schema, indent=2) + "\n"
with open(DEFAULT_OUT) as f:
current = f.read()
if current == rendered:
return
diff = "\n".join(difflib.unified_diff(
current.splitlines(),
rendered.splitlines(),
fromfile="settings_ui.json (on disk)",
tofile="settings_ui.json (freshly compiled)",
lineterm="",
))
pytest.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}")
class TestRefResolution:
def test_list_context_splices(self):
macros = {"a": [{"type": "offroad_only"}], "b": [{"type": "not_engaged"}]}
out = _resolve_refs([{"$ref": "#/macros/a"}, {"$ref": "#/macros/b"}], macros)
assert out == [{"type": "offroad_only"}, {"type": "not_engaged"}]
def test_scalar_context_substitutes(self):
macros = {"x": {"type": "capability", "field": "brand", "equals": "tesla"}}
out = _resolve_refs({"condition": {"$ref": "#/macros/x"}}, macros)
assert out == {"condition": {"type": "capability", "field": "brand", "equals": "tesla"}}
def test_chained_ref_resolves(self):
macros = {
"leaf": [{"type": "offroad_only"}],
"middle": [{"$ref": "#/macros/leaf"}],
}
out = _resolve_refs([{"$ref": "#/macros/middle"}], macros)
assert out == [{"type": "offroad_only"}]
def test_unknown_macro_raises(self):
with pytest.raises(CompileError, match="unknown macro"):
_resolve_refs([{"$ref": "#/macros/missing"}], {})
def test_cycle_raises(self):
macros = {"a": [{"$ref": "#/macros/b"}], "b": [{"$ref": "#/macros/a"}]}
with pytest.raises(CompileError, match="cycle"):
_resolve_refs([{"$ref": "#/macros/a"}], macros)
def test_depth_limit(self):
# Depth 4 chain should fail (limit is 3).
macros = {
"l1": [{"$ref": "#/macros/l2"}],
"l2": [{"$ref": "#/macros/l3"}],
"l3": [{"$ref": "#/macros/l4"}],
"l4": [{"type": "offroad_only"}],
}
with pytest.raises(CompileError, match="depth"):
_resolve_refs([{"$ref": "#/macros/l1"}], macros)
def test_invalid_ref_scheme(self):
with pytest.raises(CompileError, match="unsupported"):
_resolve_refs([{"$ref": "https://example.com/x"}], {})
def test_scalar_macro_in_list_context_raises(self):
macros = {"x": {"type": "offroad_only"}} # macro is a single rule (dict), not a list
with pytest.raises(CompileError, match="must resolve to a list"):
_resolve_refs([{"$ref": "#/macros/x"}], macros)
class TestCompiledShape:
def test_panels_present(self, compiled):
assert isinstance(compiled["panels"], list)
assert len(compiled["panels"]) == 9
panel_ids = {p["id"] for p in compiled["panels"]}
assert {"steering", "cruise", "display", "visuals", "toggles",
"device", "software", "developer", "models"} <= panel_ids
def test_vehicle_settings_consistent_shape(self, compiled):
"""Each brand in vehicle_settings must have {title, description, items}."""
for brand, data in compiled["vehicle_settings"].items():
assert isinstance(data, dict), f"{brand}: expected object, got {type(data).__name__}"
assert "title" in data, f"{brand}: missing title"
assert "description" in data, f"{brand}: missing description"
assert "items" in data, f"{brand}: missing items"
def test_no_dangling_refs_after_compile(self, compiled):
"""All $ref objects must be resolved during compilation."""
def walk(node):
if isinstance(node, dict):
if "$ref" in node:
pytest.fail(f"unresolved $ref: {node}")
for v in node.values():
walk(v)
elif isinstance(node, list):
for x in node:
walk(x)
walk(compiled)
class TestSourceTreeIntegrity:
def test_macros_yaml_well_formed(self):
with open(os.path.join(DEFAULT_SRC, "_macros.yaml")) as f:
doc = yaml.safe_load(f)
assert "macros" in doc
for name, body in doc["macros"].items():
assert name.replace("_", "").isalnum(), f"macro name '{name}' must be alphanumeric_"
assert body, f"macro '{name}' empty"
def test_pages_dir_well_formed(self):
pages_dir = os.path.join(DEFAULT_SRC, "pages")
assert os.path.isdir(pages_dir), "pages/ directory missing"
page_files = sorted(fn for fn in os.listdir(pages_dir) if fn.endswith(".yaml"))
# 9 panels + 1 vehicle = 10
assert len(page_files) == 10, f"expected 10 pages, found {len(page_files)}: {page_files}"
def test_every_page_has_id(self):
pages_dir = os.path.join(DEFAULT_SRC, "pages")
for fn in sorted(os.listdir(pages_dir)):
if not fn.endswith(".yaml"):
continue
path = os.path.join(pages_dir, fn)
with open(path) as f:
doc = yaml.safe_load(f)
assert isinstance(doc, dict), f"{path}: top-level must be a mapping"
assert "id" in doc, f"{path}: page missing 'id'"
# File basename should match page id (modulo .yaml extension).
expected_id = os.path.splitext(fn)[0]
assert doc["id"] == expected_id, (
f"{path}: page id '{doc['id']}' must match filename '{expected_id}'"
)
def test_vehicle_page_kind(self):
"""vehicle.yaml must declare kind: vehicle so it routes to vehicle_settings."""
path = os.path.join(DEFAULT_SRC, "pages", "vehicle.yaml")
with open(path) as f:
doc = yaml.safe_load(f)
assert doc.get("kind") == "vehicle", "vehicle.yaml must declare kind: vehicle"