Compare commits

...
7 changed files with 1647 additions and 11 deletions
@@ -277,6 +277,117 @@ class QuestInstancesDao:
assert instance is not None
return instance
async def upsert_if_valid(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
) -> QuestInstanceRecord | None:
"""Insert one dated instance iff its tuple is still materializable.
This is the materialization walk's single write path. Unlike the
generic :meth:`upsert`, which validates only existence and
assignment, this method re-validates EVERY materialization
precondition in the SAME connection-lock-held transaction as the
INSERT — closing the check-then-insert gap a concurrent config
write could otherwise slip through:
- the definition exists AND is active (a definition deactivated
after the input snapshot is skipped, never written);
- the child exists AND is active (a deactivated child is skipped);
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped).
The live checks above ONLY decide whether the tuple is still
valid; they never read the window's ``due_time``. The instance is
written with the caller-supplied ``due_time`` — the value snapshotted
from the coherent input read — so a window-time edit landing
mid-walk cannot leak a newer time into a batch that was already
snapshotted against older rule/presence/assignment decisions.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
rather than raising, so the rest of the batch is unaffected. The
no-past rule and the immutable-completed-instance refusal are
preserved from :meth:`upsert` (those are not configuration
invalidations and still raise ValueError).
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates before queuing on the lock;
# the authoritative check re-runs under the lock (see upsert).
today = datetime.date.today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today!r}"
)
async with _connection_lock(self._database):
async with self._database.transaction():
today = datetime.date.today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: "
f"due_date {due_date!r} is before today {today!r}"
)
still_valid = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions d "
"JOIN quest_definition_assignees a "
"ON a.definition_id = d.id "
"JOIN children c ON c.id = a.child_id "
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 LIMIT 1",
(definition_id, child_id, window),
)
if still_valid is None:
# A config change invalidated the tuple since the
# snapshot: skip it rather than abort the batch.
return None
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
raise ValueError(
f"instance for (definition {definition_id}, "
f"child {child_id}, due_date {due_date!r}, "
f"window {window!r}) already has a completion "
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def get(
self,
definition_id: int,
+56 -1
View File
@@ -276,4 +276,59 @@ class PresenceOverridesDao:
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
return result.rowcount > 0
async def read_snapshot_unlocked(
database: NestQuestDatabase,
child_ids: list[int],
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
``child_ids`` must name existing children (the caller derives them
from a trusted snapshot, within the same transaction).
"""
children = sorted(set(child_ids))
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
tuple(children),
)
override_rows = await database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
+119 -1
View File
@@ -907,6 +907,81 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
"""Read each definition's rule, assignees and windows.
MUST be called inside the connection lock (and ideally a
transaction) so the definitions handed in and the rows this
reads back cannot be raced by a concurrent edit between list
and bundle. Each rule is read through
:class:`ScheduleRulesDao` on the same connection, so the
snapshot's rule record is the one the definition references AT
the moment of the locked read.
"""
rules_dao = ScheduleRulesDao(self._database)
snapshots: list[QuestDefinitionSnapshot] = []
for definition in definitions:
rule_record = await rules_dao.get(definition.schedule_rule_id)
assert rule_record is not None
snapshots.append(
QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=await self.list_assignees(definition.id),
windows=await self.list_windows(definition.id),
)
)
return snapshots
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition with its rule, assignees and
windows, all read inside one locked transaction.
The definitive list and each definition's linked rows are read
in a single BEGIN..COMMIT span under the connection lock, so a
concurrent atomic edit cannot interleave between the definition
list and the bundle read: the caller receives a coherent
snapshot (each definition's rule, assignees and windows as they
were at one instant), never a mixed old-rule/new-windows state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
Mirrors :meth:`list_snapshots_active` minus the lock and
transaction: the definitions and their rules, assignees and
windows are read on the caller's connection under whatever
transaction the caller is already holding, so they stay coherent
with every OTHER table the caller reads inside that same
transaction. MUST be called inside the connection lock and an
open transaction (the single-transaction input snapshot the
materialization walk uses is that caller).
"""
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_by_child(
self, child_id: int
) -> list[QuestDefinitionSnapshot]:
"""Return each definition assigned to ``child_id`` with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` but selects by assignment
(``list_by_child`` order: newest first). Locks read-and-bundle
into one transaction so the bundle never mixes one edit's
definition with another's windows.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_by_child(child_id)
return await self._snapshots_for(definitions)
async def update(
self,
definition_id: int,
@@ -1129,4 +1204,47 @@ class QuestDefinitionsDao:
if not child[0]:
raise ValueError(
f"child {child_id} is inactive and cannot be assigned"
)
)
async def load_materialization_input(
database: NestQuestDatabase,
range_start: str,
range_end: str,
):
"""Read every materialization input in ONE locked transaction.
Returns ``(snapshots, schedules, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and
windows (the :class:`QuestDefinitionSnapshot` shape).
- ``schedules`` / ``overrides``: the presence schedules and
range-scoped overrides of every assignee child, in
:mod:`.dao_presence`'s record shapes.
The whole read — definitions, rules, assignees, windows, and the
assignees' presence schedules and overrides — runs inside a single
BEGIN..COMMIT span under the connection lock, so a concurrent edit
(definition, assignment, window, or presence) cannot interleave
between the parts and the walk never sees a mixed-time view. Each
table's SQL stays in its own DAO module: the presence portion is
delegated to :mod:`.dao_presence` (a lazy import here, since that
module imports this one).
"""
definitions_dao = QuestDefinitionsDao(database)
async with _connection_lock(database):
async with database.transaction():
snapshots = await definitions_dao.read_snapshots_active()
child_ids = sorted(
{
child.id
for snapshot in snapshots
for child in snapshot.assignees
}
)
from . import dao_presence # lazy: dao_presence imports us
schedules, overrides = await dao_presence.read_snapshot_unlocked(
database, child_ids, range_start, range_end
)
return snapshots, schedules, overrides
+167
View File
@@ -0,0 +1,167 @@
"""The materialization walk (Feature 07 core).
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) and skip-completed
generation are separate later tasks and live elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date, window),
so re-running the materialization over the same range never duplicates a
row; skipped-completed handling is deliberately NOT done here.
"""
from __future__ import annotations
import datetime
from .dao_instances import QuestInstancesDao
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
@@ -44,11 +44,13 @@ from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
@@ -56,7 +58,7 @@ from .dao_rules import (
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .recurrence import ScheduleRule
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
@@ -317,14 +319,23 @@ async def edit_quest_definition(
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
@@ -452,3 +463,89 @@ async def set_quest_definition_active(
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
+564
View File
@@ -0,0 +1,564 @@
"""Tests for materialize.py: the Feature 07 materialization walk."""
from __future__ import annotations
import asyncio
import datetime
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
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
NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
return database
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = await _prepare(tmp_path / name)
try:
return await body(database)
finally:
await database.close()
return _run(_main())
return _run_test
def _future_monday() -> datetime.date:
"""The Monday of next week (strictly future, all 28 days eligible)."""
today = datetime.date.today()
this_monday = today - datetime.timedelta(days=today.weekday())
return this_monday + datetime.timedelta(days=7)
def _build_expected(start, def1_id, def2_id, a_id, b_id, c_id) -> set:
"""Hand-derived expected (definition_id, child_id, window, due_date,
due_time) tuples over the four-week window.
Custody: A is present week 0 and 2 of the two-week cycle, absent
1 and 3; B is the mirror; C has no schedule (present every day).
"""
expected: set = set()
start_dt = datetime.date.fromisoformat(start)
for offset in range(28):
day = start_dt + datetime.timedelta(days=offset)
iso = day.isoformat()
week = offset // 7
monday = day.weekday() == 0
# Def1: DAILY, assignees A + C, window morning due 09:00.
if week % 2 == 0:
expected.add((def1_id, a_id, "morning", iso, "09:00"))
expected.add((def1_id, c_id, "morning", iso, "09:00"))
# Def2: WEEKLY Monday, assignees B + C, morning 07:00 + evening 19:00.
if monday:
if week % 2 == 1:
expected.add((def2_id, b_id, "morning", iso, "07:00"))
expected.add((def2_id, b_id, "evening", iso, "19:00"))
expected.add((def2_id, c_id, "morning", iso, "07:00"))
expected.add((def2_id, c_id, "evening", iso, "19:00"))
return expected
async def _collect_instances(database, child_ids, start, end) -> set:
dao = QuestInstancesDao(database)
collected = set()
for child_id in child_ids:
for record in await dao.list_by_date_range(child_id, start, end):
collected.add(
(
record.definition_id,
record.child_id,
record.window,
record.due_date,
record.due_time,
)
)
return collected
def test_materialize_two_custody_one_always_present(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2
b = await children.create("Bo", NOW) # present weeks 1, 3
c = await children.create("Cleo", NOW) # no schedule: always present
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=27)).isoformat()
await schedules.upsert_by_child(
a.id, 2, start_iso, "0,1,2,3,4,5,6|"
)
await schedules.upsert_by_child(
b.id, 2, start_iso, "|0,1,2,3,4,5,6"
)
def1 = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id, c.id],
[("morning", "09:00")],
)
def2 = await create_quest_definition(
database,
"Weekly chore",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0},
start_date=start_iso,
),
[b.id, c.id],
[("morning", "07:00"), ("evening", "19:00")],
)
expected = _build_expected(
start_iso, def1.definition.id, def2.definition.id,
a.id, b.id, c.id,
)
count = await materialize(database, start_iso, end_iso)
assert count == len(expected)
actual = await _collect_instances(
database, (a.id, b.id, c.id), start_iso, end_iso
)
assert actual == expected
return count
_with_db(tmp_path, "materialize.db")(_body)
def test_materialize_shares_one_generated_at_per_run(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
await materialize(database, start_iso, end_iso)
dao = QuestInstancesDao(database)
stamps = {
record.generated_at
for record in await dao.list_by_date_range(
child.id, start_iso, end_iso
)
}
assert len(stamps) == 1
return stamps
_with_db(tmp_path, "materialize-stamp.db")(_body)
def test_materialize_is_idempotent(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
first = await materialize(database, start_iso, end_iso)
second = await materialize(database, start_iso, end_iso)
assert first == second == 7
# Running twice refreshes the SAME seven rows: exactly one
# physical row per (definition_id, child_id, due_date, window)
# key, never a duplicate.
records = await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
)
keys = [
(r.definition_id, r.child_id, r.due_date, r.window)
for r in records
]
assert len(keys) == 7
assert len(set(keys)) == 7
return first
_with_db(tmp_path, "materialize-idempotent.db")(_body)
async def _materialize_with_change(
database, start_iso, end_iso, change
) -> int:
"""Run materialize, pause before its first atomic write, apply
``change``, then release. Returns the materialize count.
The gate parks the walk AFTER the single input snapshot is read but
BEFORE the first :meth:`QuestInstancesDao.upsert_if_valid` call
acquires the connection lock, so ``change`` commits against the live
state and the write's own in-transaction precondition check then
skips the tuples it invalidated (or, for a presence-only change,
proceeds with the snapped presence). This deterministically
exercises the snapshot-to-insert gap that a config edit can
interleave into.
"""
original_upsert = QuestInstancesDao.upsert_if_valid
entered = asyncio.Event()
release = asyncio.Event()
seen = {"n": 0}
async def _gated(self, definition_id, child_id, due_date, generated_at,
*, window, due_time=None):
seen["n"] += 1
if seen["n"] == 1:
seen["first"] = (definition_id, child_id, window)
entered.set()
await release.wait()
return await original_upsert(
self, definition_id, child_id, due_date, generated_at,
window=window, due_time=due_time,
)
QuestInstancesDao.upsert_if_valid = _gated
try:
task = asyncio.ensure_future(
materialize(database, start_iso, end_iso)
)
await entered.wait()
await change()
release.set()
return await task
finally:
QuestInstancesDao.upsert_if_valid = original_upsert
def test_materialize_skips_removed_assignee_mid_walk(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
a = await children.create("Ada", NOW)
c = await children.create("Cleo", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=2)).isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id, c.id],
["morning"],
)
async def _remove_assignee():
await QuestDefinitionsDao(database).remove_assignee(
created.definition.id, a.id
)
count = await _materialize_with_change(
database, start_iso, end_iso, _remove_assignee
)
assert count == 3 # Cleo's three days survive
assert await QuestInstancesDao(database).list_by_date_range(
a.id, start_iso, end_iso
) == []
assert len(await QuestInstancesDao(database).list_by_date_range(
c.id, start_iso, end_iso
)) == 3
return count
_with_db(tmp_path, "materialize-removed-assignee.db")(_body)
def test_materialize_skips_deactivated_definition_mid_walk(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
a = await children.create("Ada", NOW)
c = await children.create("Cleo", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=2)).isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id, c.id],
["morning"],
)
async def _deactivate():
await QuestDefinitionsDao(database).set_active(
created.definition.id, False
)
count = await _materialize_with_change(
database, start_iso, end_iso, _deactivate
)
assert count == 0
assert await QuestInstancesDao(database).list_by_date_range(
a.id, start_iso, end_iso
) == []
assert await QuestInstancesDao(database).list_by_date_range(
c.id, start_iso, end_iso
) == []
return count
_with_db(tmp_path, "materialize-deactivated-definition.db")(_body)
def test_materialize_skips_removed_window_mid_walk(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
a = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=2)).isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id],
[("morning", "09:00")],
)
async def _remove_window():
await QuestDefinitionsDao(database).remove_window(
created.definition.id, "morning"
)
count = await _materialize_with_change(
database, start_iso, end_iso, _remove_window
)
assert count == 0
assert await QuestInstancesDao(database).list_by_date_range(
a.id, start_iso, end_iso
) == []
return count
_with_db(tmp_path, "materialize-removed-window.db")(_body)
def test_materialize_skips_deactivated_child_mid_walk(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
a = await children.create("Ada", NOW)
c = await children.create("Cleo", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=2)).isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id, c.id],
["morning"],
)
async def _deactivate_child():
await ChildrenDao(database).set_active(a.id, False)
count = await _materialize_with_change(
database, start_iso, end_iso, _deactivate_child
)
assert count == 3 # Cleo's three days survive
assert await QuestInstancesDao(database).list_by_date_range(
a.id, start_iso, end_iso
) == []
assert len(await QuestInstancesDao(database).list_by_date_range(
c.id, start_iso, end_iso
)) == 3
return count
_with_db(tmp_path, "materialize-deactivated-child.db")(_body)
def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
children = ChildrenDao(database)
overrides = PresenceOverridesDao(database)
child = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=2)).isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
async def _add_blocking_override():
await overrides.create(
child.id, start_iso, end_iso, False, note="away"
)
# Presence is read once, inside the input snapshot; a blocking
# override landing AFTER that read must not make the walk panic
# or half-apply it. The walk stays fully governed by the
# snapshot it read: Ada was present every day there, so all
# three days still materialize, deterministically.
count = await _materialize_with_change(
database, start_iso, end_iso, _add_blocking_override
)
assert count == 3
assert len(await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
)) == 3
return count
_with_db(tmp_path, "materialize-presence-snapshot.db")(_body)
def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + 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_due_time():
await QuestDefinitionsDao(database).upsert_window(
created.definition.id, "morning", due_time="10:15"
)
# The window's due_time is edited to 10:15 between the input
# snapshot and the insert. The walk must store the SNAPSHOT's
# 09:00, not the live 10:15 — the batch is governed entirely by
# the coherent snapshot it read.
count = await _materialize_with_change(
database, start_iso, end_iso, _edit_due_time
)
assert count == 3
records = await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
)
assert len(records) == 3
assert {r.due_time for r in records} == {"09:00"}
return records
_with_db(tmp_path, "materialize-snapshot-due-time.db")(_body)
def test_materialize_honours_overrides(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
children = ChildrenDao(database)
overrides = PresenceOverridesDao(database)
child = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
# No schedule means the child is present every day; a blocking
# override covering the whole week must still suppress every day.
await overrides.create(
child.id, start_iso, end_iso, False, note="away all week"
)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
count = await materialize(database, start_iso, end_iso)
assert count == 0
return count
_with_db(tmp_path, "materialize-override.db")(_body)
def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
async def _body(database):
start = (_future_monday()).isoformat()
end = (datetime.date.fromisoformat(start)
+ datetime.timedelta(days=1)).isoformat()
for bad in ("not-a-date", "2026-9-7", ""):
with pytest.raises(ValueError, match="start_date"):
await materialize(database, bad, end)
with pytest.raises(ValueError, match="end_date"):
await materialize(database, start, bad)
with pytest.raises(ValueError, match="end_date must be on or after"):
await materialize(database, end, start)
return None
_with_db(tmp_path, "materialize-bounds.db")(_body)
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.quest_definitions import (
set_quest_definition_active,
)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
await set_quest_definition_active(database, created.definition.id, False)
count = await materialize(database, start_iso, end_iso)
assert count == 0
return count
_with_db(tmp_path, "materialize-inactive.db")(_body)
+524
View File
@@ -24,6 +24,9 @@ from custom_components.nestquest.quest_definitions import (
assign_child,
create_quest_definition,
edit_quest_definition,
list_active_definitions,
list_definitions_firing_on,
list_definitions_for_child,
set_quest_definition_active,
unassign_child,
)
@@ -783,6 +786,24 @@ def test_edit_rejects_nonexistent_definition(tmp_path) -> None:
_with_db(tmp_path, "edit-missing-definition.db")(_body)
def test_edit_nonexistent_definition_names_field(tmp_path) -> None:
"""A missing definition's error names the ``definition_id`` field.
The DAO raises a bare "quest definition <id> does not exist"; the
business layer must re-raise it prefixed with the field the caller
passed, matching assign/unassign/deactivate.
"""
async def _body(database, children, rules, definitions, child):
with pytest.raises(
ValueError,
match="definition_id: quest definition 999 does not exist",
):
await edit_quest_definition(database, 999, title="X")
return None
_with_db(tmp_path, "edit-missing-definition-field.db")(_body)
@pytest.mark.parametrize("bad", [True, 1.5, "1", None])
def test_edit_rejects_non_int_definition_id(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
@@ -1420,3 +1441,506 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
await database.close()
_run(_main())
# ---------------------------------------------------------------------------
# list query helpers: list_active_definitions / list_definitions_for_child /
# list_definitions_firing_on
# ---------------------------------------------------------------------------
def test_list_active_definitions_returns_only_active_in_id_order(tmp_path) -> None:
"""Only active definitions come back, in rising id order, each rich.
Two definitions are created (ids 1 and 2), then definition 1 is
deactivated. ``list_active_definitions`` must return only definition
2, and every bundle must carry the decoded rule, assignees and
windows of its own definition.
"""
async def _body(database, children, rules, definitions, child):
bo = await children.create("Bo", NOW)
first = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0, 2},
start_date="2026-09-14",
)
second = await create_quest_definition(
database,
"Tidy room",
weekly,
[child.id, bo.id],
[("morning", "08:00"), ("evening", "19:00")],
)
assert [d.id for d in await definitions.list_active()] == [
first.definition.id,
second.definition.id,
]
await set_quest_definition_active(database, first.definition.id, False)
result = await list_active_definitions(database)
assert [bundle.definition.id for bundle in result] == [second.definition.id]
assert isinstance(result[0], CreatedQuestDefinition)
assert result[0].rule == weekly
assert sorted(c.id for c in result[0].assignees) == sorted(
[child.id, bo.id]
)
by_name = {w.window: w.due_time for w in result[0].windows}
assert by_name == {"morning": "08:00", "evening": "19:00"}
return result
_with_db(tmp_path, "list-active.db")(_body)
def test_list_definitions_for_child_returns_only_that_child(tmp_path) -> None:
"""Only the given child's definitions come back, deterministically.
Definitions are created assigned to Ada (child), Bo, or both; the
helper must return exactly the definitions assigned to Ada, excluding
a Bo-only definition. Inactive definitions are still assigned, so
they too come back (the helper filters by child, not by activity).
"""
async def _body(database, children, rules, definitions, child):
bo = await children.create("Bo", NOW)
ada_only = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
both = await create_quest_definition(
database,
"Tidy room",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={1},
start_date="2026-09-14",
),
[child.id, bo.id],
["evening"],
)
bo_only = await create_quest_definition(
database, "Walk dog", _daily_rule(), [bo.id], ["afternoon"]
)
# The helper returns the given child's definitions in ascending
# id order, consistent with list_active_definitions.
result = await list_definitions_for_child(database, child.id)
assert [bundle.definition.id for bundle in result] == [
ada_only.definition.id,
both.definition.id,
]
assert result[0].rule == _daily_rule()
assert [c.id for c in result[0].assignees] == [child.id]
assert [w.window for w in result[0].windows] == ["morning"]
bo_result = await list_definitions_for_child(database, bo.id)
assert [bundle.definition.id for bundle in bo_result] == [
both.definition.id,
bo_only.definition.id,
]
return result
_with_db(tmp_path, "list-for-child.db")(_body)
@pytest.mark.parametrize("bad", [True, False, 1.5, "1", None])
def test_list_definitions_for_child_rejects_non_int_child_id(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match="child_id must be an integer"):
await list_definitions_for_child(database, bad)
return None
_with_db(tmp_path, "list-for-child-bad-id.db")(_body)
def test_list_definitions_firing_on_mixed_rules(tmp_path) -> None:
"""A mixed daily + weekly + monthly set fires on the right dates.
Daily fires every day from its start; weekly fires on its weekdays;
monthly fires on its day-of-month. Deactivated definitions never
fire, and the returned bundles carry the decoded rule.
"""
async def _body(database, children, rules, definitions, child):
daily = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-14"),
[child.id],
["morning"],
)
weekly = await create_quest_definition(
database,
"Weekly chore",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0, 2}, # Monday, Wednesday
start_date="2026-09-14",
),
[child.id],
["evening"],
)
monthly = await create_quest_definition(
database,
"Monthly chore",
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY,
day_of_month=15,
start_date="2026-09-14",
),
[child.id],
["afternoon"],
)
# 2026-09-14 is a Monday (weekday 0): daily + weekly fire;
# monthly (the 15th) does not.
hits = await list_definitions_firing_on(database, "2026-09-14")
assert [b.definition.id for b in hits] == [
daily.definition.id,
weekly.definition.id,
]
# 2026-09-15 is a Tuesday (weekday 1, day 15): daily + monthly.
hits = await list_definitions_firing_on(database, "2026-09-15")
assert [b.definition.id for b in hits] == [
daily.definition.id,
monthly.definition.id,
]
# 2026-09-16 is a Wednesday (weekday 2, day 16): daily + weekly.
hits = await list_definitions_firing_on(database, "2026-09-16")
assert [b.definition.id for b in hits] == [
daily.definition.id,
weekly.definition.id,
]
# Deactivating a definition removes it from every future result.
await set_quest_definition_active(database, weekly.definition.id, False)
hits = await list_definitions_firing_on(database, "2026-09-14")
assert [b.definition.id for b in hits] == [daily.definition.id]
# The returned bundle carries the decoded rule, not the raw row.
assert hits[0].rule == ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-14"
)
return hits
_with_db(tmp_path, "list-firing-on.db")(_body)
@pytest.mark.parametrize(
"bad",
["2026-9-4", "2026-13-01", "2026-02-30", "not-a-date", None, 20260914],
)
def test_list_definitions_firing_on_rejects_malformed_date(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
with pytest.raises(ValueError, match="date must be"):
await list_definitions_firing_on(database, bad)
return None
_with_db(tmp_path, "list-firing-on-bad-date.db")(_body)
def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
"""A racing edit cannot mix states into a query helper's snapshot.
The query helper pauses mid-snapshot (inside its locked transaction)
while an edit that changes BOTH the rule and the windows is queued
behind the connection lock. Because the helper reads definitions,
rules and windows in one locked transaction, its returned bundle is
either the whole old state or the whole new state — never the old
rule paired with the new windows (the exact interleaving the unlocked
reads used to allow). The pause hook holds
``list_windows`` mid-flight so the edit is deterministically queued
against the helper's lock.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={2},
start_date="2026-09-14",
)
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_windows
started = asyncio.Event()
release = asyncio.Event()
async def _pausing_list(self, definition_id):
if not started.is_set():
started.set()
await release.wait()
return await original_list(self, definition_id)
dao_rules.QuestDefinitionsDao.list_windows = _pausing_list
try:
read_task = asyncio.ensure_future(
list_definitions_firing_on(database, "2026-09-16")
)
await started.wait()
edit_task = asyncio.ensure_future(
edit_quest_definition(
database,
created.definition.id,
rule=weekly,
windows=["evening"],
)
)
await asyncio.sleep(0)
release.set()
result, edited = await asyncio.gather(read_task, edit_task)
finally:
dao_rules.QuestDefinitionsDao.list_windows = original_list
# The helper finished before the queued edit, so its snapshot is
# the whole OLD state: the daily rule with its morning window.
assert [b.definition.id for b in result] == [created.definition.id]
assert result[0].rule == _daily_rule()
assert [w.window for w in result[0].windows] == ["morning"]
# The edit then reported the whole NEW state, not a mix either.
assert edited.rule == weekly
assert [w.window for w in edited.windows] == ["evening"]
return result
_with_db(tmp_path, "list-snapshot-concurrent.db")(_body)
def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign(
tmp_path,
) -> None:
"""A racing unassign cannot strip the child from the child-scoped
snapshot that ``list_definitions_for_child`` returns.
The helper pauses mid-snapshot (inside its locked transaction) while
an unassign of the very child it is listing is queued behind the
connection lock. Because the definition set and its assignees are
read in one locked transaction, the returned bundle still shows the
child assigned — the coherent pre-unassign snapshot — rather than the
mixed state (definition returned "for child" but with the child
already gone from its roster) that the unlocked reads used to allow.
The pause hook holds ``list_assignees`` mid-flight so the unassign is
deterministically queued against the helper's lock.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_assignees
started = asyncio.Event()
release = asyncio.Event()
async def _pausing_list(self, definition_id):
if not started.is_set():
started.set()
await release.wait()
return await original_list(self, definition_id)
dao_rules.QuestDefinitionsDao.list_assignees = _pausing_list
try:
read_task = asyncio.ensure_future(
list_definitions_for_child(database, child.id)
)
await started.wait()
unassign_task = asyncio.ensure_future(
unassign_child(database, created.definition.id, child.id)
)
for _ in range(5):
await asyncio.sleep(0)
release.set()
result, removed = await asyncio.gather(read_task, unassign_task)
finally:
dao_rules.QuestDefinitionsDao.list_assignees = original_list
# The helper finished before the queued unassign, so its snapshot
# is the whole OLD state: the child still assigned to the
# definition it returned for that child.
assert [b.definition.id for b in result] == [created.definition.id]
assert [c.id for c in result[0].assignees] == [child.id]
assert removed is True
return result
_with_db(tmp_path, "list-for-child-snapshot-concurrent.db")(_body)
def test_list_active_definitions_snapshot_coherent_under_racing_edit(
tmp_path,
) -> None:
"""A racing edit cannot mix states into ``list_active_definitions``.
The helper pauses mid-snapshot (inside its locked transaction) while
an edit that changes BOTH the rule and the windows is queued behind
the connection lock. Because definitions, rules and windows are read
in one locked transaction, the returned bundle is the whole old state
— the daily rule with its morning window — never the old rule paired
with the new windows. The pause hook holds ``list_windows``
mid-flight so the edit is deterministically queued against the
helper's lock.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={2},
start_date="2026-09-14",
)
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_windows
started = asyncio.Event()
release = asyncio.Event()
async def _pausing_list(self, definition_id):
if not started.is_set():
started.set()
await release.wait()
return await original_list(self, definition_id)
dao_rules.QuestDefinitionsDao.list_windows = _pausing_list
try:
read_task = asyncio.ensure_future(list_active_definitions(database))
await started.wait()
edit_task = asyncio.ensure_future(
edit_quest_definition(
database,
created.definition.id,
rule=weekly,
windows=["evening"],
)
)
await asyncio.sleep(0)
release.set()
result, edited = await asyncio.gather(read_task, edit_task)
finally:
dao_rules.QuestDefinitionsDao.list_windows = original_list
# The helper finished before the queued edit, so its snapshot is
# the whole OLD state: the daily rule with its morning window.
assert [b.definition.id for b in result] == [created.definition.id]
assert result[0].rule == _daily_rule()
assert [w.window for w in result[0].windows] == ["morning"]
# The edit then reported the whole NEW state, not a mix either.
assert edited.rule == weekly
assert [w.window for w in edited.windows] == ["evening"]
return result
_with_db(tmp_path, "list-active-snapshot-concurrent.db")(_body)
# ---------------------------------------------------------------------------
# full-lifecycle integration: one definition through every operation
# ---------------------------------------------------------------------------
def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
"""End-to-end lifecycle on one definition writes only the four
definition tables; ``quest_instances`` and ``completion_events`` are
left exactly as they were at every stage.
A real instance and completion event are seeded before the lifecycle
begins, so each stage is checked against pre-existing data being
mutated (not an empty table staying empty): create -> edit
rule/windows -> assign a second child -> unassign the original child
-> deactivate -> reactivate.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Brush teeth",
_daily_rule(),
[child.id],
[("morning", "07:00")],
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
event = await events.append(
instance.id,
child.id,
"completed",
"user",
NOW,
True,
actor_user_id="admin-1",
)
instance_count = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
)
event_count = await database.fetch_one(
"SELECT COUNT(*) FROM completion_events"
)
async def _assert_history_untouched():
assert await instances.get_by_id(instance.id) == instance
assert await events.list_by_instance(instance.id) == [event]
assert await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
) == instance_count
assert await database.fetch_one(
"SELECT COUNT(*) FROM completion_events"
) == event_count
await _assert_history_untouched()
# Edit rule and windows together; the definition's rule row is
# rewritten in place and its window set replaced.
edited = await edit_quest_definition(
database,
definition_id,
rule=ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={1, 3},
start_date="2026-09-14",
),
windows=[("morning", "08:00"), ("evening", "19:30")],
)
assert edited.rule == ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={1, 3},
start_date="2026-09-14",
)
by_name = {w.window: w.due_time for w in edited.windows}
assert by_name == {"morning": "08:00", "evening": "19:30"}
await _assert_history_untouched()
# Assign a second child: multi-assignee, both present.
bo = await children.create("Bo", NOW)
roster = await assign_child(database, definition_id, bo.id)
assert sorted(c.id for c in roster) == sorted([child.id, bo.id])
await _assert_history_untouched()
# Unassign the original child: only the link drops.
assert await unassign_child(database, definition_id, child.id) is True
assert [
c.id for c in await definitions.list_assignees(definition_id)
] == [bo.id]
await _assert_history_untouched()
# Deactivate, then reactivate: the definition survives both.
deactivated = await set_quest_definition_active(
database, definition_id, False
)
assert deactivated.definition.is_active is False
await _assert_history_untouched()
reactivated = await set_quest_definition_active(
database, definition_id, True
)
assert reactivated.definition.is_active is True
await _assert_history_untouched()
return reactivated
_with_db(tmp_path, "full-lifecycle.db")(_body)