Startup backfill + regenerate service (Feature 07, task 7) (#81)
This commit was merged in pull request #81.
This commit is contained in:
@@ -307,6 +307,50 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||
|
||||
|
||||
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_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):
|
||||
self._hass = hass
|
||||
self._services: dict[tuple[str, str], object] = {}
|
||||
|
||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||
self._services[(domain, service)] = func
|
||||
return None
|
||||
|
||||
def has_service(self, domain, service):
|
||||
return (domain, service) in self._services
|
||||
|
||||
def async_remove(self, domain, service):
|
||||
self._services.pop((domain, service), None)
|
||||
return None
|
||||
|
||||
async def call(self, domain, service, data=None):
|
||||
func = self._services.get((domain, service))
|
||||
if func is None:
|
||||
raise KeyError(f"service {domain}.{service} not registered")
|
||||
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
||||
result = func(call)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
return result
|
||||
|
||||
@property
|
||||
def registered_services(self):
|
||||
"""The registered (domain, service) pairs."""
|
||||
return list(self._services)
|
||||
|
||||
|
||||
def make_config_entry(
|
||||
entry_id: str = "test_entry",
|
||||
options: dict | None = None,
|
||||
@@ -369,6 +413,7 @@ def make_hass() -> tuple:
|
||||
hass.async_add_executor_job = _run_executor_job
|
||||
registry = ListenerRegistry(hass)
|
||||
hass.time_change = TimeChangeRegistry(hass)
|
||||
hass.services = ServiceRegistry(hass)
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
+174
-2
@@ -11,6 +11,7 @@ from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
)
|
||||
|
||||
@@ -345,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:
|
||||
@@ -381,4 +384,173 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||
"quest_definitions",
|
||||
"schedule_rules",
|
||||
]
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def _seed_daily_child_and_definition(database, today) -> int:
|
||||
"""Create one always-present child + a daily definition starting today.
|
||||
|
||||
Returns the child's id. No presence schedule means the child is present
|
||||
every day, so the daily rule yields exactly one instance per horizon day.
|
||||
"""
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
NOW = "2026-09-14T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
return child.id
|
||||
|
||||
|
||||
def _local_today(hass) -> "datetime.date":
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
|
||||
|
||||
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||
"""Setup materializes once so a restart backfills any missed days."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||
# already holds definitions. The startup backfill must pick them up.
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); the service must generate them on demand.
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
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)
|
||||
|
||||
|
||||
async def test_regenerate_service_is_domain_scoped_across_entries(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The ``regenerate`` service is domain-global, not per-entry.
|
||||
|
||||
With two loaded entries the service must be registered and functional;
|
||||
unloading one entry must keep it registered and functional for the
|
||||
remaining entry; unloading the final entry must remove it.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
||||
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
||||
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# The domain-global handler resolves a live database from any entry.
|
||||
database_b = entry_b.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database_b, _local_today(hass)
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
# Unloading one entry must not tear down the shared service.
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# It still works for the remaining entry.
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# Unloading the final entry removes the domain-global service.
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
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")
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tests for the NestQuest services.yaml declaration file."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SERVICES_PATH = (
|
||||
Path(__file__).parent.parent
|
||||
/ "custom_components"
|
||||
/ "nestquest"
|
||||
/ "services.yaml"
|
||||
)
|
||||
|
||||
|
||||
def test_services_yaml_exists() -> None:
|
||||
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
||||
|
||||
|
||||
def test_services_yaml_declares_regenerate() -> None:
|
||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||
"services.yaml must declare the 'regenerate' service"
|
||||
)
|
||||
|
||||
|
||||
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
||||
"""The regenerate service is documented with a clear description and no
|
||||
required fields (there is no ``fields`` block under it)."""
|
||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
||||
assert match is not None
|
||||
section = text[match.end():]
|
||||
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
||||
if next_service is not None:
|
||||
section = section[: next_service.start()]
|
||||
assert re.search(r"description:", section), (
|
||||
"regenerate must carry a description"
|
||||
)
|
||||
assert "fields:" not in section, (
|
||||
"regenerate declares no required fields"
|
||||
)
|
||||
Reference in New Issue
Block a user