Implement core materialization walk (Feature 07)
This commit is contained in:
@@ -276,4 +276,75 @@ class PresenceOverridesDao:
|
|||||||
"DELETE FROM presence_overrides WHERE id = ?",
|
"DELETE FROM presence_overrides WHERE id = ?",
|
||||||
(override_id,),
|
(override_id,),
|
||||||
)
|
)
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def load_snapshot(
|
||||||
|
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`` in ONE transaction.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Both tables are read inside one BEGIN..COMMIT span under the
|
||||||
|
connection-scoped lock, so the returned pairing is a coherent snapshot
|
||||||
|
— a concurrent schedule upsert or override create cannot interleave
|
||||||
|
between the two reads. Children are validated as existing up front so
|
||||||
|
an unknown ``child_id`` raises ValueError rather than being silently
|
||||||
|
read as "present every day", mirroring
|
||||||
|
:meth:`PresenceSchedulesDao.get_by_child`.
|
||||||
|
"""
|
||||||
|
_validate_date(range_start, "range_start")
|
||||||
|
_validate_date(range_end, "range_end")
|
||||||
|
if range_end < range_start:
|
||||||
|
raise ValueError(
|
||||||
|
"range_end must be on or after range_start, got "
|
||||||
|
f"{range_end!r} < {range_start!r}"
|
||||||
|
)
|
||||||
|
children = sorted(set(child_ids))
|
||||||
|
if not children:
|
||||||
|
return {}, {}
|
||||||
|
placeholders = ",".join("?" for _ in children)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
async with database.transaction():
|
||||||
|
existing_rows = await database.fetch_all(
|
||||||
|
f"SELECT id FROM children WHERE id IN ({placeholders})",
|
||||||
|
tuple(children),
|
||||||
|
)
|
||||||
|
existing = {row[0] for row in existing_rows}
|
||||||
|
unknown = sorted(set(children) - existing)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"child {unknown[0]} does not exist")
|
||||||
|
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
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""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:
|
||||||
|
|
||||||
|
- Every active definition is read with its decoded
|
||||||
|
:class:`~.recurrence.ScheduleRule`, assignees and windows through the
|
||||||
|
Feature 06 snapshot helpers, so each bundle is a coherent read.
|
||||||
|
- Presence is snapshotted into an immutable
|
||||||
|
:class:`~.presence.PresenceEngine` from the schedules and overrides of
|
||||||
|
every assignee child, read in one locked transaction (see
|
||||||
|
:func:`~.dao_presence.load_snapshot`).
|
||||||
|
- 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
|
||||||
|
upserted through :class:`~.dao_instances.QuestInstancesDao.upsert` with
|
||||||
|
the window's ``due_time`` snapshotted and a ``generated_at`` stamp
|
||||||
|
shared by the whole batch (one coherent time per run).
|
||||||
|
|
||||||
|
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_presence import load_snapshot
|
||||||
|
from .dao_rules import (
|
||||||
|
QuestDefinitionsDao,
|
||||||
|
_validate_date,
|
||||||
|
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 (idempotent — a re-run for the same batch
|
||||||
|
refreshes the same rows, never duplicates them).
|
||||||
|
|
||||||
|
The active definitions and the presence inputs are each read as a
|
||||||
|
coherent snapshot, and the presence schedule/overrides are read under
|
||||||
|
one locked transaction so the walk sees a single stable view of who
|
||||||
|
is where on each date.
|
||||||
|
"""
|
||||||
|
_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 = await QuestDefinitionsDao(database).list_snapshots_active()
|
||||||
|
assignee_child_ids = sorted(
|
||||||
|
{
|
||||||
|
child.id
|
||||||
|
for snapshot in snapshots
|
||||||
|
for child in snapshot.assignees
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
schedules_records, overrides_records = await load_snapshot(
|
||||||
|
database, assignee_child_ids, 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:
|
||||||
|
await instances.upsert(
|
||||||
|
definition_id,
|
||||||
|
child.id,
|
||||||
|
iso,
|
||||||
|
generated_at,
|
||||||
|
window=window.window,
|
||||||
|
due_time=window.due_time,
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
|
return count
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""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.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
|
||||||
|
return first
|
||||||
|
|
||||||
|
_with_db(tmp_path, "materialize-idempotent.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)
|
||||||
Reference in New Issue
Block a user