1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-01 10:52:19 +01:00
Files
esphome/tests/unit_tests/test_substitutions.py
2025-07-25 08:15:54 -10:00

126 lines
4.4 KiB
Python

import glob
import logging
import os
from esphome import yaml_util
from esphome.components import substitutions
from esphome.const import CONF_PACKAGES
_LOGGER = logging.getLogger(__name__)
# Set to True for dev mode behavior
# This will generate the expected version of the test files.
DEV_MODE = False
def sort_dicts(obj):
"""Recursively sort dictionaries for order-insensitive comparison."""
if isinstance(obj, dict):
return {k: sort_dicts(obj[k]) for k in sorted(obj)}
elif isinstance(obj, list):
# Lists are not sorted; we preserve order
return [sort_dicts(i) for i in obj]
else:
return obj
def dict_diff(a, b, path=""):
"""Recursively find differences between two dict/list structures."""
diffs = []
if isinstance(a, dict) and isinstance(b, dict):
a_keys = set(a)
b_keys = set(b)
diffs.extend(f"{path}/{key} only in actual" for key in a_keys - b_keys)
diffs.extend(f"{path}/{key} only in expected" for key in b_keys - a_keys)
for key in a_keys & b_keys:
diffs.extend(dict_diff(a[key], b[key], f"{path}/{key}"))
elif isinstance(a, list) and isinstance(b, list):
min_len = min(len(a), len(b))
for i in range(min_len):
diffs.extend(dict_diff(a[i], b[i], f"{path}[{i}]"))
if len(a) > len(b):
diffs.extend(
f"{path}[{i}] only in actual: {a[i]!r}" for i in range(min_len, len(a))
)
elif len(b) > len(a):
diffs.extend(
f"{path}[{i}] only in expected: {b[i]!r}"
for i in range(min_len, len(b))
)
elif a != b:
diffs.append(f"\t{path}: actual={a!r} expected={b!r}")
return diffs
def write_yaml(path, data):
with open(path, "w", encoding="utf-8") as f:
f.write(yaml_util.dump(data))
def test_substitutions_fixtures(fixture_path):
base_dir = fixture_path / "substitutions"
sources = sorted(glob.glob(str(base_dir / "*.input.yaml")))
assert sources, f"No input YAML files found in {base_dir}"
failures = []
for source_path in sources:
try:
expected_path = source_path.replace(".input.yaml", ".approved.yaml")
test_case = os.path.splitext(os.path.basename(source_path))[0].replace(
".input", ""
)
# Load using ESPHome's YAML loader
config = yaml_util.load_yaml(source_path)
if CONF_PACKAGES in config:
from esphome.components.packages import do_packages_pass
config = do_packages_pass(config)
substitutions.do_substitution_pass(config, None)
# Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE
if os.path.isfile(expected_path):
expected = yaml_util.load_yaml(expected_path)
elif DEV_MODE:
expected = {}
else:
assert os.path.isfile(expected_path), (
f"Expected file missing: {expected_path}"
)
# Sort dicts only (not lists) for comparison
got_sorted = sort_dicts(config)
expected_sorted = sort_dicts(expected)
if got_sorted != expected_sorted:
diff = "\n".join(dict_diff(got_sorted, expected_sorted))
msg = (
f"Substitution result mismatch for {os.path.basename(source_path)}\n"
f"Diff:\n{diff}\n\n"
f"Got: {got_sorted}\n"
f"Expected: {expected_sorted}"
)
# Write out the received file when test fails
if DEV_MODE:
received_path = os.path.join(
os.path.dirname(source_path), f"{test_case}.received.yaml"
)
write_yaml(received_path, config)
print(msg)
failures.append(msg)
else:
raise AssertionError(msg)
except Exception as err:
_LOGGER.error("Error in test file %s", source_path)
raise err
if DEV_MODE and failures:
print(f"\n{len(failures)} substitution test case(s) failed.")
if DEV_MODE:
_LOGGER.error("Tests passed, but Dev mode is enabled.")
assert not DEV_MODE # make sure DEV_MODE is disabled after you are finished.