Skip to content

Commit d14be94

Browse files
authored
Merge pull request #676 from edwardtfn/dev
improve: Fewer templates for the API subscriptions
2 parents 6c8ed91 + 79cf377 commit d14be94

3 files changed

Lines changed: 334 additions & 98 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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())

‎.github/workflows/versioning.yml‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# 10. Creates a version tag and GitHub Release, described by the PR that triggered it
1414
# 11. Updates the stable and latest floating tags
1515
# 12. Announces the release on Discord
16+
# 13. Brings dev up to date with the released main, creating it if missing
17+
# (runs in parallel with step 12, so a failure never blocks the announcement)
1618
#
1719
# The workflow skips execution if the head commit message contains
1820
# [skip-versioning], preventing loops caused by its own version bump and
@@ -611,6 +613,7 @@ jobs:
611613
outputs:
612614
released: ${{ steps.push_tag.outcome == 'success' }}
613615
version: ${{ needs.bump.outputs.version }}
616+
release_sha: ${{ steps.capture_release_sha.outputs.sha }}
614617
release_url: ${{ steps.create_release.outputs.url }}
615618

616619
steps:
@@ -694,6 +697,10 @@ jobs:
694697
exit 1
695698
}
696699
700+
- name: Capture release SHA
701+
id: capture_release_sha
702+
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
703+
697704
# -------------------------------------------------------------------
698705
# Tag and release (after binary commit, so tag points to final state)
699706
# -------------------------------------------------------------------
@@ -760,7 +767,8 @@ jobs:
760767
761768
# ---------------------------------------------------------------------------
762769
# Announce the new release on Discord.
763-
# Always runs last — only when a release was actually published.
770+
# Runs after the release, in parallel with sync-dev — only when a release
771+
# was actually published.
764772
# ---------------------------------------------------------------------------
765773
notify-discord:
766774
name: Notify Discord
@@ -785,4 +793,81 @@ jobs:
785793
-H "Content-Type: application/json" \
786794
-d @- \
787795
"$DISCORD_WEBHOOK"
796+
797+
# ---------------------------------------------------------------------------
798+
# Bring dev up to date with the released main, so the version bump and the
799+
# pre-built firmware commits do not leave dev behind main.
800+
# Runs in parallel with notify-discord, so a failure here never blocks the
801+
# announcement. A conflict fails this job and is left for manual resolution;
802+
# dev is never force-pushed.
803+
# Note: pushes made with GITHUB_TOKEN do not trigger other workflows, so CI
804+
# does not run on the updated dev.
805+
# ---------------------------------------------------------------------------
806+
sync-dev:
807+
name: Sync dev with main
808+
needs: [commit-and-release]
809+
if: needs.commit-and-release.outputs.released == 'true'
810+
runs-on: ubuntu-latest
811+
concurrency:
812+
group: sync-dev
813+
cancel-in-progress: false
814+
permissions:
815+
contents: write
816+
817+
steps:
818+
- name: Checkout released main
819+
uses: actions/checkout@v7
820+
with:
821+
ref: ${{ needs.commit-and-release.outputs.release_sha }}
822+
fetch-depth: 0
823+
824+
- name: Set up Git
825+
run: |
826+
git config user.name "GitHub Actions"
827+
git config user.email "actions@github.com"
828+
829+
- name: Fast-forward, merge or create dev
830+
env:
831+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
832+
RELEASE_SHA: ${{ needs.commit-and-release.outputs.release_sha }}
833+
NEW_VERSION: ${{ needs.commit-and-release.outputs.version }}
834+
run: |
835+
if [[ -z "$RELEASE_SHA" ]]; then
836+
echo "ERROR: release SHA is empty"
837+
exit 1
838+
fi
839+
840+
# Exit code 2 means the branch does not exist; any other failure is
841+
# an error and must not be taken as a missing branch.
842+
RC=0
843+
git ls-remote --exit-code --heads origin dev > /dev/null || RC=$?
844+
if [[ $RC -eq 2 ]]; then
845+
echo "dev does not exist, creating it from ${RELEASE_SHA}"
846+
git push origin "${RELEASE_SHA}:refs/heads/dev"
847+
exit 0
848+
elif [[ $RC -ne 0 ]]; then
849+
echo "ERROR: could not query the dev branch (exit code ${RC})"
850+
exit 1
851+
fi # if [[ $RC -eq 2 ]]
852+
853+
git fetch origin dev
854+
855+
if git merge-base --is-ancestor "$RELEASE_SHA" origin/dev; then
856+
echo "dev already contains ${RELEASE_SHA}, nothing to do"
857+
elif git merge-base --is-ancestor origin/dev "$RELEASE_SHA"; then
858+
# Normal case: dev has nothing beyond the released main.
859+
echo "Fast-forwarding dev to ${RELEASE_SHA}"
860+
git push origin "${RELEASE_SHA}:refs/heads/dev"
861+
else
862+
# dev received commits during the release window: merge main into it.
863+
echo "dev has diverged from main, merging ${RELEASE_SHA} into dev"
864+
git checkout -B dev origin/dev
865+
if ! git merge --no-ff "$RELEASE_SHA" -m "Merge main (v${NEW_VERSION}) into dev"; then
866+
git merge --abort
867+
echo "ERROR: merging main into dev conflicts; resolve it manually"
868+
exit 1
869+
fi
870+
# A plain push: if dev moved again meanwhile, it is rejected rather than overwritten.
871+
git push origin HEAD:refs/heads/dev
872+
fi # if git merge-base --is-ancestor "$RELEASE_SHA" origin/dev
788873
...

0 commit comments

Comments
 (0)