* init * no * more * tree it * final * comment * only with enforce torque * only with enforce torque * missed * no * lint * Apply suggestion from @sunnyhaibin * sunnylink metadata sync * Apply suggestion from @sunnyhaibin --------- Co-authored-by: nayan <nayan8teen@gmail.com>
285 lines
10 KiB
Python
285 lines
10 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.
|
|
"""
|
|
import json
|
|
import os
|
|
import pytest
|
|
|
|
from openpilot.common.params import Params
|
|
from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import METADATA_PATH
|
|
|
|
|
|
def test_metadata_json_exists():
|
|
"""
|
|
Test that the params_metadata.json file exists at the expected path.
|
|
|
|
Why:
|
|
The metadata file is the source of truth for parameter descriptions, options, and constraints.
|
|
If it's missing, the UI will not be able to display rich information for parameters.
|
|
|
|
Expected:
|
|
The file should exist at sunnypilot/sunnylink/params_metadata.json.
|
|
"""
|
|
assert os.path.exists(METADATA_PATH), f"Metadata file not found at {METADATA_PATH}"
|
|
|
|
|
|
def test_metadata_json_valid():
|
|
"""
|
|
Test that the params_metadata.json file contains valid JSON.
|
|
|
|
Why:
|
|
Invalid JSON will cause the metadata loading to fail, potentially crashing the UI or
|
|
resulting in missing metadata.
|
|
|
|
Expected:
|
|
The file content should be parseable as a JSON object (dictionary).
|
|
"""
|
|
with open(METADATA_PATH) as f:
|
|
try:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError:
|
|
pytest.fail("Metadata file is not valid JSON")
|
|
|
|
assert isinstance(data, dict), "Metadata root must be a dictionary"
|
|
|
|
|
|
def test_all_params_have_metadata():
|
|
"""
|
|
Test that every parameter in the codebase has a corresponding entry in params_metadata.json.
|
|
|
|
Why:
|
|
We want to ensure 100% coverage of parameter metadata. Any parameter added to the codebase
|
|
should also be documented in the metadata file.
|
|
|
|
Expected:
|
|
There should be no parameters in Params() that are missing from the metadata file.
|
|
If this fails, run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py'.
|
|
"""
|
|
params = Params()
|
|
all_keys = [k.decode('utf-8') for k in params.all_keys()]
|
|
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
missing_keys = [key for key in all_keys if key not in metadata]
|
|
|
|
if missing_keys:
|
|
pytest.fail(
|
|
f"The following parameters are missing from metadata: {missing_keys}. "
|
|
+ "Please run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py' to update."
|
|
)
|
|
|
|
|
|
def test_metadata_keys_exist_in_params():
|
|
"""
|
|
Test that all keys in params_metadata.json actually exist in the codebase.
|
|
|
|
Why:
|
|
We want to avoid stale metadata for parameters that have been removed or renamed.
|
|
This keeps the metadata file clean and relevant.
|
|
|
|
Expected:
|
|
There should be no keys in the metadata file that are not present in Params().
|
|
This prints a warning rather than failing, as it's less critical than missing metadata.
|
|
"""
|
|
params = Params()
|
|
all_keys = {k.decode('utf-8') for k in params.all_keys()}
|
|
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
extra_keys = [key for key in metadata.keys() if key not in all_keys]
|
|
|
|
if extra_keys:
|
|
print(f"Warning: The following keys in metadata do not exist in Params: {extra_keys}")
|
|
|
|
|
|
def test_no_default_titles():
|
|
"""
|
|
Test that no parameter has a title that is identical to its key.
|
|
|
|
Why:
|
|
The default behavior of the update script is to set the title equal to the key.
|
|
We want to force developers to provide human-readable, descriptive titles for all parameters.
|
|
|
|
Expected:
|
|
No parameter metadata should have 'title' == 'key'.
|
|
"""
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
default_title_keys = [key for key, meta in metadata.items() if meta.get("title") == key]
|
|
|
|
if default_title_keys:
|
|
pytest.fail(
|
|
f"The following parameters have default titles (title == key): {default_title_keys}. "
|
|
+ "Please update 'params_metadata.json' with descriptive titles."
|
|
)
|
|
|
|
|
|
def test_options_structure():
|
|
"""
|
|
Test that the 'options' field in metadata follows the correct structure.
|
|
|
|
Why:
|
|
The UI expects 'options' to be a list of objects with 'value' and 'label' keys.
|
|
Incorrect structure will break the UI rendering for dropdowns/toggles.
|
|
|
|
Expected:
|
|
If 'options' is present, it must be a list of dicts, and each dict must have 'value' and 'label'.
|
|
"""
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
for key, meta in metadata.items():
|
|
if "options" in meta:
|
|
options = meta["options"]
|
|
assert isinstance(options, list), f"Options for {key} must be a list"
|
|
for option in options:
|
|
assert isinstance(option, dict), f"Option in {key} must be a dictionary"
|
|
assert "value" in option, f"Option in {key} must have a 'value' key"
|
|
assert "label" in option, f"Option in {key} must have a 'label' key"
|
|
|
|
|
|
def test_numeric_constraints():
|
|
"""
|
|
Test that numeric parameters have valid 'min', 'max', and 'step' constraints.
|
|
|
|
Why:
|
|
The UI uses these constraints to validate user input and render sliders/steppers.
|
|
Missing or invalid constraints can lead to UI bugs or invalid parameter values.
|
|
|
|
Expected:
|
|
If any of min/max/step is present, ALL of them must be present.
|
|
They must be numbers (int/float), and min must be less than max.
|
|
"""
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
for key, meta in metadata.items():
|
|
if "min" in meta or "max" in meta or "step" in meta:
|
|
assert "min" in meta, f"Numeric param {key} must have 'min'"
|
|
assert "max" in meta, f"Numeric param {key} must have 'max'"
|
|
assert "step" in meta, f"Numeric param {key} must have 'step'"
|
|
|
|
assert isinstance(meta["min"], (int, float)), f"Min for {key} must be number"
|
|
assert isinstance(meta["max"], (int, float)), f"Max for {key} must be number"
|
|
assert isinstance(meta["step"], (int, float)), f"Step for {key} must be number"
|
|
assert meta["min"] < meta["max"], f"Min must be less than max for {key}"
|
|
|
|
|
|
def test_known_params_metadata():
|
|
"""
|
|
Test specific known parameters to ensure they have the expected rich metadata.
|
|
|
|
Why:
|
|
This acts as a spot check to ensure that our rich metadata population logic is working correctly
|
|
and that critical parameters (like LongitudinalPersonality) have their options and constraints preserved.
|
|
|
|
Expected:
|
|
'LongitudinalPersonality' should have 3 options (Aggressive, Standard, Relaxed).
|
|
'CustomAccLongPressIncrement' should have min=1, max=10, step=1.
|
|
"""
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
# Check an enum-like param
|
|
lp = metadata.get("LongitudinalPersonality")
|
|
assert lp is not None
|
|
assert "options" in lp
|
|
assert len(lp["options"]) == 3
|
|
assert lp["options"][0]["label"] == "Aggressive"
|
|
assert lp["options"][0]["value"] == 0
|
|
|
|
# Check a numeric param
|
|
acc_long = metadata.get("CustomAccLongPressIncrement")
|
|
assert acc_long is not None
|
|
assert acc_long["min"] == 1
|
|
assert acc_long["max"] == 10
|
|
assert acc_long["step"] == 1
|
|
|
|
|
|
def test_torque_control_tune_versions_in_sync():
|
|
"""
|
|
Test that TorqueControlTune options in params_metadata.json match versions in latcontrol_torque_versions.json.
|
|
|
|
Why:
|
|
The TorqueControlTune dropdown in the UI should always reflect the available torque tune versions.
|
|
If versions are added/removed from latcontrol_torque_versions.json, the metadata must be updated accordingly.
|
|
|
|
Expected:
|
|
- TorqueControlTune should have a 'Default' option with empty string value
|
|
- All versions from latcontrol_torque_versions.json should be present in the options
|
|
- The version values and labels should match between both files
|
|
"""
|
|
from openpilot.common.basedir import BASEDIR
|
|
|
|
versions_json_path = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json")
|
|
sync_script_path = "python3 sunnypilot/sunnylink/tools/sync_torque_versions.py"
|
|
|
|
# Load both files
|
|
with open(METADATA_PATH) as f:
|
|
metadata = json.load(f)
|
|
|
|
with open(versions_json_path) as f:
|
|
versions = json.load(f)
|
|
|
|
# Get TorqueControlTune metadata
|
|
torque_tune = metadata.get("TorqueControlTune")
|
|
if torque_tune is None:
|
|
pytest.fail(f"TorqueControlTune not found in params_metadata.json. Please run '{sync_script_path}' to sync.")
|
|
|
|
if "options" not in torque_tune:
|
|
pytest.fail(f"TorqueControlTune must have options. Please run '{sync_script_path}' to sync.")
|
|
|
|
options = torque_tune["options"]
|
|
if not isinstance(options, list):
|
|
pytest.fail(f"TorqueControlTune options must be a list. Please run '{sync_script_path}' to sync.")
|
|
|
|
if len(options) == 0:
|
|
pytest.fail(f"TorqueControlTune must have at least one option. Please run '{sync_script_path}' to sync.")
|
|
|
|
# Check that Default option exists
|
|
default_option = next((opt for opt in options if opt.get("value") == ""), None)
|
|
if default_option is None:
|
|
pytest.fail(f"TorqueControlTune must have a 'Default' option with empty string value. Please run '{sync_script_path}' to sync.")
|
|
|
|
if default_option.get("label") != "Default":
|
|
pytest.fail(f"Default option must have label 'Default'. Please run '{sync_script_path}' to sync.")
|
|
|
|
# Build expected options from versions.json
|
|
expected_version_keys = set(versions.keys())
|
|
actual_version_keys = set()
|
|
|
|
for option in options:
|
|
if option.get("value") == "":
|
|
continue # Skip the default option
|
|
|
|
label = option.get("label")
|
|
value = option.get("value")
|
|
|
|
# Check that this option corresponds to a version
|
|
if label not in versions:
|
|
pytest.fail(f"Option label '{label}' not found in latcontrol_torque_versions.json. Please run '{sync_script_path}' to sync.")
|
|
|
|
# Check that the value matches the version number
|
|
expected_value = float(versions[label]["version"])
|
|
if value != expected_value:
|
|
pytest.fail(f"Option '{label}' has value {value}, expected {expected_value}. Please run '{sync_script_path}' to sync.")
|
|
|
|
actual_version_keys.add(label)
|
|
|
|
# Check that all versions are represented
|
|
missing_versions = expected_version_keys - actual_version_keys
|
|
if missing_versions:
|
|
pytest.fail(f"The following versions are missing from TorqueControlTune options: {missing_versions}. " +
|
|
f"Please run '{sync_script_path}' to sync.")
|
|
|
|
extra_versions = actual_version_keys - expected_version_keys
|
|
if extra_versions:
|
|
pytest.fail("The following versions in TorqueControlTune options are not in latcontrol_torque_versions.json: " +
|
|
f"{extra_versions}. Please run '{sync_script_path}' to sync.")
|