feat: schedule daily materialization run at day-rollover time

This commit is contained in:
2026-09-17 15:02:08 -04:00
parent ca26ced59f
commit 6231344520
3 changed files with 221 additions and 4 deletions
+68
View File
@@ -44,6 +44,7 @@ _HA_MODULES = (
"homeassistant.exceptions",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.event",
)
def _find_spec(name: str):
@@ -83,6 +84,7 @@ for _parent, _child in (
("homeassistant", "exceptions"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "event"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
@@ -242,6 +244,68 @@ class ListenerRegistry:
return sum(len(listeners) for listeners in self._listeners.values())
class TimeChangeRegistry:
"""Models HA's time-change listener bookkeeping.
``track`` mirrors ``homeassistant.helpers.event.async_track_time_change``:
each call appends a record (action + hour/minute/second/local) and returns
a remove callable that reverses the registration. Tests inspect
``registrations`` and exercise the newest action through :meth:`fire`.
"""
def __init__(self, hass=None):
self._hass = hass
self._registrations: list[dict] = []
def track(self, action, hour=None, minute=None, second=None, local=False):
record = {
"action": action,
"hour": hour,
"minute": minute,
"second": second,
"local": local,
}
def _remove():
self._registrations.remove(record)
record["remove"] = _remove
self._registrations.append(record)
return _remove
@property
def registrations(self):
"""Return a copy of the live registrations."""
return list(self._registrations)
@property
def size(self) -> int:
"""Number of live registrations."""
return len(self._registrations)
async def fire(self, now=None, index: int = -1):
"""Run a registered action as HA's scheduler would, awaiting coroutines."""
result = self._registrations[index]["action"](now)
if inspect.isawaitable(result):
await result
def _async_track_time_change(hass, action, hour=None, minute=None, second=None, local=False):
"""Mirror homeassistant.helpers.event.async_track_time_change.
Routes to the registry attached to ``hass`` (created on demand), so
tests reach the registrations via ``hass.time_change``.
"""
registry = getattr(hass, "time_change", None)
if registry is None:
registry = TimeChangeRegistry(hass)
hass.time_change = registry
return registry.track(action, hour=hour, minute=minute, second=second, local=local)
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
def make_config_entry(
entry_id: str = "test_entry",
options: dict | None = None,
@@ -303,6 +367,10 @@ def make_hass() -> tuple:
hass.async_add_executor_job = _run_executor_job
registry = ListenerRegistry(hass)
hass.time_change = TimeChangeRegistry(hass)
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
hass.config.time_zone = "UTC"
async def _async_reload(entry_id: str) -> None:
registry.reloaded.append(entry_id)
+84 -1
View File
@@ -8,7 +8,11 @@ import pytest
from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
from custom_components.nestquest.const import (
CONF_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DOMAIN as DOMAIN_CONST,
)
def _wire(entry, registry):
@@ -239,6 +243,85 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
assert DOMAIN not in hass.data
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
"""Setup registers a daily rollover listener and stores its cancel fn."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.time_change.size == 1
registration = hass.time_change.registrations[0]
assert registration["hour"] == 0
assert registration["minute"] == 0
assert registration["local"] is True
assert callable(entry.runtime_data.remove_time_change_listener)
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
"""The listener fires at the entry option's rollover time, not the default."""
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
await async_setup_entry(hass, entry)
registration = hass.time_change.registrations[0]
assert registration["hour"] == 6
assert registration["minute"] == 30
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
"""Unload removes the time-change listener alongside the update listener."""
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
assert hass.time_change.size == 1
assert await async_unload_entry(hass, entry) is True
assert hass.time_change.size == 0
async def test_rollover_time_change_reregisters_listener_at_new_time(
hass, make_entry
) -> None:
"""A rollover-time options change re-registers the listener at the new time."""
entry = _wire(
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
)
await async_setup_entry(hass, entry)
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
# The options flow writes the new rollover time, then HA reloads the
# entry: unload tears down the old listener, setup re-registers it.
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
await async_unload_entry(hass, entry)
assert hass.time_change.size == 0
assert await async_setup_entry(hass, entry) is True
assert hass.time_change.size == 1
registration = hass.time_change.registrations[0]
assert (registration["hour"], registration["minute"]) == (6, 30)
async def test_day_rollover_listener_materializes_ha_local_horizon(
hass, make_entry, monkeypatch
) -> None:
"""Firing the listener materializes today..today+horizon in HA local time."""
import datetime
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
import custom_components.nestquest as nestquest
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
fake = AsyncMock()
monkeypatch.setattr(nestquest, "_materialize_run", fake)
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
expected_start = today.isoformat()
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
await hass.time_change.fire()
fake.assert_awaited_once_with(database, expected_start, expected_end)
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
"""Setup applies the full v1 DDL: all four v1 tables exist afterwards."""
entry = _wire(make_entry(), hass.registry)