|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Equivalence test for the variables of the Blueprint's "API Subscriptions" step. |
| 4 | +
|
| 5 | +Renders the variables of two versions of the Blueprint, up to `api_subscriptions`, with mocked Home Assistant |
| 6 | +state and Blueprint inputs, and compares the resulting list: the subscriptions pushed to the panel (entity, |
| 7 | +page, component, attribute, icon, icon color and inverted flag), in order. Meant for refactorings that must not |
| 8 | +change what the panel subscribes to (issue #657): run it against the version before the change. |
| 9 | +
|
| 10 | +The cases cover the weather, outdoor and indoor temperature inputs (empty, single entity or list, visible or |
| 11 | +not), the temperature calibration reference (empty, sensor, climate, the panel's own entities, with and without |
| 12 | +the embedded thermostat), the wake-up sensors (none, a single entity or a list with gaps), and the chips and |
| 13 | +custom buttons (missing, empty, invalid and valid entities, with and without icon and color). |
| 14 | +
|
| 15 | +Usage: |
| 16 | + test_api_subscriptions.py REFERENCE_BLUEPRINT [BLUEPRINT] |
| 17 | +
|
| 18 | + REFERENCE_BLUEPRINT Blueprint before the change, e.g. from |
| 19 | + `git show origin/dev:nspanel_easy_blueprint.yaml > /tmp/reference.yaml` |
| 20 | + BLUEPRINT Blueprint to test (default: nspanel_easy_blueprint.yaml) |
| 21 | +
|
| 22 | +Requires PyYAML and Jinja2, pinned in .github/requirements.txt (`pip install -r .github/requirements.txt`). |
| 23 | +Exit code: 0 when both render the same, 1 on any difference. |
| 24 | +""" |
| 25 | + |
| 26 | +import random |
| 27 | +import sys |
| 28 | + |
| 29 | +import yaml |
| 30 | + |
| 31 | +from test_variable_entity import MockHomeAssistant, MockState, find, render |
| 32 | + |
| 33 | +STEP_ALIAS = "API Subscriptions" |
| 34 | +GLOBALS_ALIAS = "Global variables" |
| 35 | +RESULT_NAME = "api_subscriptions" |
| 36 | + |
| 37 | +NSPANEL_TEMP = "sensor.nspanel_temperature" |
| 38 | +THERMOSTAT_EMBEDDED = "climate.nspanel_thermostat" |
| 39 | +WEATHER_ENTITY = "weather.home" |
| 40 | + |
| 41 | +CASES = 4000 |
| 42 | +SEED = 657 |
| 43 | +MAX_REPORTED_DIFFERENCES = 20 |
| 44 | + |
| 45 | +# Values each kind of input takes in the cases |
| 46 | +ENTITY_SAMPLES = (None, "", "invalid", "sensor.a", "binary_sensor.door", "light.kitchen", "climate.living") |
| 47 | +ICON_SAMPLES = (None, "", "mdi:lightbulb", "mdi:not-an-icon") |
| 48 | +COLOR_SAMPLES = (None, [], [255, 0, 0], [1, 2]) |
| 49 | +TEMP_ENTITY_SAMPLES = ("", "sensor.outdoor", ["sensor.outdoor"], [], "climate.living", ["climate.living"], |
| 50 | + WEATHER_ENTITY) |
| 51 | +CALIBRATION_SAMPLES = ("", "sensor.reference", ["sensor.reference"], [], "climate.reference", |
| 52 | + ["climate.reference"], NSPANEL_TEMP, THERMOSTAT_EMBEDDED, "invalid") |
| 53 | +WAKE_UP_SAMPLES = (None, [], "", "binary_sensor.motion", ["binary_sensor.motion"], |
| 54 | + ["binary_sensor.motion", "", "binary_sensor.door"], ["", "", "binary_sensor.door"]) |
| 55 | + |
| 56 | + |
| 57 | +class InputRef: |
| 58 | + """An `!input` tag, resolved per case.""" |
| 59 | + |
| 60 | + def __init__(self, name): |
| 61 | + self.name = name |
| 62 | + |
| 63 | + |
| 64 | +class InputLoader(yaml.SafeLoader): |
| 65 | + """SafeLoader keeping `!input` tags as InputRef.""" |
| 66 | + |
| 67 | + |
| 68 | +InputLoader.add_multi_constructor("!", lambda loader, suffix, node: InputRef(loader.construct_scalar(node))) |
| 69 | + |
| 70 | + |
| 71 | +def resolve(node, inputs): |
| 72 | + """Replace every InputRef under node with the case's value (None when not set, like an empty input).""" |
| 73 | + if isinstance(node, InputRef): |
| 74 | + return inputs.get(node.name) |
| 75 | + if isinstance(node, dict): |
| 76 | + return {key: resolve(value, inputs) for key, value in node.items()} |
| 77 | + if isinstance(node, list): |
| 78 | + return [resolve(value, inputs) for value in node] |
| 79 | + return node |
| 80 | + |
| 81 | + |
| 82 | +def load(path): |
| 83 | + """Return the variables of the step, in order up to the result, and the global `home_page` variable.""" |
| 84 | + with open(path, encoding="utf-8") as f: |
| 85 | + data = yaml.load(f, Loader=InputLoader) # nosec B506 - SafeLoader subclass |
| 86 | + step = find(data.get("actions"), lambda d: d.get("alias") == STEP_ALIAS) |
| 87 | + global_step = find(data.get("actions"), lambda d: d.get("alias") == GLOBALS_ALIAS) |
| 88 | + if step is None or global_step is None: |
| 89 | + sys.exit(f"ERROR: '{STEP_ALIAS}' or '{GLOBALS_ALIAS}' not found in {path}") |
| 90 | + variables = [] |
| 91 | + for item in step["sequence"]: |
| 92 | + found = item.get("variables") if isinstance(item, dict) else None |
| 93 | + if found is None: |
| 94 | + continue |
| 95 | + variables.extend(found.items()) |
| 96 | + if RESULT_NAME in found: |
| 97 | + return variables, global_step["variables"]["home_page"] |
| 98 | + sys.exit(f"ERROR: '{RESULT_NAME}' not found in '{STEP_ALIAS}' of {path}") |
| 99 | + |
| 100 | + |
| 101 | +def run(hass, variables, home_page, case): |
| 102 | + """Render the variables in order, each seeing the ones before it, and return the result.""" |
| 103 | + hass.db = case["db"] |
| 104 | + context = { |
| 105 | + "nspaneltemp": NSPANEL_TEMP, |
| 106 | + "thermostat_embedded": THERMOSTAT_EMBEDDED, |
| 107 | + "home_page": render(hass, resolve(home_page, case["inputs"]), {}), |
| 108 | + } |
| 109 | + for name, value in variables: |
| 110 | + context[name] = render(hass, resolve(value, case["inputs"]), context) |
| 111 | + return context[RESULT_NAME] |
| 112 | + |
| 113 | + |
| 114 | +def cases(): |
| 115 | + """Random cases (seeded) over the inputs and the entities present.""" |
| 116 | + rng = random.Random(SEED) |
| 117 | + for _ in range(CASES): |
| 118 | + inputs = { |
| 119 | + "weather_entity": rng.choice(("", WEATHER_ENTITY, None)), |
| 120 | + "outdoortemp": rng.choice(TEMP_ENTITY_SAMPLES), |
| 121 | + "home_outdoor_temp_visible": rng.choice((True, False)), |
| 122 | + "indoortemp": rng.choice(TEMP_ENTITY_SAMPLES), |
| 123 | + "home_indoor_temp_visible": rng.choice((True, False)), |
| 124 | + "temp_calibration_reference": rng.choice(CALIBRATION_SAMPLES), |
| 125 | + "thermostat_use_temp_reference": rng.choice((True, False)), |
| 126 | + "wake_up_sensors": rng.choice(WAKE_UP_SAMPLES), |
| 127 | + } |
| 128 | + for index in range(1, 8): |
| 129 | + prefix = f"chip{index:02d}" |
| 130 | + inputs[prefix] = rng.choice(ENTITY_SAMPLES) |
| 131 | + inputs[prefix + "_inverted"] = rng.choice((True, False, None)) |
| 132 | + inputs[prefix + "_icon"] = rng.choice(ICON_SAMPLES) |
| 133 | + inputs[prefix + "_icon_color"] = rng.choice(COLOR_SAMPLES) |
| 134 | + for index in range(1, 8): |
| 135 | + prefix = f"home_custom_button{index:02d}" |
| 136 | + inputs[prefix] = rng.choice(ENTITY_SAMPLES) |
| 137 | + inputs[prefix + "_icon"] = rng.choice(ICON_SAMPLES) |
| 138 | + inputs[prefix + "_name"] = rng.choice((None, "", "Name")) |
| 139 | + db = {} |
| 140 | + if rng.random() < 0.7: |
| 141 | + db[WEATHER_ENTITY] = MockState(WEATHER_ENTITY, "sunny", {"temperature": 7}) |
| 142 | + if rng.random() < 0.5: |
| 143 | + db[THERMOSTAT_EMBEDDED] = MockState(THERMOSTAT_EMBEDDED, "heat", {"current_temperature": 21}) |
| 144 | + yield {"inputs": inputs, "db": db} |
| 145 | + |
| 146 | + |
| 147 | +def main(): |
| 148 | + if len(sys.argv) not in (2, 3): |
| 149 | + sys.exit(__doc__) |
| 150 | + reference = load(sys.argv[1]) |
| 151 | + candidate = load(sys.argv[2] if len(sys.argv) == 3 else "nspanel_easy_blueprint.yaml") |
| 152 | + hass = MockHomeAssistant() |
| 153 | + hass.env.filters["combine"] = lambda *dicts: {key: value for d in dicts for key, value in d.items()} |
| 154 | + |
| 155 | + differences = 0 |
| 156 | + total = 0 |
| 157 | + for case in cases(): |
| 158 | + total += 1 |
| 159 | + expected = run(hass, *reference, case) |
| 160 | + try: |
| 161 | + actual = run(hass, *candidate, case) |
| 162 | + except Exception as error: # pylint: disable=broad-except - any error is a difference |
| 163 | + actual = f"ERROR: {error!r}" |
| 164 | + if actual != expected: |
| 165 | + differences += 1 |
| 166 | + if differences <= MAX_REPORTED_DIFFERENCES: |
| 167 | + print(f"DIFF inputs={case['inputs']} db={sorted(case['db'])}\n reference: {expected}\n" |
| 168 | + f" tested: {actual}") |
| 169 | + print(f"{total} cases, {differences} differences") |
| 170 | + return 1 if differences else 0 |
| 171 | + |
| 172 | + |
| 173 | +if __name__ == "__main__": |
| 174 | + sys.exit(main()) |
0 commit comments