Skip to content

Commit 9a24d9b

Browse files
jonphobclaude
andcommitted
tests: add pytest suite for the glue's time-window logic
Regression protection for the class of bug this glue keeps hitting: a stale enable flag reopening a ~24h dispatch (2026-07-14) and export firing ~30 min early (2026-09-02). Covers _parse_time_of_day, _in_window (incl. midnight cross + fails-permissive), _minutes_remaining (incl. same-day-passed-end and midnight roll), and _clamp_power. 37 tests. alphaess_glue.py is a pyscript module and isn't importable normally, so tests/conftest.py execs it with pyscript's globals stubbed and a frozen clock - the shipped glue stays byte-identical to what runs in HA. Deliberately does not mock hardware dispatch/triggers (that's the live manual-slot territory in AI-ASSISTANT.md). Adds requirements-dev.txt, pytest.ini, and a README Tests section. (CI workflow .github/workflows/ci.yml added separately - needs the workflow token scope to push.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LAJPmF1cvsq1XRgtuxFuu
1 parent 00d0d4a commit 9a24d9b

6 files changed

Lines changed: 318 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
__pycache__/
22
*.pyc
3+
.pytest_cache/

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,27 @@ and Predbat restarts mid-dispatch.
118118
| `ha-packages/predbat_alphaess_decoys.yaml` | Decoy helper entities + template switches |
119119
| `apps.yaml.snippet.yaml` | Predbat `apps.yaml` inverter/control/telemetry section |
120120
| `AI-ASSISTANT.md` | Copy-paste prompts for setting up & testing safely with an AI assistant |
121+
| `tests/` | Unit tests for the glue's time-window logic (pytest) |
122+
123+
## Tests
124+
125+
The glue's **time-window logic** — the part where subtle bugs keep appearing
126+
(a stale flag reopening a ~24h dispatch; export firing ~30 min early) — has a
127+
small [pytest](tests/) suite as regression protection. It doesn't try to mock
128+
the hardware dispatch or triggers (that's what the live manual-slot checks in
129+
`AI-ASSISTANT.md` are for); it locks down the pure logic: `_parse_time_of_day`,
130+
`_in_window`, `_minutes_remaining` (incl. midnight-crossing) and `_clamp_power`.
131+
132+
`alphaess_glue.py` is a pyscript module and isn't normally importable, so
133+
`tests/conftest.py` loads it with pyscript's globals stubbed and a frozen clock —
134+
the shipped glue stays byte-identical to what runs in Home Assistant.
135+
136+
```sh
137+
pip install -r requirements-dev.txt
138+
pytest
139+
```
140+
141+
CI (`.github/workflows/ci.yml`) runs `py_compile` + the suite on every push and PR.
121142

122143
## Safety & disclaimer
123144

‎pytest.ini‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
testpaths = tests

‎requirements-dev.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Dev/test dependencies only. The glue itself has no pip dependencies - it runs
2+
# inside Home Assistant's pyscript, which provides its runtime.
3+
pytest>=7

‎tests/conftest.py‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Test harness for alphaess_glue.py.
2+
3+
alphaess_glue.py is a pyscript module, not a normal importable one: it relies on
4+
globals pyscript injects at runtime (`state`, `log`, `switch`, `number`,
5+
`select`, `button`, `task`) and on the `@time_trigger` / `@state_trigger`
6+
decorators, which run at import time. It also reads the wall clock via
7+
`datetime.datetime.now()`.
8+
9+
So we can't `import` it. Instead we read the source and `exec` it in a namespace
10+
where those globals are stubbed and the clock is controllable. This keeps the
11+
*shipped* glue byte-identical to what runs in Home Assistant - the tests adapt to
12+
it, never the other way round.
13+
14+
The `glue` fixture returns that namespace (functions + module constants), plus:
15+
glue["S"] - the FakeState (set entity states / attrs)
16+
glue["set_now"] - set the frozen wall-clock datetime
17+
glue["log"] - the log recorder (inspect emitted warnings/errors)
18+
"""
19+
20+
import datetime as _dt
21+
import pathlib
22+
import types
23+
24+
import pytest
25+
26+
GLUE_PATH = pathlib.Path(__file__).resolve().parents[1] / "pyscript" / "alphaess_glue.py"
27+
28+
29+
class FakeState:
30+
"""Stand-in for pyscript's `state`. Entity states and attributes are just
31+
dicts you populate per-test."""
32+
33+
def __init__(self):
34+
self._vals = {}
35+
self._attrs = {}
36+
37+
# pyscript API used by the glue
38+
def get(self, entity_id):
39+
return self._vals.get(entity_id)
40+
41+
def getattr(self, entity_id):
42+
return self._attrs.get(entity_id)
43+
44+
def set(self, entity_id, value=None, **kwargs):
45+
self._vals[entity_id] = value
46+
47+
# test helpers
48+
def put(self, entity_id, value):
49+
self._vals[entity_id] = value
50+
return self
51+
52+
def put_attrs(self, entity_id, attrs):
53+
self._attrs[entity_id] = attrs
54+
return self
55+
56+
57+
class Recorder:
58+
"""No-op stand-in for log / switch / number / select / button / task.
59+
Records every call so tests can assert on side effects if they want."""
60+
61+
def __init__(self):
62+
self.calls = []
63+
64+
def __getattr__(self, name):
65+
def _capture(*args, **kwargs):
66+
self.calls.append((name, args, kwargs))
67+
return _capture
68+
69+
def messages(self, level):
70+
"""e.g. recorder.messages('warning') -> list of message strings."""
71+
return [a[0] for (n, a, k) in self.calls if n == level and a]
72+
73+
74+
def _noop_decorator(*d_args, **d_kwargs):
75+
def deco(fn):
76+
return fn
77+
return deco
78+
79+
80+
class _Clock:
81+
value = _dt.datetime(2026, 9, 3, 12, 0, 0)
82+
83+
84+
class FakeDatetime(_dt.datetime):
85+
"""Real datetime in every way except now(), which returns the frozen value.
86+
Subclassing keeps strptime/combine/etc. behaving normally."""
87+
88+
@classmethod
89+
def now(cls, tz=None):
90+
return _Clock.value
91+
92+
93+
def _fake_datetime_module():
94+
return types.SimpleNamespace(
95+
datetime=FakeDatetime,
96+
timedelta=_dt.timedelta,
97+
date=_dt.date,
98+
time=_dt.time,
99+
)
100+
101+
102+
@pytest.fixture
103+
def glue():
104+
src = GLUE_PATH.read_text()
105+
ns = {}
106+
# decorators that execute at import
107+
ns["time_trigger"] = _noop_decorator
108+
ns["state_trigger"] = _noop_decorator
109+
ns["service"] = _noop_decorator
110+
# pyscript injected globals
111+
state = FakeState()
112+
log = Recorder()
113+
ns["state"] = state
114+
ns["log"] = log
115+
for name in ("switch", "number", "select", "button", "task"):
116+
ns[name] = Recorder()
117+
118+
exec(compile(src, str(GLUE_PATH), "exec"), ns)
119+
120+
# Replace the real datetime the module imported with our controllable one,
121+
# so functions calling datetime.datetime.now() see the frozen clock.
122+
ns["datetime"] = _fake_datetime_module()
123+
124+
ns["S"] = state
125+
ns["log"] = log
126+
127+
def set_now(dt):
128+
_Clock.value = dt
129+
130+
ns["set_now"] = set_now
131+
# sensible default clock for any test that forgets to set it
132+
set_now(_dt.datetime(2026, 9, 3, 12, 0, 0))
133+
return ns

‎tests/test_window_logic.py‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Unit tests for the glue's time-window logic.
2+
3+
This is the code where subtle, real-world bugs have repeatedly lived:
4+
* 2026-07-14 a stale enable flag + an unchanged end time silently reopened a
5+
~24h dispatch -> guarded by _minutes_remaining returning 0 for a
6+
same-day window whose end has already passed.
7+
* 2026-09-02 export ran ~30 min early because the glue dispatched the moment
8+
the enable flag was armed, ignoring the window start time
9+
-> guarded by _in_window().
10+
11+
The tests below lock in both behaviours plus the ordinary and midnight-crossing
12+
cases. Everything else in the glue (dispatch commit, triggers, hardware writes)
13+
is integration territory - validated with real manual slots, not mocked here.
14+
"""
15+
16+
import datetime as dt
17+
18+
import pytest
19+
20+
S, E = "test.start", "test.end"
21+
22+
23+
def _at(glue, hh, mm, start=None, end=None):
24+
"""Set the clock to today at hh:mm and (optionally) the window entities."""
25+
glue["set_now"](dt.datetime(2026, 9, 3, hh, mm, 0))
26+
if start is not None:
27+
glue["S"].put(S, start)
28+
if end is not None:
29+
glue["S"].put(E, end)
30+
31+
32+
# --------------------------------------------------------------------------
33+
# _parse_time_of_day
34+
# --------------------------------------------------------------------------
35+
36+
@pytest.mark.parametrize("raw,expected", [
37+
("20:00", dt.time(20, 0)),
38+
("20:00:00", dt.time(20, 0, 0)),
39+
("05:30:00", dt.time(5, 30, 0)),
40+
("00:00", dt.time(0, 0)),
41+
])
42+
def test_parse_time_valid(glue, raw, expected):
43+
assert glue["_parse_time_of_day"](raw) == expected
44+
45+
46+
@pytest.mark.parametrize("raw", ["garbage", "", "25:99", None, "unknown", "unavailable"])
47+
def test_parse_time_invalid_returns_none(glue, raw):
48+
assert glue["_parse_time_of_day"](raw) is None
49+
50+
51+
# --------------------------------------------------------------------------
52+
# _in_window - same-day window (this is the 2026-09-02 regression)
53+
# --------------------------------------------------------------------------
54+
55+
@pytest.mark.parametrize("hh,mm,expected", [
56+
(19, 30, False), # <-- armed 30 min early: MUST NOT dispatch (the fix)
57+
(19, 59, False),
58+
(20, 0, True), # start boundary inclusive
59+
(20, 30, True), # squarely inside
60+
(21, 0, True), # end boundary inclusive
61+
(21, 1, False), # just after end
62+
(23, 0, False),
63+
])
64+
def test_in_window_same_day(glue, hh, mm, expected):
65+
_at(glue, hh, mm, start="20:00:00", end="21:00:00")
66+
assert glue["_in_window"](S, E) is expected
67+
68+
69+
# --------------------------------------------------------------------------
70+
# _in_window - window crossing midnight (e.g. the overnight charge 23:30->05:30)
71+
# --------------------------------------------------------------------------
72+
73+
@pytest.mark.parametrize("hh,mm,expected", [
74+
(22, 0, False), # before start
75+
(23, 29, False),
76+
(23, 30, True), # start boundary
77+
(23, 59, True),
78+
(0, 30, True), # after midnight, still in window
79+
(5, 0, True),
80+
(5, 30, True), # end boundary
81+
(5, 31, False),
82+
(6, 0, False), # after end
83+
(12, 0, False), # midday, nowhere near
84+
])
85+
def test_in_window_crosses_midnight(glue, hh, mm, expected):
86+
_at(glue, hh, mm, start="23:30:00", end="05:30:00")
87+
assert glue["_in_window"](S, E) is expected
88+
89+
90+
# --------------------------------------------------------------------------
91+
# _in_window - fails permissive when the start can't be read
92+
# --------------------------------------------------------------------------
93+
94+
def test_in_window_unreadable_start_is_permissive(glue):
95+
_at(glue, 3, 0, start="unknown", end="21:00:00")
96+
assert glue["_in_window"](S, E) is True
97+
98+
99+
def test_in_window_unreadable_end_is_permissive(glue):
100+
_at(glue, 3, 0, start="20:00:00", end=None)
101+
assert glue["_in_window"](S, E) is True
102+
103+
104+
# --------------------------------------------------------------------------
105+
# _minutes_remaining
106+
# --------------------------------------------------------------------------
107+
108+
def test_minutes_remaining_same_day_counts_to_end(glue):
109+
_at(glue, 20, 0, start="20:00:00", end="21:00:00")
110+
assert glue["_minutes_remaining"](S, E) == 60
111+
112+
113+
def test_minutes_remaining_same_day_passed_end_returns_zero(glue):
114+
# 2026-07-14 regression: a same-day window whose end has passed is OVER,
115+
# not "see you tomorrow" - otherwise a stale flag reopens a ~24h dispatch.
116+
_at(glue, 22, 0, start="20:00:00", end="21:00:00")
117+
assert glue["_minutes_remaining"](S, E) == 0
118+
119+
120+
def test_minutes_remaining_midnight_before_midnight(glue):
121+
# 23:00, window 23:30->05:30 -> end rolls to tomorrow 05:30 = 390 min away.
122+
_at(glue, 23, 0, start="23:30:00", end="05:30:00")
123+
assert glue["_minutes_remaining"](S, E) == 390
124+
125+
126+
def test_minutes_remaining_midnight_after_midnight(glue):
127+
# 02:00, window 23:30->05:30 -> end is today 05:30 = 210 min away.
128+
_at(glue, 2, 0, start="23:30:00", end="05:30:00")
129+
assert glue["_minutes_remaining"](S, E) == 210
130+
131+
132+
def test_minutes_remaining_unparseable_end_returns_zero_and_logs(glue):
133+
_at(glue, 20, 0, start="20:00:00", end="garbage")
134+
assert glue["_minutes_remaining"](S, E) == 0
135+
assert glue["log"].messages("error"), "expected an error log for unparseable end"
136+
137+
138+
# --------------------------------------------------------------------------
139+
# _clamp_power - the 2026-08-05 out-of-range guard
140+
# --------------------------------------------------------------------------
141+
142+
def test_clamp_power_within_range_unchanged(glue):
143+
glue["S"].put_attrs(glue["DISPATCH_POWER"], {"min": -5.0, "max": 5.0})
144+
assert glue["_clamp_power"](3.0) == 3.0
145+
assert glue["_clamp_power"](-5.0) == -5.0
146+
147+
148+
def test_clamp_power_clamps_both_ends(glue):
149+
glue["S"].put_attrs(glue["DISPATCH_POWER"], {"min": -5.0, "max": 5.0})
150+
assert glue["_clamp_power"](6.0) == 5.0
151+
assert glue["_clamp_power"](-6.0) == -5.0
152+
153+
154+
def test_clamp_power_falls_back_to_conservative_limit(glue):
155+
# No attrs available -> _dispatch_power_limits falls back to +/-3.0.
156+
assert glue["_clamp_power"](5.0) == 3.0
157+
assert glue["_clamp_power"](-5.0) == -3.0
158+
assert glue["log"].messages("warning"), "expected a CLAMPED warning"

0 commit comments

Comments
 (0)