Compare commits

...
10 changed files with 1975 additions and 55 deletions
+49 -19
View File
@@ -19,6 +19,7 @@ from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DOMAIN,
@@ -273,10 +274,28 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
return int(hour), int(minute)
def _configured_horizon_days(options: dict[str, Any]) -> int:
"""Return the configured generation horizon, validated, else the default.
The options flow already validates ``horizon_days`` as an integer >= 1,
but a stored ``entry.options`` dict could still carry a partial or
hand-edited value. Re-checking here — and falling back to
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
— keeps a malformed option from shrinking or exploding the materialized
horizon.
"""
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
return DEFAULT_HORIZON_DAYS
return value
async def _run_horizon_materialization(
hass: HomeAssistant, database: NestQuestDatabase
hass: HomeAssistant,
database: NestQuestDatabase,
horizon_days: int = DEFAULT_HORIZON_DAYS,
) -> None:
"""Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
@@ -285,13 +304,15 @@ async def _run_horizon_materialization(
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic.
no duplicated materialization logic. ``horizon_days`` sizes the window
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
entry's validated option value through.
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start_date = today.isoformat()
end_date = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
today + datetime.timedelta(days=horizon_days)
).isoformat()
await _materialize_run(database, start_date, end_date, today=today)
@@ -304,31 +325,34 @@ def _register_day_rollover_listener(
"""Register the daily materialization listener and return its remover.
Fires once per day at the configured local ``day_rollover_time`` and
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``
through the shared :func:`_run_horizon_materialization` path.
materializes the rolling horizon ``[today, today + horizon_days]``
through the shared :func:`_run_horizon_materialization` path, using the
entry's configured (validated) ``horizon_days``.
"""
hour, minute = _day_rollover_hour_minute(options)
horizon_days = _configured_horizon_days(options)
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database)
await _run_horizon_materialization(hass, database, horizon_days)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None:
"""Return a connected database from the active runtime data, or None.
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a connected database, or None.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's database is currently
live at call time, so none of them can hold a stale handle to a closed
connection after another entry unloads.
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's options (e.g. the configured horizon).
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
return database
return runtime_data
return None
@@ -349,14 +373,17 @@ def _register_regenerate_service(hass: HomeAssistant) -> None:
return
async def _regenerate(_call: Any) -> None:
database = _find_live_database(hass)
if database is None:
runtime_data = _find_live_runtime_data(hass)
if runtime_data is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(hass, database)
horizon_days = _configured_horizon_days(runtime_data.options)
await _run_horizon_materialization(
hass, runtime_data.database, horizon_days
)
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
@@ -456,9 +483,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs
# through the SAME path as the daily listener and regenerate service.
await _run_horizon_materialization(hass, database)
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(
hass, database, _configured_horizon_days(dict(entry.options))
)
_register_regenerate_service(hass)
except BaseException:
# A failure anywhere after the database is open must not leak the
+200
View File
@@ -0,0 +1,200 @@
"""Business layer for the completion event log (Feature 08).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
validation and the UTC occurred-at stamp here, and never talk to the
DAO or SQL directly. This module is the only Feature-08 caller of
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing and
un-completing an instance (no-op-if-already-done) arrive in later
tasks. Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done``); there is no status
column.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
requires ``actor_child_id`` (the tapped profile) and forbids
``actor_user_id``. Inverted shapes raise a field-naming ValueError
before any write.
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
lexicographic range queries stay exact. ``was_on_time`` is required
(True/False) for completed events; un-completions may pass None. There
is no permission check here (that is Feature 09).
"""
from __future__ import annotations
import datetime
from .dao_children import _connection_lock
from .dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstancesDao,
)
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
if now is None:
return _now_stamp()
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return now.astimezone(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _validate_int_id(value: object, field: str) -> int:
"""Reject non-int ids (bools included) before any lookup."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer, got {value!r}")
return value
def _validate_event_type(value: object) -> str:
"""Return a schema-legal event type, or raise naming the field."""
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {value!r}"
)
return value
def _validate_actor(
actor_source: object,
actor_user_id: object,
actor_child_id: object,
) -> tuple[str, str | None, int | None]:
"""Return a schema-legal actor triple, or raise naming the field.
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
``actor_child_id``. ``'panel'`` requires an integer
``actor_child_id`` and forbids ``actor_user_id``. Any other
``actor_source`` (including ``'service'``) is rejected.
"""
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
)
if actor_source == "user":
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
raise ValueError("actor_source 'user' requires actor_user_id")
if actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
return "user", actor_user_id.strip(), None
if actor_user_id is not None:
raise ValueError("actor_source 'panel' must not carry actor_user_id")
if actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id"
)
return (
"panel",
None,
_validate_int_id(actor_child_id, "actor_child_id"),
)
def _validate_was_on_time(
event_type: str, was_on_time: object
) -> bool | None:
"""Return a real bool or None; completed events require True/False."""
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got {was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
return was_on_time
async def append_event(
database: NestQuestDatabase,
instance_id: int,
child_id: int,
event_type: str,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
was_on_time: bool | None = None,
now: datetime.datetime | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event and return the row.
Validates actor shapes and the completed-event on-time requirement
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
child_id = _validate_int_id(child_id, "child_id")
event_type = _validate_event_type(event_type)
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
was_on_time = _validate_was_on_time(event_type, was_on_time)
occurred_at = _stamp_occurred_at(now)
return await CompletionEventsDao(database).append(
instance_id,
child_id,
event_type,
actor_source,
occurred_at,
was_on_time,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
)
async def instance_state(
database: NestQuestDatabase,
instance_id: int,
) -> str:
"""Return ``open`` or ``done`` from the instance's latest event.
No events, or a latest event of uncompleted, is ``open``. A latest
event of completed is ``done``. Unknown ``instance_id`` raises
ValueError naming the field. Existence and the latest event are
read inside the connection lock so the answer is one snapshot.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
async with _connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return "open"
if latest.event_type == EVENT_COMPLETED:
return "done"
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
+16 -9
View File
@@ -344,14 +344,20 @@ class QuestInstancesDao:
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped).
is skipped);
- the window's live ``due_time`` still EQUALS the caller-supplied
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
with no due time matches a snapshot with no due time). A
window-time edit landed after the input snapshot makes the
caller's ``due_time`` stale, and writing it would overwrite the
newer due_time a concurrent regeneration already upserted — so
the tuple is skipped instead of written.
The live checks above ONLY decide whether the tuple is still
valid; they never read the window's ``due_time``. The instance is
written with the caller-supplied ``due_time`` — the value snapshotted
from the coherent input read — so a window-time edit landing
mid-walk cannot leak a newer time into a batch that was already
snapshotted against older rule/presence/assignment decisions.
The due_time check closes the one check-then-insert gap this
method previously left open: it re-validated every OTHER
precondition but then wrote the snapshot's ``due_time``
unconditionally, so a stale materialization could clobber a
newer, concurrently-regenerated due_time.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
@@ -384,8 +390,9 @@ class QuestInstancesDao:
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 LIMIT 1",
(definition_id, child_id, window),
"AND d.is_active = 1 AND c.is_active = 1 "
"AND w.due_time IS ? LIMIT 1",
(definition_id, child_id, window, due_time),
)
if still_valid is None:
# A config change invalidated the tuple since the
+58 -7
View File
@@ -44,7 +44,8 @@ definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
covers a single child's presence change (Feature 09). Both delete the
affected open instances at or after today — never a completed instance,
never the past — and re-run the walk over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]``.
``[today, today + horizon_days]`` (``horizon_days`` defaults to
``DEFAULT_HORIZON_DAYS``).
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
@@ -83,6 +84,31 @@ def _now_stamp() -> str:
)
def _resolve_horizon_days(horizon_days: int | None) -> int:
"""Validate a regeneration horizon and return the resolved day count.
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
or a value below 1 raises ValueError naming the field — the validation
runs BEFORE any future-instance delete so a malformed horizon can never
delete rows it then fails to rebuild. A negative horizon would
otherwise delete first and only then be rejected by
:func:`materialize`'s inverted-range check; ``0``/``False`` would
silently shrink the window to a single day.
"""
if horizon_days is None:
return DEFAULT_HORIZON_DAYS
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < 1
):
raise ValueError(
f"horizon_days must be a positive integer, got {horizon_days!r}"
)
return horizon_days
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
@@ -151,6 +177,14 @@ async def materialize(
Assistant's configured time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
@@ -184,6 +218,13 @@ async def materialize(
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
@@ -217,6 +258,7 @@ async def regenerate_for_definition(
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
@@ -226,7 +268,7 @@ async def regenerate_for_definition(
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
``[today, today + horizon_days]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
@@ -236,14 +278,18 @@ async def regenerate_for_definition(
range. ``today`` optionally pins a caller-resolved HA-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. Returns the number of instances the re-materialization
consistent. ``horizon_days`` sizes the re-materialization window;
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
today_date + datetime.timedelta(days=horizon)
).isoformat()
instances = QuestInstancesDao(database)
@@ -271,6 +317,7 @@ async def regenerate_for_child(
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
@@ -284,7 +331,7 @@ async def regenerate_for_child(
(never a completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
then the materialization walk re-runs over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
``[today, today + horizon_days]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
@@ -300,13 +347,17 @@ async def regenerate_for_child(
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. Returns the number of instances
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
@@ -320,7 +371,7 @@ async def regenerate_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
@@ -271,6 +271,8 @@ async def edit_quest_definition(
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
@@ -292,6 +294,16 @@ async def edit_quest_definition(
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
supplies the hass-derived value. When omitted, regeneration falls
back to the host clock, same as before. ``horizon_days`` sizes the
regeneration's re-materialization window the same way (threaded into
:func:`~.materialize.regenerate_for_definition`); it defaults to
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
"""
_validate_definition_id(definition_id)
@@ -342,7 +354,9 @@ async def edit_quest_definition(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(database, definition_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=snapshot.definition,
@@ -356,6 +370,9 @@ async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
@@ -369,6 +386,11 @@ async def assign_child(
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
@@ -384,7 +406,9 @@ async def assign_child(
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(database, definition_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return assignees
@@ -392,6 +416,9 @@ async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
@@ -401,6 +428,11 @@ async def unassign_child(
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
@@ -411,7 +443,9 @@ async def unassign_child(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(database, definition_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return removed
@@ -419,6 +453,9 @@ async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
@@ -441,6 +478,11 @@ async def set_quest_definition_active(
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
@@ -464,7 +506,9 @@ async def set_quest_definition_active(
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(database, definition_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
+446
View File
@@ -0,0 +1,446 @@
"""Tests for completion.py: the Feature 08 append-only business layer."""
from __future__ import annotations
import asyncio
import datetime
import inspect
import re
import pytest
from custom_components.nestquest.completion import append_event, instance_state
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.dao_rules import (
QuestDefinitionsDao,
ScheduleRulesDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
from unittest.mock import AsyncMock, MagicMock
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
def _today_iso() -> str:
return datetime.date.today().isoformat()
def _now_stamp() -> str:
return f"{_today_iso()}T08:00:00+00:00"
def _future_day(offset: int) -> str:
return (
datetime.date.today() + datetime.timedelta(days=offset)
).isoformat()
D1 = _future_day(1)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
rules = ScheduleRulesDao(database)
definitions = QuestDefinitionsDao(database)
instances = QuestInstancesDao(database)
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning"
)
return database, child, instance
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
prepared = await _prepare(tmp_path / name)
try:
return await body(*prepared)
finally:
await prepared[0].close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# inverted actor shapes: field-naming ValueErrors, no write
# ---------------------------------------------------------------------------
def test_user_without_actor_user_id_rejected() -> None:
async def _body():
with pytest.raises(ValueError, match="actor_user_id"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="user",
was_on_time=True,
)
with pytest.raises(ValueError, match="actor_user_id"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="user",
actor_user_id=None,
was_on_time=True,
)
with pytest.raises(ValueError, match="actor_user_id"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="",
was_on_time=True,
)
_run(_body())
def test_user_must_not_carry_actor_child_id() -> None:
async def _body():
with pytest.raises(ValueError, match="actor_child_id"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
actor_child_id=1,
was_on_time=True,
)
_run(_body())
def test_panel_must_not_carry_actor_user_id() -> None:
async def _body():
with pytest.raises(ValueError, match="actor_user_id"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="panel",
actor_user_id="user-1",
actor_child_id=1,
was_on_time=True,
)
_run(_body())
def test_panel_requires_actor_child_id() -> None:
async def _body():
with pytest.raises(ValueError, match="actor_child_id"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="panel",
was_on_time=True,
)
_run(_body())
def test_actor_source_service_rejected() -> None:
async def _body():
with pytest.raises(ValueError, match="actor_source"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="service",
actor_user_id="user-1",
was_on_time=True,
)
_run(_body())
def test_completed_requires_was_on_time() -> None:
async def _body():
with pytest.raises(ValueError, match="was_on_time"):
await append_event(
object(),
1,
1,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
)
_run(_body())
# ---------------------------------------------------------------------------
# happy paths
# ---------------------------------------------------------------------------
def test_user_append_round_trips(tmp_path) -> None:
async def _body(database, child, instance):
event = await append_event(
database,
instance.id,
child.id,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
was_on_time=True,
)
assert isinstance(event, CompletionEventRecord)
assert event.event_type == EVENT_COMPLETED
assert event.actor_source == "user"
assert event.actor_user_id == "user-1"
assert event.actor_child_id is None
assert event.was_on_time is True
assert re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", event.occurred_at
)
return event
_with_db(tmp_path, "user-append.db")(_body)
def test_panel_append_round_trips(tmp_path) -> None:
async def _body(database, child, instance):
event = await append_event(
database,
instance.id,
child.id,
EVENT_COMPLETED,
actor_source="panel",
actor_child_id=child.id,
was_on_time=False,
)
assert event.actor_source == "panel"
assert event.actor_user_id is None
assert event.actor_child_id == child.id
assert event.was_on_time is False
assert re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", event.occurred_at
)
return event
_with_db(tmp_path, "panel-append.db")(_body)
def test_uncompleted_allows_null_was_on_time(tmp_path) -> None:
async def _body(database, child, instance):
event = await append_event(
database,
instance.id,
child.id,
EVENT_UNCOMPLETED,
actor_source="user",
actor_user_id="user-1",
was_on_time=None,
)
assert event.event_type == EVENT_UNCOMPLETED
assert event.was_on_time is None
return event
_with_db(tmp_path, "uncompleted-null.db")(_body)
def test_now_pins_occurred_at_utc(tmp_path) -> None:
async def _body(database, child, instance):
pinned = datetime.datetime(
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
)
event = await append_event(
database,
instance.id,
child.id,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
was_on_time=True,
now=pinned,
)
assert event.occurred_at == "2026-09-18T08:00:00+00:00"
return event
_with_db(tmp_path, "pinned-now.db")(_body)
# ---------------------------------------------------------------------------
# append-only: the module never exposes update/delete
# ---------------------------------------------------------------------------
def test_module_never_exposes_update_or_delete() -> None:
import custom_components.nestquest.completion as completion
public = {
name
for name, _ in inspect.getmembers(completion, inspect.isfunction)
if not name.startswith("_")
}
forbidden = {
name
for name in public
if name.startswith(
(
"update",
"delete",
"set",
"clear",
"remove",
"modify",
"edit",
"upsert",
)
)
}
assert forbidden == set(), (
f"completion.py exposed mutation methods: {forbidden}"
)
assert "append_event" in public
assert "instance_state" in public
assert "complete_instance" not in public
assert "uncomplete_instance" not in public
source = inspect.getsource(completion)
assert "UPDATE" not in source
assert "DELETE" not in source
assert "INSERT" not in source
# ---------------------------------------------------------------------------
# instance_state: derived from the latest event
# ---------------------------------------------------------------------------
def test_instance_state_open_when_no_events(tmp_path) -> None:
async def _body(database, child, instance):
assert await instance_state(database, instance.id) == "open"
_with_db(tmp_path, "state-none.db")(_body)
def test_instance_state_open_when_latest_uncompleted(tmp_path) -> None:
async def _body(database, child, instance):
await append_event(
database,
instance.id,
child.id,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
was_on_time=True,
)
await append_event(
database,
instance.id,
child.id,
EVENT_UNCOMPLETED,
actor_source="user",
actor_user_id="user-1",
)
assert await instance_state(database, instance.id) == "open"
_with_db(tmp_path, "state-uncompleted.db")(_body)
def test_instance_state_unknown_instance_id_raises(tmp_path) -> None:
async def _body(database, child, instance):
with pytest.raises(ValueError, match="instance_id"):
await instance_state(database, instance.id + 999)
_with_db(tmp_path, "state-unknown.db")(_body)
def test_instance_state_rejects_non_integer_id() -> None:
async def _body():
with pytest.raises(ValueError, match="instance_id"):
await instance_state(object(), "1")
with pytest.raises(ValueError, match="instance_id"):
await instance_state(object(), True)
_run(_body())
def test_instance_state_three_events_returns_done_and_preserves_rows(
tmp_path,
) -> None:
async def _body(database, child, instance):
base = datetime.datetime(
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
)
await append_event(
database,
instance.id,
child.id,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
was_on_time=True,
now=base,
)
await append_event(
database,
instance.id,
child.id,
EVENT_UNCOMPLETED,
actor_source="user",
actor_user_id="user-1",
now=base + datetime.timedelta(seconds=1),
)
await append_event(
database,
instance.id,
child.id,
EVENT_COMPLETED,
actor_source="user",
actor_user_id="user-1",
was_on_time=False,
now=base + datetime.timedelta(seconds=2),
)
events = CompletionEventsDao(database)
before = await events.list_by_instance(instance.id)
assert [e.event_type for e in before] == [
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
EVENT_COMPLETED,
]
assert await instance_state(database, instance.id) == "done"
after = await events.list_by_instance(instance.id)
assert after == before
assert len(after) == 3
_with_db(tmp_path, "state-three.db")(_body)
+125
View File
@@ -10,6 +10,7 @@ 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 (
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
SERVICE_REGENERATE,
DOMAIN as DOMAIN_CONST,
@@ -351,6 +352,39 @@ async def test_day_rollover_listener_materializes_ha_local_horizon(
)
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
"""The daily run materializes only the configured horizon window."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
# A definition created AFTER setup has no instances yet (create does not
# materialize); firing the daily listener must generate only the horizon.
child_id = await _seed_daily_child_and_definition(
database, _local_today(hass)
)
await hass.time_change.fire()
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child_id, beyond, beyond_end
) == []
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
@@ -447,6 +481,63 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
"""Setup backfills only the configured (non-default) horizon window."""
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
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(options={CONF_HORIZON_DAYS: 5}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, today.isoformat(), end)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, beyond, beyond_end) == []
def test_configured_horizon_days_validates_and_falls_back() -> None:
"""The configured horizon is validated; a malformed value uses the default."""
import custom_components.nestquest as nestquest
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
== DEFAULT_HORIZON_DAYS
)
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
== DEFAULT_HORIZON_DAYS
)
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
== DEFAULT_HORIZON_DAYS
)
async def test_regenerate_service_registered_and_materializes(
hass, make_entry
) -> None:
@@ -474,6 +565,40 @@ async def test_regenerate_service_registered_and_materializes(
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_regenerate_service_uses_configured_horizon_days(
hass, make_entry
) -> None:
"""``nestquest.regenerate`` regenerates only the configured horizon."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
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=5)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child_id, beyond, beyond_end
) == []
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)
+594 -16
View File
@@ -7,6 +7,7 @@ import datetime
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
@@ -544,7 +545,7 @@ def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
_with_db(tmp_path, "materialize-presence-snapshot.db")(_body)
def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None:
def test_materialize_skips_stale_due_time_mid_walk(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
@@ -565,21 +566,64 @@ def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None:
)
# The window's due_time is edited to 10:15 between the input
# snapshot and the insert. The walk must store the SNAPSHOT's
# 09:00, not the live 10:15 — the batch is governed entirely by
# the coherent snapshot it read.
# snapshot and the insert. The walk's snapshotted 09:00 no
# longer matches the live 10:15, so the tuple is SKIPPED rather
# than writing a due_time that has already been superseded.
count = await _materialize_with_change(
database, start_iso, end_iso, _edit_due_time
)
assert count == 3
assert count == 0
assert await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
) == []
return count
_with_db(tmp_path, "materialize-stale-due-time.db")(_body)
def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
end_iso = (today + datetime.timedelta(days=2)).isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
async def _edit_and_regenerate():
# A window-time edit + regeneration land after the stale walk
# read its input snapshot: the window moves to 10:15 and the
# regeneration upserts the newer due_time across the horizon.
await QuestDefinitionsDao(database).upsert_window(
created.definition.id, "morning", due_time="10:15"
)
await regenerate_for_definition(database, created.definition.id)
# The stale walk (snapshot due_time 09:00) then resumes. It must
# SKIP these tuples — its snapshotted 09:00 no longer matches the
# live 10:15 — so the regeneration's newer due_time survives and
# is NOT overwritten by the stale value.
count = await _materialize_with_change(
database, start_iso, end_iso, _edit_and_regenerate
)
assert count == 0
records = await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
)
assert len(records) == 3
assert {r.due_time for r in records} == {"09:00"}
assert {r.due_time for r in records} == {"10:15"}
return records
_with_db(tmp_path, "materialize-snapshot-due-time.db")(_body)
_with_db(tmp_path, "materialize-regenerated-due-time.db")(_body)
def test_materialize_honours_overrides(tmp_path) -> None:
@@ -631,13 +675,14 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
"""The no-past guard compares against a caller-supplied HA-local today.
"""The clamp and no-past guard compare 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.
``date.today()``. The walk clamps its start to that anchor: without
a pinned HA-local date the effective start is the host's today (one
day after ha_today), so the now-past day is skipped rather than
rejected, and only the host-today-onward days materialize.
"""
async def _body(database):
children = ChildrenDao(database)
@@ -653,12 +698,13 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
["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)
# Without a pinned HA-local today, the walk clamps its start to the
# host's today (one day after ha_today); the past day is dropped and
# the 14 host-today-onward days materialize.
without_pin = await materialize(database, start_iso, end_iso)
assert without_pin == 14
# With today=ha_today, the same range is accepted and materialized.
# With today=ha_today, the full 15-day horizon materializes.
count = await materialize(database, start_iso, end_iso, today=ha_today)
assert count == 15
return count
@@ -666,6 +712,50 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
_with_db(tmp_path, "materialize-ha-local-today.db")(_body)
def test_materialize_clamps_past_start_to_today(tmp_path) -> None:
"""A past start_date is clamped to today: no past-dated instance is created.
A caller passing a start one month in the past never yields a
past-dated instance: the walk clamps its effective start up to today
and materializes only dates on/after today, leaving the past untouched
rather than raising or corrupting state.
"""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = (today - datetime.timedelta(days=30)).isoformat()
end_iso = (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"],
)
count = await materialize(database, start_iso, end_iso, today=today)
assert count == 15
dao = QuestInstancesDao(database)
records = await dao.list_by_date_range(child.id, start_iso, end_iso)
assert len(records) == 15
assert all(r.due_date >= today.isoformat() for r in records)
# The past month is empty: no past-dated instance was ever created.
past_records = await dao.list_by_date_range(
child.id,
start_iso,
(today - datetime.timedelta(days=1)).isoformat(),
)
assert past_records == []
return count
_with_db(tmp_path, "materialize-past-start.db")(_body)
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.quest_definitions import (
@@ -1067,6 +1157,494 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
def test_regenerate_for_definition_respects_horizon_days(tmp_path) -> None:
"""A non-default horizon_days sizes the definition's rebuild window."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
# ``create`` does not materialize; regeneration over a 5-day horizon
# must build exactly today..today+5 (six daily rows).
await regenerate_for_definition(
database, created.definition.id, horizon_days=5
)
dao = QuestInstancesDao(database)
horizon_end = today + datetime.timedelta(days=5)
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == 6
# The window is BOUNDED: nothing must exist at day 6 or later (a
# hard-coded 14-day default would still emit rows there).
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await dao.list_by_date_range(child.id, beyond, beyond_end) == []
return None
_with_db(tmp_path, "regenerate-def-horizon.db")(_body)
def test_regenerate_for_child_respects_horizon_days(tmp_path) -> None:
"""A non-default horizon_days sizes the child's rebuild window."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
await regenerate_for_child(database, child.id, horizon_days=5)
dao = QuestInstancesDao(database)
horizon_end = today + datetime.timedelta(days=5)
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == 6
# Bounded window: nothing must exist at day 6 or later.
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await dao.list_by_date_range(child.id, beyond, beyond_end) == []
return None
_with_db(tmp_path, "regenerate-child-horizon.db")(_body)
@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5])
def test_regenerate_for_definition_rejects_invalid_horizon_before_delete(
tmp_path, bad
) -> None:
"""A malformed horizon fails BEFORE any future-instance delete."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
await materialize(database, start_iso, horizon_end.isoformat())
with pytest.raises(ValueError, match="horizon_days"):
await regenerate_for_definition(
database, created.definition.id, horizon_days=bad
)
# The refused call deleted nothing: the seeded horizon survives.
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
return None
_with_db(tmp_path, "regenerate-def-horizon-invalid.db")(_body)
@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5])
def test_regenerate_for_child_rejects_invalid_horizon_before_delete(
tmp_path, bad
) -> None:
"""A malformed child horizon fails BEFORE any future-instance delete."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
await materialize(database, start_iso, horizon_end.isoformat())
with pytest.raises(ValueError, match="horizon_days"):
await regenerate_for_child(database, child.id, horizon_days=bad)
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
return None
_with_db(tmp_path, "regenerate-child-horizon-invalid.db")(_body)
#: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month,
#: so the two-week custody cycle is week-aligned and the yearly rule's
#: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window
#: (pin ``today=anchor`` throughout so the walk never clamps this fixed
#: calendar date up to the host clock).
_E2E_ANCHOR = "2026-06-01"
#: Hand-checked oracle over 2026-06-01 .. 2026-06-28 (69 entries). Each row
#: is one independent (definition, child, window, due_date, due_time); the
#: definition/child slugs are substituted with runtime ids, but the dates are
#: literal, reviewed by hand — no recurrence/presence math is re-run here.
#: Custody (2-week cycle anchored on the Monday): A present weeks 0 and 2,
#: B present weeks 1 and 3, C present every day.
_E2E_EXPECTED = [
# daily [A, C] morning 09:00, every day. A present weeks 0, 2.
("daily", "A", "morning", "2026-06-01", "09:00"),
("daily", "A", "morning", "2026-06-02", "09:00"),
("daily", "A", "morning", "2026-06-03", "09:00"),
("daily", "A", "morning", "2026-06-04", "09:00"),
("daily", "A", "morning", "2026-06-05", "09:00"),
("daily", "A", "morning", "2026-06-06", "09:00"),
("daily", "A", "morning", "2026-06-07", "09:00"),
("daily", "A", "morning", "2026-06-15", "09:00"),
("daily", "A", "morning", "2026-06-16", "09:00"),
("daily", "A", "morning", "2026-06-17", "09:00"),
("daily", "A", "morning", "2026-06-18", "09:00"),
("daily", "A", "morning", "2026-06-19", "09:00"),
("daily", "A", "morning", "2026-06-20", "09:00"),
("daily", "A", "morning", "2026-06-21", "09:00"),
# daily C is present every day: all 28 days.
("daily", "C", "morning", "2026-06-01", "09:00"),
("daily", "C", "morning", "2026-06-02", "09:00"),
("daily", "C", "morning", "2026-06-03", "09:00"),
("daily", "C", "morning", "2026-06-04", "09:00"),
("daily", "C", "morning", "2026-06-05", "09:00"),
("daily", "C", "morning", "2026-06-06", "09:00"),
("daily", "C", "morning", "2026-06-07", "09:00"),
("daily", "C", "morning", "2026-06-08", "09:00"),
("daily", "C", "morning", "2026-06-09", "09:00"),
("daily", "C", "morning", "2026-06-10", "09:00"),
("daily", "C", "morning", "2026-06-11", "09:00"),
("daily", "C", "morning", "2026-06-12", "09:00"),
("daily", "C", "morning", "2026-06-13", "09:00"),
("daily", "C", "morning", "2026-06-14", "09:00"),
("daily", "C", "morning", "2026-06-15", "09:00"),
("daily", "C", "morning", "2026-06-16", "09:00"),
("daily", "C", "morning", "2026-06-17", "09:00"),
("daily", "C", "morning", "2026-06-18", "09:00"),
("daily", "C", "morning", "2026-06-19", "09:00"),
("daily", "C", "morning", "2026-06-20", "09:00"),
("daily", "C", "morning", "2026-06-21", "09:00"),
("daily", "C", "morning", "2026-06-22", "09:00"),
("daily", "C", "morning", "2026-06-23", "09:00"),
("daily", "C", "morning", "2026-06-24", "09:00"),
("daily", "C", "morning", "2026-06-25", "09:00"),
("daily", "C", "morning", "2026-06-26", "09:00"),
("daily", "C", "morning", "2026-06-27", "09:00"),
("daily", "C", "morning", "2026-06-28", "09:00"),
# weekly [B, C] Mondays, morning 07:00 + evening 19:00.
# B present weeks 1, 3 -> Mondays 06-08 and 06-22.
("weekly", "B", "morning", "2026-06-08", "07:00"),
("weekly", "B", "evening", "2026-06-08", "19:00"),
("weekly", "B", "morning", "2026-06-22", "07:00"),
("weekly", "B", "evening", "2026-06-22", "19:00"),
# C present every Monday: 06-01, 06-08, 06-15, 06-22.
("weekly", "C", "morning", "2026-06-01", "07:00"),
("weekly", "C", "evening", "2026-06-01", "19:00"),
("weekly", "C", "morning", "2026-06-08", "07:00"),
("weekly", "C", "evening", "2026-06-08", "19:00"),
("weekly", "C", "morning", "2026-06-15", "07:00"),
("weekly", "C", "evening", "2026-06-15", "19:00"),
("weekly", "C", "morning", "2026-06-22", "07:00"),
("weekly", "C", "evening", "2026-06-22", "19:00"),
# month_day [C] the 15th, morning 08:00. 2026-06-15 is the 15th.
("month_day", "C", "morning", "2026-06-15", "08:00"),
# month_wd [C] 2nd Wednesday, morning 07:30. June 2026's 2nd
# Wednesday is 06-10 (the 1st is 06-03).
("month_wd", "C", "morning", "2026-06-10", "07:30"),
# yearly [C] 1st of June, afternoon 12:00. Fires on 06-01.
("yearly", "C", "afternoon", "2026-06-01", "12:00"),
# custom [A, C] Saturdays + Sundays, evening 20:00.
# A present weeks 0, 2 -> 06-06, 06-07, 06-20, 06-21.
("custom", "A", "evening", "2026-06-06", "20:00"),
("custom", "A", "evening", "2026-06-07", "20:00"),
("custom", "A", "evening", "2026-06-20", "20:00"),
("custom", "A", "evening", "2026-06-21", "20:00"),
# C present every weekend day.
("custom", "C", "evening", "2026-06-06", "20:00"),
("custom", "C", "evening", "2026-06-07", "20:00"),
("custom", "C", "evening", "2026-06-13", "20:00"),
("custom", "C", "evening", "2026-06-14", "20:00"),
("custom", "C", "evening", "2026-06-20", "20:00"),
("custom", "C", "evening", "2026-06-21", "20:00"),
("custom", "C", "evening", "2026-06-27", "20:00"),
("custom", "C", "evening", "2026-06-28", "20:00"),
]
# The tuples that must disappear when Cleo (C) is overridden to ABSENT for
# 2026-06-04 .. 2026-06-06 (a mid-window stretch): her two daily chores and
# the Saturday-evening custom chore on 06-06.
_E2E_REMOVED = [
("daily", "C", "morning", "2026-06-04", "09:00"),
("daily", "C", "morning", "2026-06-05", "09:00"),
("daily", "C", "morning", "2026-06-06", "09:00"),
("custom", "C", "evening", "2026-06-06", "20:00"),
]
def _resolve_e2e(raw, def_ids, child_ids) -> set:
"""Substitute the oracle's definition/child slugs with runtime ids."""
return {
(def_ids[slug], child_ids[child], window, due_date, due_time)
for slug, child, window, due_date, due_time in raw
}
async def _collect_keyed(database, child_ids, start, end) -> dict:
"""Return {(definition_id, child_id, window, due_date): (id, due_time)}.
The stable parts of an instance — its widened key, its physical id and
its due_time snapshot — with the refreshable ``generated_at`` stamp
deliberately excluded. Comparing two of these captures the real
re-run contract: no rows added/removed, ids stable (upsert refreshes
in place, never delete + reinsert), and due_time untouched.
"""
dao = QuestInstancesDao(database)
keyed = {}
for child_id in child_ids:
for record in await dao.list_by_date_range(child_id, start, end):
key = (
record.definition_id,
record.child_id,
record.window,
record.due_date,
)
keyed[key] = (record.id, record.due_time)
return keyed
async def _collect_records(database, child_ids, start, end) -> set:
"""Return the full 7-field instance records over the children/range.
Includes the ``generated_at`` stamp, so this is only used for children
that should NEVER be written (Ada/Bo during Cleo's override): their
records are truly byte-for-byte untouched, unlike the idempotency
re-run where the stamp legitimately refreshes.
"""
dao = QuestInstancesDao(database)
records = set()
for child_id in child_ids:
for record in await dao.list_by_date_range(child_id, start, end):
records.add(
(
record.id,
record.definition_id,
record.child_id,
record.window,
record.due_date,
record.due_time,
record.generated_at,
)
)
return records
def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
import custom_components.nestquest.materialize as materialize_module
# Give each materialize run a DISTINCT batch stamp: production upserts
# rewrite generated_at on every re-run, so a shared pinned stamp would
# merely hide that rewrite. The idempotency assertion below therefore
# ignores generated_at and checks the stable columns only.
stamps = {"n": 0}
def _distinct_stamp() -> str:
stamps["n"] += 1
return f"2026-06-01T00:00:{stamps['n']:02d}+00:00"
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
async def _body(database):
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
from custom_components.nestquest.presence import PresenceSchedule
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
overrides = PresenceOverridesDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2
b = await children.create("Bo", NOW) # present weeks 1, 3
c = await children.create("Cleo", NOW) # no schedule: always present
anchor = datetime.date.fromisoformat(_E2E_ANCHOR)
start_iso = anchor.isoformat()
end_iso = (anchor + datetime.timedelta(days=27)).isoformat()
# Two-week custody with OPPOSITE weeks, encoded through the
# PresenceSchedule model on the fixed Monday anchor.
all_week = frozenset(range(7))
a_schedule = PresenceSchedule(a.id, 2, anchor, {0: all_week, 1: frozenset()})
b_schedule = PresenceSchedule(b.id, 2, anchor, {0: frozenset(), 1: all_week})
await schedules.upsert_by_child(a.id, 2, start_iso, a_schedule.encode())
await schedules.upsert_by_child(b.id, 2, start_iso, b_schedule.encode())
def_ids = {}
def_ids["daily"] = (await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id, c.id],
[("morning", "09:00")],
)).definition.id
def_ids["weekly"] = (await create_quest_definition(
database,
"Weekly Monday chore",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0},
start_date=start_iso,
),
[b.id, c.id],
[("morning", "07:00"), ("evening", "19:00")],
)).definition.id
def_ids["month_day"] = (await create_quest_definition(
database,
"Monthly 15th chore",
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY,
day_of_month=15,
start_date=start_iso,
),
[c.id],
[("morning", "08:00")],
)).definition.id
def_ids["month_wd"] = (await create_quest_definition(
database,
"Monthly 2nd-Wednesday chore",
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=2,
nth_weekday_weekday=2,
start_date=start_iso,
),
[c.id],
[("morning", "07:30")],
)).definition.id
def_ids["yearly"] = (await create_quest_definition(
database,
"Yearly first-of-June chore",
ScheduleRule(
rule_type=RuleType.YEARLY,
month=anchor.month,
day_of_month=1,
start_date=start_iso,
),
[c.id],
[("afternoon", "12:00")],
)).definition.id
def_ids["custom"] = (await create_quest_definition(
database,
"Weekend chore",
ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS,
weekday_set={5, 6},
start_date=start_iso,
),
[a.id, c.id],
[("evening", "20:00")],
)).definition.id
child_ids = {"A": a.id, "B": b.id, "C": c.id}
expected = _resolve_e2e(_E2E_EXPECTED, def_ids, child_ids)
removed = _resolve_e2e(_E2E_REMOVED, def_ids, child_ids)
all_children = (a.id, b.id, c.id)
# First run: every (definition, child, window, date) tuple, exactly.
first = await materialize(database, start_iso, end_iso, today=anchor)
assert first == len(expected)
assert await _collect_instances(
database, all_children, start_iso, end_iso
) == expected
# Re-run contract: the upsert refreshes each existing row IN PLACE,
# so the widened key set, the physical ids and each due_time are all
# stable (only generated_at may refresh to the new run's stamp).
first_keyed = await _collect_keyed(
database, all_children, start_iso, end_iso
)
second = await materialize(database, start_iso, end_iso, today=anchor)
assert second == len(expected)
assert await _collect_keyed(
database, all_children, start_iso, end_iso
) == first_keyed
# Capture Ada and Bo's complete records BEFORE the override, so we
# can prove they are untouched afterward.
a_before = await _collect_records(database, (a.id,), start_iso, end_iso)
b_before = await _collect_records(database, (b.id,), start_iso, end_iso)
# Cleo is ABSENT 2026-06-04 .. 2026-06-06 (mid-window).
await overrides.create(
c.id, "2026-06-04", "2026-06-06", False, note="away"
)
# regenerate_for_child deletes + rebuilds CLEO ONLY (Ada and Bo are
# never touched). It rebuilds a 14-day rolling horizon though, so
# the child-scoped materialize below restores the remainder of the
# 28-day window without ever writing another child.
await regenerate_for_child(database, c.id, today=anchor)
await materialize(
database, start_iso, end_iso, child_ids=[c.id], today=anchor
)
# Ada and Bo are byte-for-byte untouched: id, snapshot columns and
# generated_at alike.
assert await _collect_records(
database, (a.id,), start_iso, end_iso
) == a_before
assert await _collect_records(
database, (b.id,), start_iso, end_iso
) == b_before
# Exactly Cleo's override-covered tuples vanish; every other tuple
# survives.
remaining = await _collect_instances(
database, all_children, start_iso, end_iso
)
assert remaining == expected - removed
assert remaining.isdisjoint(removed)
return first
_with_db(tmp_path, "materialize-e2e.db")(_body)
def test_regenerate_for_child_rejects_non_int_child_id(tmp_path) -> None:
async def _body(database):
for bad in (True, False, 1.0, "1"):
+69
View File
@@ -8,6 +8,7 @@ import re
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
@@ -1938,3 +1939,71 @@ def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None:
return reactivated
_with_db(tmp_path, "full-lifecycle.db")(_body)
@pytest.mark.parametrize(
"mode",
["edit", "assign", "unassign", "set_active"],
)
def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
"""The four change wrappers forward a non-default horizon_days.
Each wrapper threads ``horizon_days`` into the regeneration it triggers;
passing 5 here must bound the regenerated window to today..today+5 —
proving the forwarding argument is actually honored, not dropped.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=_today_iso()),
[child.id],
["morning"],
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
if mode == "edit":
await edit_quest_definition(
database,
definition_id,
windows=[("morning", "10:30")],
horizon_days=5,
)
tracked = child
elif mode == "assign":
bo = await children.create("Bo", NOW)
await assign_child(database, definition_id, bo.id, horizon_days=5)
tracked = bo
elif mode == "unassign":
# The remaining assignee (``child``) must be regenerated over
# the 5-day window after ``bo`` is removed.
bo = await children.create("Bo", NOW)
await assign_child(database, definition_id, bo.id)
await unassign_child(
database, definition_id, bo.id, horizon_days=5
)
tracked = child
else: # set_active
await set_quest_definition_active(
database, definition_id, True, horizon_days=5
)
tracked = child
today = datetime.date.today()
end = (today + datetime.timedelta(days=5)).isoformat()
records = await instances.list_by_date_range(
tracked.id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await instances.list_by_date_range(
tracked.id, beyond, beyond_end
) == []
return None
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
+370
View File
@@ -0,0 +1,370 @@
"""Timezone tests for the single HA-local date source in the generation path.
These pin the done-condition that every "today" read in the generation path
derives from ``hass.config.time_zone`` — not the host clock and not UTC —
and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the
no-past guard resolve the correct local dates around midnight and across the
daylight-saving transitions.
"""
from __future__ import annotations
import asyncio
import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_children import ChildrenDao
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.quest_definitions import (
assign_child,
create_quest_definition,
edit_quest_definition,
)
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
NOW = "2026-09-14T12:00:00+00:00"
NEW_YORK = "America/New_York"
def _make_hass_mock():
from unittest.mock import AsyncMock, MagicMock
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
return database
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _freeze_clock(
monkeypatch, instant: datetime.datetime
) -> tuple[datetime.datetime, list]:
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
the generation path's "today" reads this instant (localized to whatever
time zone it asks for) while every other module and the test harness keep
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
objects the code under test passed to ``now`` — so a test can prove the
generation path actually requested the configured HA time zone rather
than silently reading UTC.
"""
import custom_components.nestquest as nestquest
frozen = instant.astimezone(datetime.timezone.utc)
requested: list = []
class _FrozenDatetime(datetime.datetime):
@classmethod
def now(cls, tz=None):
requested.append(tz)
if tz is None:
return frozen.replace(tzinfo=None)
return frozen.astimezone(tz)
fake_module = SimpleNamespace(
datetime=_FrozenDatetime,
timedelta=datetime.timedelta,
date=datetime.date,
)
monkeypatch.setattr(nestquest, "datetime", fake_module)
return frozen, requested
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
"""Assert ``now`` was called with (only) the configured HA time zone.
This is what turns the DST tests into a real proof of the HA-local date
source: if the generation path regressed to ``now(timezone.utc)``, its
requested tz would be UTC and this fails.
"""
assert requested, "the generation path never called now(tz)"
assert all(tz == ZoneInfo(time_zone) for tz in requested)
def _tz_hass(time_zone: str):
"""A minimal hass exposing only the ``config.time_zone`` the walk reads."""
return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone))
async def _seed_daily(database, start_iso: str) -> int:
"""One always-present child + a daily definition starting ``start_iso``."""
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
return child.id
def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date:
return instant_utc.astimezone(ZoneInfo(time_zone)).date()
def _assert_contiguous_horizon(records, local_today: datetime.date) -> None:
"""The horizon is exactly today..today+horizon, no off-by-one, no gap."""
expected = {
(local_today + datetime.timedelta(days=offset)).isoformat()
for offset in range(DEFAULT_HORIZON_DAYS + 1)
}
assert {r.due_date for r in records} == expected
assert len(records) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_uses_newyork_local_date_after_local_midnight(
tmp_path, monkeypatch
) -> None:
"""A run just after New York local midnight uses the local date.
The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so
04:05 UTC). The horizon must start on 2026-03-10 — the local calendar
date — not any host/UTC-derived value.
"""
instant = datetime.datetime(
2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK)
)
local_today = instant.date()
async def _body(database):
hass = _tz_hass(NEW_YORK)
_frozen, requested = _freeze_clock(monkeypatch, instant)
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
_assert_requested_ha_timezone(requested, NEW_YORK)
return len(records)
async def _main():
database = await _prepare(tmp_path / "tz-midnight.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_uses_local_date_not_utc_when_host_ahead(
tmp_path, monkeypatch
) -> None:
"""The walk's "today" is HA-local, never the UTC/system calendar date.
FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but
New York local is still 2026-01-14 22:00 EST. The horizon must start on
2026-01-14 (local), proving the UTC date did not win.
"""
instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc)
local_today = _local_date(instant, NEW_YORK)
assert local_today == datetime.date(2026, 1, 14)
assert instant.date() == datetime.date(2026, 1, 15)
async def _body(database):
hass = _tz_hass(NEW_YORK)
_frozen, requested = _freeze_clock(monkeypatch, instant)
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
_assert_requested_ha_timezone(requested, NEW_YORK)
return len(records)
async def _main():
database = await _prepare(tmp_path / "tz-host-ahead.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_resolves_spring_forward_day_without_off_by_one(
tmp_path, monkeypatch
) -> None:
"""Across the 2026-03-08 spring-forward, today and the horizon stay local.
Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to
2026-03-08 local — the lost hour must not skip or repeat a calendar day —
and the full horizon remains 15 contiguous days.
"""
zone = ZoneInfo(NEW_YORK)
before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST
after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT
assert before.date() == after.date() == datetime.date(2026, 3, 8)
async def _body(database):
hass = _tz_hass(NEW_YORK)
import custom_components.nestquest as nestquest
requested_batches: list[list] = []
for instant in (before, after):
_frozen, requested = _freeze_clock(monkeypatch, instant)
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
requested_all = [tz for batch in requested_batches for tz in batch]
_assert_requested_ha_timezone(requested_all, NEW_YORK)
return local_today
async def _main():
database = await _prepare(tmp_path / "tz-spring-forward.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == datetime.date(2026, 3, 8)
def test_horizon_resolves_fall_back_day_without_off_by_one(
tmp_path, monkeypatch
) -> None:
"""Across the 2026-11-01 fall-back, today and the horizon stay local.
The repeated 01:00-02:00 hour must not resolve to the wrong calendar
day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30
(``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01
— and both, along with 03:30 EST after the jump, materialize the same
15 contiguous-day horizon with no off-by-one.
"""
zone = ZoneInfo(NEW_YORK)
first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT
second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST
after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST
assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1)
assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds
async def _body(database):
hass = _tz_hass(NEW_YORK)
import custom_components.nestquest as nestquest
requested_batches: list[list] = []
for instant in (first, second, after):
_frozen, requested = _freeze_clock(monkeypatch, instant)
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
requested_all = [tz for batch in requested_batches for tz in batch]
_assert_requested_ha_timezone(requested_all, NEW_YORK)
return local_today
async def _main():
database = await _prepare(tmp_path / "tz-fall-back.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == datetime.date(2026, 11, 1)
def test_definition_business_regeneration_uses_pinned_today_not_host(
tmp_path, monkeypatch
) -> None:
"""``edit_quest_definition``/``assign_child`` anchor regeneration on
the caller-pinned HA-local ``today``, not the host clock.
The host clock (the ``_today`` fallback) is pinned one day AHEAD of the
HA-local ``today``; the guard would reject the local "today" as past
(and the horizon would skip it) if it consulted the host clock. Both
business operations must regenerate against the pinned date and keep
that first local day, proving the threaded ``today`` wins.
"""
import custom_components.nestquest.dao_instances as dao_instances_module
pinned = datetime.date(2026, 1, 14)
host_today = datetime.date(2026, 1, 15)
monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today)
async def _body(database):
children = ChildrenDao(database)
dao = QuestInstancesDao(database)
horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
a = await children.create("Ada", NOW)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()),
[a.id],
[("morning", "09:00")],
)
definition_id = created.definition.id
# edit anchors on pinned: the horizon starts at the local day the
# host clock calls "past", so its presence proves pinned won.
await edit_quest_definition(
database,
definition_id,
windows=[("morning", "10:15")],
today=pinned,
)
a_records = await dao.list_by_date_range(
a.id, pinned.isoformat(), horizon_end.isoformat()
)
assert [r.due_date for r in a_records][0] == pinned.isoformat()
assert {r.due_time for r in a_records} == {"10:15"}
assert len(a_records) == DEFAULT_HORIZON_DAYS + 1
# assign anchors on pinned too: the new child's regeneration keeps
# the same pinned-local first day rather than skipping to the host day.
b = await children.create("Bo", NOW)
await assign_child(database, definition_id, b.id, today=pinned)
b_records = await dao.list_by_date_range(
b.id, pinned.isoformat(), horizon_end.isoformat()
)
assert [r.due_date for r in b_records][0] == pinned.isoformat()
assert len(b_records) == DEFAULT_HORIZON_DAYS + 1
return definition_id
async def _main():
database = await _prepare(tmp_path / "tz-definition-regen.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) is not None