fix: async_remove service, HA-local today for no-past guard, setup cleanup (NQ-81-001..003)

This commit is contained in:
2026-09-17 15:34:39 -04:00
parent 0127362ebf
commit 386f9dc8b6
6 changed files with 205 additions and 67 deletions
+8 -9
View File
@@ -311,11 +311,13 @@ class ServiceRegistry:
"""Models HA's ``hass.services`` service registry.
``async_register`` stores a handler under its (domain, service) key,
``has_service`` reports registration, and ``async_unregister`` removes
it. ``async_remove`` is the deprecated HA alias, kept so either
spelling resolves. ``call`` invokes a stored handler the way HA's
service bus would — awaiting a coroutine result — so tests can exercise
a registered service end-to-end.
``has_service`` reports registration, and ``async_remove`` removes it
— exactly the surface Home Assistant's ``ServiceRegistry`` exposes
(there is deliberately NO ``async_unregister`` alias here, so a caller
that uses the wrong method name fails instead of being silently
papered over). ``call`` invokes a stored handler the way HA's service
bus would — awaiting a coroutine result — so tests can exercise a
registered service end-to-end.
"""
def __init__(self, hass=None):
@@ -329,13 +331,10 @@ class ServiceRegistry:
def has_service(self, domain, service):
return (domain, service) in self._services
def async_unregister(self, domain, service):
def async_remove(self, domain, service):
self._services.pop((domain, service), None)
return None
def async_remove(self, domain, service):
return self.async_unregister(domain, service)
async def call(self, domain, service, data=None):
func = self._services.get((domain, service))
if func is None:
+31 -2
View File
@@ -346,7 +346,9 @@ async def test_day_rollover_listener_materializes_ha_local_horizon(
await hass.time_change.fire()
fake.assert_awaited_once_with(database, expected_start, expected_end)
fake.assert_awaited_once_with(
database, expected_start, expected_end, today=today
)
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
@@ -479,4 +481,31 @@ async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert await async_unload_entry(hass, entry) is True
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
async def test_setup_cleans_listeners_when_materialization_raises(
hass, make_entry, monkeypatch
) -> None:
"""A startup-materialization failure rolls back every registered listener.
The daily and update listeners are installed before the startup
materialization runs; when that walk raises, the database is closed but
the remover callables must also be invoked so no listener survives
firing against a closed database.
"""
import custom_components.nestquest as nestquest
async def _boom(*_args, **_kwargs):
raise RuntimeError("materialization failed")
monkeypatch.setattr(nestquest, "_materialize_run", _boom)
entry = _wire(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="materialization failed"):
await async_setup_entry(hass, entry)
assert hass.time_change.size == 0
assert hass.registry.size == 0
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert not hasattr(entry, "runtime_data")
+38 -4
View File
@@ -340,7 +340,7 @@ async def _materialize_with_change(
seen = {"n": 0}
async def _gated(self, definition_id, child_id, due_date, generated_at,
*, window, due_time=None):
*, window, due_time=None, today=None):
seen["n"] += 1
if seen["n"] == 1:
seen["first"] = (definition_id, child_id, window)
@@ -348,7 +348,7 @@ async def _materialize_with_change(
await release.wait()
return await original_upsert(
self, definition_id, child_id, due_date, generated_at,
window=window, due_time=due_time,
window=window, due_time=due_time, today=today,
)
QuestInstancesDao.upsert_if_valid = _gated
@@ -630,6 +630,42 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
_with_db(tmp_path, "materialize-bounds.db")(_body)
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
"""The no-past guard compares against a caller-supplied HA-local today.
A household time zone behind the host clock around midnight resolves a
HA-local "today" one calendar day earlier than the host's
``date.today()``. The host-clock guard would reject that date as past,
so the resolved HA-local date must be threaded through ``materialize``
and accepted instead of raising.
"""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
ha_today = datetime.date.today() - datetime.timedelta(days=1)
start_iso = ha_today.isoformat()
end_iso = (ha_today + datetime.timedelta(days=14)).isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
# Without the pinned HA-local today, the host-clock guard rejects
# the horizon start (ha_today is "yesterday" on the host).
with pytest.raises(ValueError, match="never generated in the past"):
await materialize(database, start_iso, end_iso)
# With today=ha_today, the same range is accepted and materialized.
count = await materialize(database, start_iso, end_iso, today=ha_today)
assert count == 15
return count
_with_db(tmp_path, "materialize-ha-local-today.db")(_body)
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.quest_definitions import (
@@ -914,10 +950,8 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
return day0 if clock["reads"] == 1 else day1
import custom_components.nestquest.dao_instances as dao_instances_module
import custom_components.nestquest.materialize as materialize_module
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
monkeypatch.setattr(materialize_module, "_today", _fake_today)
async def _body(database):
children = ChildrenDao(database)