Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c3d21742 | ||
|
|
6024eac529 | ||
|
|
4d47a24a3d |
@@ -19,7 +19,6 @@ from homeassistant.helpers.event import async_track_time_change
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -274,28 +273,10 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
|||||||
return int(hour), int(minute)
|
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(
|
async def _run_horizon_materialization(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant, database: NestQuestDatabase
|
||||||
database: NestQuestDatabase,
|
|
||||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
"""Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||||
|
|
||||||
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
"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.
|
than the system clock, so the horizon tracks the household's own day.
|
||||||
@@ -304,15 +285,13 @@ async def _run_horizon_materialization(
|
|||||||
|
|
||||||
This is the ONE generation path: the startup backfill, the daily rollover
|
This is the ONE generation path: the startup backfill, the daily rollover
|
||||||
listener and the ``regenerate`` service all funnel through it, so there is
|
listener and the ``regenerate`` service all funnel through it, so there is
|
||||||
no duplicated materialization logic. ``horizon_days`` sizes the window
|
no duplicated materialization logic.
|
||||||
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
|
||||||
entry's validated option value through.
|
|
||||||
"""
|
"""
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
today = datetime.datetime.now(time_zone).date()
|
today = datetime.datetime.now(time_zone).date()
|
||||||
start_date = today.isoformat()
|
start_date = today.isoformat()
|
||||||
end_date = (
|
end_date = (
|
||||||
today + datetime.timedelta(days=horizon_days)
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
await _materialize_run(database, start_date, end_date, today=today)
|
await _materialize_run(database, start_date, end_date, today=today)
|
||||||
|
|
||||||
@@ -325,34 +304,31 @@ def _register_day_rollover_listener(
|
|||||||
"""Register the daily materialization listener and return its remover.
|
"""Register the daily materialization listener and return its remover.
|
||||||
|
|
||||||
Fires once per day at the configured local ``day_rollover_time`` and
|
Fires once per day at the configured local ``day_rollover_time`` and
|
||||||
materializes the rolling horizon ``[today, today + horizon_days]``
|
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``
|
||||||
through the shared :func:`_run_horizon_materialization` path, using the
|
through the shared :func:`_run_horizon_materialization` path.
|
||||||
entry's configured (validated) ``horizon_days``.
|
|
||||||
"""
|
"""
|
||||||
hour, minute = _day_rollover_hour_minute(options)
|
hour, minute = _day_rollover_hour_minute(options)
|
||||||
horizon_days = _configured_horizon_days(options)
|
|
||||||
|
|
||||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||||
await _run_horizon_materialization(hass, database, horizon_days)
|
await _run_horizon_materialization(hass, database)
|
||||||
|
|
||||||
return async_track_time_change(
|
return async_track_time_change(
|
||||||
hass, _run_materialization, hour=hour, minute=minute
|
hass, _run_materialization, hour=hour, minute=minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None:
|
||||||
"""Return a live runtime record with a connected database, or None.
|
"""Return a connected database from the active runtime data, or None.
|
||||||
|
|
||||||
The domain-global services (e.g. ``regenerate``) have no database of
|
The domain-global services (e.g. ``regenerate``) have no database of
|
||||||
their own; they resolve whichever config entry's runtime data is
|
their own; they resolve whichever config entry's database is currently
|
||||||
currently live at call time, so none of them can hold a stale handle
|
live at call time, so none of them can hold a stale handle to a closed
|
||||||
to a closed connection after another entry unloads. The record also
|
connection after another entry unloads.
|
||||||
carries that entry's options (e.g. the configured horizon).
|
|
||||||
"""
|
"""
|
||||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None and database.connected:
|
if database is not None and database.connected:
|
||||||
return runtime_data
|
return database
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -373,17 +349,14 @@ def _register_regenerate_service(hass: HomeAssistant) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
async def _regenerate(_call: Any) -> None:
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
database = _find_live_database(hass)
|
||||||
if runtime_data is None:
|
if database is None:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"NestQuest regenerate requested but no config entry has a "
|
"NestQuest regenerate requested but no config entry has a "
|
||||||
"live database; ignoring"
|
"live database; ignoring"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
horizon_days = _configured_horizon_days(runtime_data.options)
|
await _run_horizon_materialization(hass, database)
|
||||||
await _run_horizon_materialization(
|
|
||||||
hass, runtime_data.database, horizon_days
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
||||||
|
|
||||||
@@ -483,12 +456,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
# Backfill any days the daily listener missed while the integration
|
# Backfill any days the daily listener missed while the integration
|
||||||
# was off (or freshly installed): run the one idempotent walk over
|
# was off (or freshly installed): run the one idempotent walk over
|
||||||
# [today, today + horizon_days] once at setup, using the entry's
|
# [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs
|
||||||
# configured horizon. This runs through the SAME path as the daily
|
# through the SAME path as the daily listener and regenerate service.
|
||||||
# listener and regenerate service.
|
await _run_horizon_materialization(hass, database)
|
||||||
await _run_horizon_materialization(
|
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
|
||||||
)
|
|
||||||
_register_regenerate_service(hass)
|
_register_regenerate_service(hass)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
"""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 an
|
|
||||||
instance (no-op if already done) is :func:`complete_instance`;
|
|
||||||
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
|
||||||
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 asyncio
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .dao_children import _CONNECTION_LOCKS, _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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _TaskReentrantLock:
|
|
||||||
"""Shared connection lock the owning task may re-enter."""
|
|
||||||
|
|
||||||
def __init__(self, inner: asyncio.Lock) -> None:
|
|
||||||
self._inner = inner
|
|
||||||
self._task: asyncio.Task | None = None
|
|
||||||
self._depth = 0
|
|
||||||
|
|
||||||
def locked(self) -> bool:
|
|
||||||
return self._inner.locked()
|
|
||||||
|
|
||||||
async def acquire(self) -> bool:
|
|
||||||
task = asyncio.current_task()
|
|
||||||
if self._task is task:
|
|
||||||
self._depth += 1
|
|
||||||
return True
|
|
||||||
await self._inner.acquire()
|
|
||||||
self._task = task
|
|
||||||
self._depth = 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
if self._task is not asyncio.current_task():
|
|
||||||
raise RuntimeError("lock released by a non-owner")
|
|
||||||
self._depth -= 1
|
|
||||||
if self._depth == 0:
|
|
||||||
self._task = None
|
|
||||||
self._inner.release()
|
|
||||||
|
|
||||||
async def __aenter__(self) -> _TaskReentrantLock:
|
|
||||||
await self.acquire()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *exc: object) -> None:
|
|
||||||
self.release()
|
|
||||||
|
|
||||||
|
|
||||||
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
|
||||||
"""Return the shared connection lock, made task-reentrant.
|
|
||||||
|
|
||||||
``CompletionEventsDao.append`` also acquires this lock, so
|
|
||||||
check+append in :func:`complete_instance` and
|
|
||||||
:func:`uncomplete_instance` must re-enter on the same task. The
|
|
||||||
wrapper is stored in the shared dict so the DAO sees the same
|
|
||||||
object.
|
|
||||||
"""
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
key = (id(database), id(loop))
|
|
||||||
lock = _CONNECTION_LOCKS.get(key)
|
|
||||||
if isinstance(lock, _TaskReentrantLock):
|
|
||||||
return lock
|
|
||||||
wrapped = _TaskReentrantLock(
|
|
||||||
lock if lock is not None else asyncio.Lock()
|
|
||||||
)
|
|
||||||
_CONNECTION_LOCKS[key] = wrapped
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def complete_instance(
|
|
||||||
database: NestQuestDatabase,
|
|
||||||
instance_id: int,
|
|
||||||
*,
|
|
||||||
actor_source: str,
|
|
||||||
actor_user_id: str | None = None,
|
|
||||||
actor_child_id: int | None = None,
|
|
||||||
now: datetime.datetime | None = None,
|
|
||||||
today: datetime.date | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Append a completed event unless the instance is already done.
|
|
||||||
|
|
||||||
Returns the derived state ``done``. An already-done instance is a
|
|
||||||
no-op: nothing is appended. Unknown ``instance_id`` raises
|
|
||||||
ValueError naming the field. Existence, latest-event, and the
|
|
||||||
append run under one connection lock so a concurrent complete
|
|
||||||
cannot double-append.
|
|
||||||
|
|
||||||
``was_on_time`` is recorded as True — a placeholder until the
|
|
||||||
on-time task computes it from ``now`` / ``today`` against the
|
|
||||||
instance due_date and optional due_time. ``today`` is accepted so
|
|
||||||
callers can thread the HA-local date through without a signature
|
|
||||||
change; it is unused here.
|
|
||||||
"""
|
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
|
||||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
|
||||||
actor_source, actor_user_id, actor_child_id
|
|
||||||
)
|
|
||||||
async with _reentrant_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 not None and latest.event_type == EVENT_COMPLETED:
|
|
||||||
return "done"
|
|
||||||
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
|
||||||
raise ValueError(
|
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
|
||||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
|
||||||
)
|
|
||||||
await append_event(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
instance.child_id,
|
|
||||||
EVENT_COMPLETED,
|
|
||||||
actor_source=actor_source,
|
|
||||||
actor_user_id=actor_user_id,
|
|
||||||
actor_child_id=actor_child_id,
|
|
||||||
was_on_time=True,
|
|
||||||
now=now,
|
|
||||||
)
|
|
||||||
return "done"
|
|
||||||
|
|
||||||
|
|
||||||
async def uncomplete_instance(
|
|
||||||
database: NestQuestDatabase,
|
|
||||||
instance_id: int,
|
|
||||||
*,
|
|
||||||
actor_source: str,
|
|
||||||
actor_user_id: str | None = None,
|
|
||||||
actor_child_id: int | None = None,
|
|
||||||
now: datetime.datetime | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Append an uncompleted event unless the instance is already open.
|
|
||||||
|
|
||||||
Returns the derived state ``open``. An already-open instance is a
|
|
||||||
no-op: nothing is appended. Unknown ``instance_id`` raises
|
|
||||||
ValueError naming the field. Existence, latest-event, and the
|
|
||||||
append run under one connection lock so a concurrent uncomplete
|
|
||||||
cannot double-append. The original completed row is not modified.
|
|
||||||
"""
|
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
|
||||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
|
||||||
actor_source, actor_user_id, actor_child_id
|
|
||||||
)
|
|
||||||
async with _reentrant_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:
|
|
||||||
raise ValueError(
|
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
|
||||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
|
||||||
)
|
|
||||||
await append_event(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
instance.child_id,
|
|
||||||
EVENT_UNCOMPLETED,
|
|
||||||
actor_source=actor_source,
|
|
||||||
actor_user_id=actor_user_id,
|
|
||||||
actor_child_id=actor_child_id,
|
|
||||||
was_on_time=None,
|
|
||||||
now=now,
|
|
||||||
)
|
|
||||||
return "open"
|
|
||||||
@@ -344,20 +344,14 @@ class QuestInstancesDao:
|
|||||||
- the (definition, child) assignment link still exists (a removed
|
- the (definition, child) assignment link still exists (a removed
|
||||||
assignment is skipped);
|
assignment is skipped);
|
||||||
- the window is still declared on the definition (a removed window
|
- 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 due_time check closes the one check-then-insert gap this
|
The live checks above ONLY decide whether the tuple is still
|
||||||
method previously left open: it re-validated every OTHER
|
valid; they never read the window's ``due_time``. The instance is
|
||||||
precondition but then wrote the snapshot's ``due_time``
|
written with the caller-supplied ``due_time`` — the value snapshotted
|
||||||
unconditionally, so a stale materialization could clobber a
|
from the coherent input read — so a window-time edit landing
|
||||||
newer, concurrently-regenerated due_time.
|
mid-walk cannot leak a newer time into a batch that was already
|
||||||
|
snapshotted against older rule/presence/assignment decisions.
|
||||||
|
|
||||||
Returns the stored instance, or ``None`` when a precondition
|
Returns the stored instance, or ``None`` when a precondition
|
||||||
failed — the tuple no longer materializable, which the walk SKIPS
|
failed — the tuple no longer materializable, which the walk SKIPS
|
||||||
@@ -390,9 +384,8 @@ class QuestInstancesDao:
|
|||||||
"JOIN quest_definition_windows w "
|
"JOIN quest_definition_windows w "
|
||||||
"ON w.definition_id = d.id "
|
"ON w.definition_id = d.id "
|
||||||
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||||
"AND d.is_active = 1 AND c.is_active = 1 "
|
"AND d.is_active = 1 AND c.is_active = 1 LIMIT 1",
|
||||||
"AND w.due_time IS ? LIMIT 1",
|
(definition_id, child_id, window),
|
||||||
(definition_id, child_id, window, due_time),
|
|
||||||
)
|
)
|
||||||
if still_valid is None:
|
if still_valid is None:
|
||||||
# A config change invalidated the tuple since the
|
# A config change invalidated the tuple since the
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
|||||||
covers a single child's presence change (Feature 09). Both delete the
|
covers a single child's presence change (Feature 09). Both delete the
|
||||||
affected open instances at or after today — never a completed instance,
|
affected open instances at or after today — never a completed instance,
|
||||||
never the past — and re-run the walk over the rolling horizon
|
never the past — and re-run the walk over the rolling horizon
|
||||||
``[today, today + horizon_days]`` (``horizon_days`` defaults to
|
``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||||
``DEFAULT_HORIZON_DAYS``).
|
|
||||||
|
|
||||||
HOOK (Feature 09 presence services): the presence business layer that
|
HOOK (Feature 09 presence services): the presence business layer that
|
||||||
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||||
@@ -84,31 +83,6 @@ 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:
|
def _decode_rule(snapshot) -> ScheduleRule:
|
||||||
"""Decode a snapshot's rule record back into a ScheduleRule.
|
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||||
|
|
||||||
@@ -258,7 +232,6 @@ async def regenerate_for_definition(
|
|||||||
definition_id: int,
|
definition_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Regenerate a definition's future instances after a config change.
|
"""Regenerate a definition's future instances after a config change.
|
||||||
|
|
||||||
@@ -268,7 +241,7 @@ async def regenerate_for_definition(
|
|||||||
completed instance, never the past) via
|
completed instance, never the past) via
|
||||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||||
then the materialization walk re-runs over the rolling horizon
|
then the materialization walk re-runs over the rolling horizon
|
||||||
``[today, today + horizon_days]`` so the (changed) rule,
|
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
|
||||||
assignee set and windows re-materialize against today's state.
|
assignee set and windows re-materialize against today's state.
|
||||||
|
|
||||||
"today" is computed the same way the walk computes it —
|
"today" is computed the same way the walk computes it —
|
||||||
@@ -278,18 +251,14 @@ async def regenerate_for_definition(
|
|||||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||||
(threaded into both the delete cutoff and the re-materialization)
|
(threaded into both the delete cutoff and the re-materialization)
|
||||||
so a household time zone behind the host around midnight stays
|
so a household time zone behind the host around midnight stays
|
||||||
consistent. ``horizon_days`` sizes the re-materialization window;
|
consistent. Returns the number of instances the re-materialization
|
||||||
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
|
upserted across the whole snapshot (idempotent — other active
|
||||||
definitions' tuples refresh in place, never duplicate).
|
definitions' tuples refresh in place, never duplicate).
|
||||||
"""
|
"""
|
||||||
horizon = _resolve_horizon_days(horizon_days)
|
|
||||||
today_date = _resolve_today(today)
|
today_date = _resolve_today(today)
|
||||||
start_date = today_date.isoformat()
|
start_date = today_date.isoformat()
|
||||||
end_date = (
|
end_date = (
|
||||||
today_date + datetime.timedelta(days=horizon)
|
today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
|
|
||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
@@ -317,7 +286,6 @@ async def regenerate_for_child(
|
|||||||
child_id: int,
|
child_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Regenerate a child's future instances after a presence change.
|
"""Regenerate a child's future instances after a presence change.
|
||||||
|
|
||||||
@@ -331,7 +299,7 @@ async def regenerate_for_child(
|
|||||||
(never a completed instance, never the past) via
|
(never a completed instance, never the past) via
|
||||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||||
then the materialization walk re-runs over the rolling horizon
|
then the materialization walk re-runs over the rolling horizon
|
||||||
``[today, today + horizon_days]`` scoped to that child only,
|
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
|
||||||
so the child's now-absent/present dates re-materialize against
|
so the child's now-absent/present dates re-materialize against
|
||||||
today's presence WITHOUT touching any other child's rows.
|
today's presence WITHOUT touching any other child's rows.
|
||||||
|
|
||||||
@@ -347,17 +315,13 @@ async def regenerate_for_child(
|
|||||||
execution-day anchor even when the call is queued across midnight.
|
execution-day anchor even when the call is queued across midnight.
|
||||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||||
into the delete cutoff and the re-materialization for the same
|
into the delete cutoff and the re-materialization for the same
|
||||||
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
host-vs-HA time-zone consistency. Returns the number of instances
|
||||||
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
|
the re-materialization upserted for the child across the whole
|
||||||
snapshot.
|
snapshot.
|
||||||
"""
|
"""
|
||||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
|
||||||
horizon = _resolve_horizon_days(horizon_days)
|
|
||||||
today_date = _resolve_today(today)
|
today_date = _resolve_today(today)
|
||||||
|
|
||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
@@ -371,7 +335,7 @@ async def regenerate_for_child(
|
|||||||
child_id, today_date.isoformat(), today=today
|
child_id, today_date.isoformat(), today=today
|
||||||
)
|
)
|
||||||
start = datetime.date.fromisoformat(effective_cutoff)
|
start = datetime.date.fromisoformat(effective_cutoff)
|
||||||
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
|
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
return await materialize(
|
return await materialize(
|
||||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -272,7 +272,6 @@ async def edit_quest_definition(
|
|||||||
rule: ScheduleRule | object = _UNSET,
|
rule: ScheduleRule | object = _UNSET,
|
||||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> CreatedQuestDefinition:
|
) -> CreatedQuestDefinition:
|
||||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||||
|
|
||||||
@@ -300,10 +299,7 @@ async def edit_quest_definition(
|
|||||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||||
``datetime.date`` with no HA import here — the Feature 09 service
|
``datetime.date`` with no HA import here — the Feature 09 service
|
||||||
supplies the hass-derived value. When omitted, regeneration falls
|
supplies the hass-derived value. When omitted, regeneration falls
|
||||||
back to the host clock, same as before. ``horizon_days`` sizes the
|
back to the host clock, same as before.
|
||||||
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)
|
_validate_definition_id(definition_id)
|
||||||
|
|
||||||
@@ -354,9 +350,7 @@ async def edit_quest_definition(
|
|||||||
schedule_rule_storage_from_record(snapshot.rule)
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
)
|
)
|
||||||
|
|
||||||
await regenerate_for_definition(
|
await regenerate_for_definition(database, definition_id, today=today)
|
||||||
database, definition_id, today=today, horizon_days=horizon_days
|
|
||||||
)
|
|
||||||
|
|
||||||
return CreatedQuestDefinition(
|
return CreatedQuestDefinition(
|
||||||
definition=snapshot.definition,
|
definition=snapshot.definition,
|
||||||
@@ -372,7 +366,6 @@ async def assign_child(
|
|||||||
child_id: int,
|
child_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> list[ChildRecord]:
|
) -> list[ChildRecord]:
|
||||||
"""Assign ``child_id`` to the definition (idempotent).
|
"""Assign ``child_id`` to the definition (idempotent).
|
||||||
|
|
||||||
@@ -389,8 +382,6 @@ async def assign_child(
|
|||||||
|
|
||||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
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_definition_id(definition_id)
|
||||||
_validate_child_id(child_id)
|
_validate_child_id(child_id)
|
||||||
@@ -406,9 +397,7 @@ async def assign_child(
|
|||||||
if message.startswith("quest definition "):
|
if message.startswith("quest definition "):
|
||||||
raise ValueError(f"definition_id: {message}") from error
|
raise ValueError(f"definition_id: {message}") from error
|
||||||
raise
|
raise
|
||||||
await regenerate_for_definition(
|
await regenerate_for_definition(database, definition_id, today=today)
|
||||||
database, definition_id, today=today, horizon_days=horizon_days
|
|
||||||
)
|
|
||||||
return assignees
|
return assignees
|
||||||
|
|
||||||
|
|
||||||
@@ -418,7 +407,6 @@ async def unassign_child(
|
|||||||
child_id: int,
|
child_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Unassign ``child_id`` from the definition; True when removed.
|
"""Unassign ``child_id`` from the definition; True when removed.
|
||||||
|
|
||||||
@@ -431,8 +419,6 @@ async def unassign_child(
|
|||||||
|
|
||||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
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_definition_id(definition_id)
|
||||||
_validate_child_id(child_id)
|
_validate_child_id(child_id)
|
||||||
@@ -443,9 +429,7 @@ async def unassign_child(
|
|||||||
f"definition_id: quest definition {definition_id} does not exist"
|
f"definition_id: quest definition {definition_id} does not exist"
|
||||||
)
|
)
|
||||||
removed = await dao.remove_assignee(definition_id, child_id)
|
removed = await dao.remove_assignee(definition_id, child_id)
|
||||||
await regenerate_for_definition(
|
await regenerate_for_definition(database, definition_id, today=today)
|
||||||
database, definition_id, today=today, horizon_days=horizon_days
|
|
||||||
)
|
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@@ -455,7 +439,6 @@ async def set_quest_definition_active(
|
|||||||
is_active: bool,
|
is_active: bool,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> CreatedQuestDefinition:
|
) -> CreatedQuestDefinition:
|
||||||
"""Deactivate or reactivate a definition; returns the updated view.
|
"""Deactivate or reactivate a definition; returns the updated view.
|
||||||
|
|
||||||
@@ -481,8 +464,7 @@ async def set_quest_definition_active(
|
|||||||
|
|
||||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
into the activation change's regeneration (see
|
into the activation change's regeneration (see
|
||||||
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
:func:`edit_quest_definition`).
|
||||||
regeneration's re-materialization window the same way.
|
|
||||||
"""
|
"""
|
||||||
_validate_definition_id(definition_id)
|
_validate_definition_id(definition_id)
|
||||||
if not isinstance(is_active, bool):
|
if not isinstance(is_active, bool):
|
||||||
@@ -506,9 +488,7 @@ async def set_quest_definition_active(
|
|||||||
rule = schedule_rule_from_storage(
|
rule = schedule_rule_from_storage(
|
||||||
schedule_rule_storage_from_record(rule_record)
|
schedule_rule_storage_from_record(rule_record)
|
||||||
)
|
)
|
||||||
await regenerate_for_definition(
|
await regenerate_for_definition(database, definition_id, today=today)
|
||||||
database, definition_id, today=today, horizon_days=horizon_days
|
|
||||||
)
|
|
||||||
return CreatedQuestDefinition(
|
return CreatedQuestDefinition(
|
||||||
definition=updated,
|
definition=updated,
|
||||||
rule=rule,
|
rule=rule,
|
||||||
|
|||||||
@@ -1,924 +0,0 @@
|
|||||||
"""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,
|
|
||||||
complete_instance,
|
|
||||||
instance_state,
|
|
||||||
uncomplete_instance,
|
|
||||||
)
|
|
||||||
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" in public
|
|
||||||
assert "uncomplete_instance" 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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# complete_instance: append completed, no-op if already done
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_appends_and_returns_done(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
assert await events.list_by_instance(instance.id) == []
|
|
||||||
state = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
assert state == "done"
|
|
||||||
assert await instance_state(database, instance.id) == "done"
|
|
||||||
rows = await events.list_by_instance(instance.id)
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].event_type == EVENT_COMPLETED
|
|
||||||
assert rows[0].actor_source == "user"
|
|
||||||
assert rows[0].actor_user_id == "user-1"
|
|
||||||
assert rows[0].was_on_time is True
|
|
||||||
|
|
||||||
_with_db(tmp_path, "complete-open.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_already_done_is_noop(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
first = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
before = await events.list_by_instance(instance.id)
|
|
||||||
assert first == "done"
|
|
||||||
assert len(before) == 1
|
|
||||||
second = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-2",
|
|
||||||
)
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert second == "done"
|
|
||||||
assert after == before
|
|
||||||
assert after[0].actor_user_id == "user-1"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "complete-noop.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_unknown_instance_id_raises(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id + 999,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "complete-unknown.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_rejects_non_integer_id() -> None:
|
|
||||||
async def _body():
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await complete_instance(
|
|
||||||
object(),
|
|
||||||
"1",
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await complete_instance(
|
|
||||||
object(),
|
|
||||||
True,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_run(_body())
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_after_uncompleted_appends(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",
|
|
||||||
)
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
before = await events.list_by_instance(instance.id)
|
|
||||||
assert await instance_state(database, instance.id) == "open"
|
|
||||||
state = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="panel",
|
|
||||||
actor_child_id=child.id,
|
|
||||||
)
|
|
||||||
assert state == "done"
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert len(after) == len(before) + 1
|
|
||||||
assert after[-1].event_type == EVENT_COMPLETED
|
|
||||||
assert after[-1].actor_source == "panel"
|
|
||||||
assert after[-1].was_on_time is True
|
|
||||||
|
|
||||||
_with_db(tmp_path, "complete-reopen.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_now_pins_occurred_at(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
pinned = datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=pinned,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].occurred_at == "2026-09-18T08:00:00+00:00"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "complete-pinned.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def _gated_hass(armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event):
|
|
||||||
"""A hass mock whose executor gates the FIRST ``_execute`` job after
|
|
||||||
arming — the INSERT of whichever ``complete_instance`` starts first
|
|
||||||
— proving that caller holds the connection lock and the second
|
|
||||||
caller must queue behind it."""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
hass = MagicMock()
|
|
||||||
|
|
||||||
async def _gated_executor(fn, *args):
|
|
||||||
if armed["active"]:
|
|
||||||
if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]:
|
|
||||||
armed["gated"] = True
|
|
||||||
a_started.set()
|
|
||||||
await gate_open.wait()
|
|
||||||
return fn(*args)
|
|
||||||
|
|
||||||
hass.async_add_executor_job = _gated_executor
|
|
||||||
return hass
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|
||||||
"""Two racing completes cannot both append.
|
|
||||||
|
|
||||||
Deterministic gate: task A is paused at its INSERT (it holds the
|
|
||||||
connection lock); task B is then started and must queue. A
|
|
||||||
finishes, B sees done and appends nothing.
|
|
||||||
"""
|
|
||||||
async def _main():
|
|
||||||
armed = {"active": False, "gated": False}
|
|
||||||
gate_open = asyncio.Event()
|
|
||||||
a_started = asyncio.Event()
|
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
|
||||||
database = NestQuestDatabase(hass)
|
|
||||||
await database.open(tmp_path / "complete-race.db")
|
|
||||||
try:
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
armed["active"] = True
|
|
||||||
task_a = asyncio.ensure_future(
|
|
||||||
complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await a_started.wait()
|
|
||||||
armed["active"] = False
|
|
||||||
task_b = asyncio.ensure_future(
|
|
||||||
complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-2",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
gate_open.set()
|
|
||||||
state_a, state_b = await asyncio.gather(task_a, task_b)
|
|
||||||
assert state_a == "done"
|
|
||||||
assert state_b == "done"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].actor_user_id == "user-1"
|
|
||||||
finally:
|
|
||||||
await database.close()
|
|
||||||
|
|
||||||
_run(_main())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# uncomplete_instance: append uncompleted, no-op if already open
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_appends_and_leaves_original_untouched(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
completed_at = datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=completed_at,
|
|
||||||
)
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
before = await events.list_by_instance(instance.id)
|
|
||||||
assert len(before) == 1
|
|
||||||
original = before[0]
|
|
||||||
assert original.event_type == EVENT_COMPLETED
|
|
||||||
assert original.occurred_at == "2026-09-18T08:00:00+00:00"
|
|
||||||
assert original.actor_source == "user"
|
|
||||||
assert original.actor_user_id == "user-1"
|
|
||||||
assert original.actor_child_id is None
|
|
||||||
uncompleted_at = completed_at + datetime.timedelta(seconds=1)
|
|
||||||
state = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="panel",
|
|
||||||
actor_child_id=child.id,
|
|
||||||
now=uncompleted_at,
|
|
||||||
)
|
|
||||||
assert state == "open"
|
|
||||||
assert await instance_state(database, instance.id) == "open"
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert len(after) == 2
|
|
||||||
assert after[0].id == original.id
|
|
||||||
assert after[0].event_type == EVENT_COMPLETED
|
|
||||||
assert after[0].occurred_at == original.occurred_at
|
|
||||||
assert after[0].actor_source == original.actor_source
|
|
||||||
assert after[0].actor_user_id == original.actor_user_id
|
|
||||||
assert after[0].actor_child_id == original.actor_child_id
|
|
||||||
assert after[1].event_type == EVENT_UNCOMPLETED
|
|
||||||
assert after[1].actor_source == "panel"
|
|
||||||
assert after[1].actor_child_id == child.id
|
|
||||||
assert after[1].was_on_time is None
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-append.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_already_open_is_noop(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
assert await events.list_by_instance(instance.id) == []
|
|
||||||
first = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
after_first = await events.list_by_instance(instance.id)
|
|
||||||
assert first == "open"
|
|
||||||
assert after_first == []
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
before = await events.list_by_instance(instance.id)
|
|
||||||
assert len(before) == 2
|
|
||||||
second = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-2",
|
|
||||||
)
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert second == "open"
|
|
||||||
assert after == before
|
|
||||||
assert after[0].actor_user_id == "user-1"
|
|
||||||
assert after[1].actor_user_id == "user-1"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-noop.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_unknown_instance_id_raises(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id + 999,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-unknown.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_rejects_non_integer_id() -> None:
|
|
||||||
async def _body():
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await uncomplete_instance(
|
|
||||||
object(),
|
|
||||||
"1",
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await uncomplete_instance(
|
|
||||||
object(),
|
|
||||||
True,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_run(_body())
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_now_pins_occurred_at(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
|
||||||
),
|
|
||||||
)
|
|
||||||
pinned = datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 1, tzinfo=datetime.timezone.utc
|
|
||||||
)
|
|
||||||
await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=pinned,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[1].occurred_at == "2026-09-18T08:00:01+00:00"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-pinned.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|
||||||
"""Two racing uncompletes cannot both append.
|
|
||||||
|
|
||||||
Deterministic gate: task A is paused at its INSERT (it holds the
|
|
||||||
connection lock); task B is then started and must queue. A
|
|
||||||
finishes, B sees open and appends nothing.
|
|
||||||
"""
|
|
||||||
async def _main():
|
|
||||||
armed = {"active": False, "gated": False}
|
|
||||||
gate_open = asyncio.Event()
|
|
||||||
a_started = asyncio.Event()
|
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
|
||||||
database = NestQuestDatabase(hass)
|
|
||||||
await database.open(tmp_path / "uncomplete-race.db")
|
|
||||||
try:
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
armed["active"] = True
|
|
||||||
task_a = asyncio.ensure_future(
|
|
||||||
uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await a_started.wait()
|
|
||||||
armed["active"] = False
|
|
||||||
task_b = asyncio.ensure_future(
|
|
||||||
uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-2",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
gate_open.set()
|
|
||||||
state_a, state_b = await asyncio.gather(task_a, task_b)
|
|
||||||
assert state_a == "open"
|
|
||||||
assert state_b == "open"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert len(rows) == 2
|
|
||||||
assert rows[0].event_type == EVENT_COMPLETED
|
|
||||||
assert rows[1].event_type == EVENT_UNCOMPLETED
|
|
||||||
assert rows[1].actor_user_id == "user-1"
|
|
||||||
finally:
|
|
||||||
await database.close()
|
|
||||||
|
|
||||||
_run(_main())
|
|
||||||
@@ -10,7 +10,6 @@ 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 import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
SERVICE_REGENERATE,
|
SERVICE_REGENERATE,
|
||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
@@ -352,39 +351,6 @@ 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:
|
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
||||||
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
||||||
|
|
||||||
@@ -481,63 +447,6 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
|||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
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(
|
async def test_regenerate_service_registered_and_materializes(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -565,40 +474,6 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
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:
|
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
|||||||
+7
-203
@@ -7,7 +7,6 @@ import datetime
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
@@ -545,7 +544,7 @@ def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "materialize-presence-snapshot.db")(_body)
|
_with_db(tmp_path, "materialize-presence-snapshot.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_skips_stale_due_time_mid_walk(tmp_path) -> None:
|
def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
child = await children.create("Ada", NOW)
|
child = await children.create("Ada", NOW)
|
||||||
@@ -566,64 +565,21 @@ def test_materialize_skips_stale_due_time_mid_walk(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# The window's due_time is edited to 10:15 between the input
|
# The window's due_time is edited to 10:15 between the input
|
||||||
# snapshot and the insert. The walk's snapshotted 09:00 no
|
# snapshot and the insert. The walk must store the SNAPSHOT's
|
||||||
# longer matches the live 10:15, so the tuple is SKIPPED rather
|
# 09:00, not the live 10:15 — the batch is governed entirely by
|
||||||
# than writing a due_time that has already been superseded.
|
# the coherent snapshot it read.
|
||||||
count = await _materialize_with_change(
|
count = await _materialize_with_change(
|
||||||
database, start_iso, end_iso, _edit_due_time
|
database, start_iso, end_iso, _edit_due_time
|
||||||
)
|
)
|
||||||
assert count == 0
|
assert count == 3
|
||||||
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(
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child.id, start_iso, end_iso
|
child.id, start_iso, end_iso
|
||||||
)
|
)
|
||||||
assert len(records) == 3
|
assert len(records) == 3
|
||||||
assert {r.due_time for r in records} == {"10:15"}
|
assert {r.due_time for r in records} == {"09:00"}
|
||||||
return records
|
return records
|
||||||
|
|
||||||
_with_db(tmp_path, "materialize-regenerated-due-time.db")(_body)
|
_with_db(tmp_path, "materialize-snapshot-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_honours_overrides(tmp_path) -> None:
|
def test_materialize_honours_overrides(tmp_path) -> None:
|
||||||
@@ -1157,158 +1113,6 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
|
_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,
|
#: 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
|
#: 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
|
#: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
@@ -1939,71 +1938,3 @@ def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None:
|
|||||||
return reactivated
|
return reactivated
|
||||||
|
|
||||||
_with_db(tmp_path, "full-lifecycle.db")(_body)
|
_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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user