Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a0a08194 | ||
|
|
c1f0d6c27f | ||
|
|
46ee78438c | ||
|
|
8eb23a3248 | ||
|
|
2146531665 | ||
|
|
168bb7b2f1 | ||
|
|
4544f78390 |
@@ -47,6 +47,18 @@ from .db import NestQuestDatabase
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
"""Return the current local calendar date (the single date source).
|
||||
|
||||
Every "today" read in this module goes through this one function so
|
||||
the delete clamp and the materialize walk's no-past guard read the
|
||||
same clock, and a caller or test can pin/roll that clock
|
||||
deterministically (e.g. a queued-across-midnight regression). Pure
|
||||
clock read — no business rule lives here.
|
||||
"""
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||
|
||||
@@ -203,7 +215,7 @@ class QuestInstancesDao:
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
today = datetime.date.today().isoformat()
|
||||
today = _today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
@@ -214,7 +226,7 @@ class QuestInstancesDao:
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers.
|
||||
today = datetime.date.today().isoformat()
|
||||
today = _today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: "
|
||||
@@ -314,15 +326,19 @@ class QuestInstancesDao:
|
||||
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).
|
||||
no-past rule (instances are never generated in the past) is
|
||||
preserved from :meth:`upsert` and still raises ValueError. An
|
||||
instance that ALREADY has a completion event is also skipped
|
||||
(returns ``None``) rather than raised: it is immutable, so the
|
||||
walk leaves it untouched instead of failing the batch — the
|
||||
generic :meth:`upsert` keeps its hard refusal for the
|
||||
delete/regenerate path.
|
||||
"""
|
||||
_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()
|
||||
today = _today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
@@ -330,7 +346,7 @@ class QuestInstancesDao:
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
today = datetime.date.today().isoformat()
|
||||
today = _today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: "
|
||||
@@ -359,12 +375,12 @@ class QuestInstancesDao:
|
||||
(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"
|
||||
)
|
||||
# The instance is already completed and therefore
|
||||
# immutable: the walk SKIPS it (no-op) rather than
|
||||
# rewriting its snapshot columns or failing the
|
||||
# batch. This is the materialization walk's skip,
|
||||
# distinct from the generic :meth:`upsert` refusal.
|
||||
return None
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
@@ -471,7 +487,7 @@ class QuestInstancesDao:
|
||||
# midnight cannot delete instances that became past
|
||||
# while it waited (backdating is clamped to the
|
||||
# execution date, not the call date).
|
||||
today = datetime.date.today().isoformat()
|
||||
today = _today().isoformat()
|
||||
effective_cutoff = max(cutoff_date, today)
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||
@@ -483,6 +499,49 @@ class QuestInstancesDao:
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_future_uncompleted_for_child(
|
||||
self, child_id: int, cutoff_date: str
|
||||
) -> tuple[int, str]:
|
||||
"""Delete the child's open instances at/after cutoff, across every
|
||||
definition, returning ``(deleted, effective_cutoff)``.
|
||||
|
||||
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||
used when a child's presence changes (Feature 09): only instances
|
||||
at or after the cutoff with NO completion event are removed, inside
|
||||
one transaction — an instance that gains a completion event
|
||||
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||
today, for the same reason as the definition-scoped method (see its
|
||||
docstring). Any instance with a completion event is never touched,
|
||||
regardless of its date.
|
||||
|
||||
Returns the number deleted and the effective cutoff that was
|
||||
actually applied (``max(cutoff_date, today)``, read under the
|
||||
lock). The caller (:func:`~.materialize.regenerate_for_child`)
|
||||
passes that effective cutoff back as its re-materialization anchor,
|
||||
so the delete and the rebuild share ONE execution-day even when the
|
||||
call is queued across midnight — instead of the caller re-reading a
|
||||
stale "today" and materializing a window the delete already
|
||||
superseded.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock, matching the
|
||||
# definition-scoped method: the DELETE executes on this
|
||||
# date, so a caller queued across midnight cannot delete
|
||||
# instances that became past while it waited.
|
||||
today = _today().isoformat()
|
||||
effective_cutoff = max(cutoff_date, today)
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(child_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount, effective_cutoff
|
||||
|
||||
|
||||
class CompletionEventsDao:
|
||||
"""Typed append-only access to the ``completion_events`` table.
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
: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.
|
||||
windows; HA scheduling (day-rollover triggers) is a separate later
|
||||
task and lives elsewhere.
|
||||
|
||||
The walk is deterministic and idempotent:
|
||||
|
||||
@@ -32,15 +32,39 @@ 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.
|
||||
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; a tuple whose instance is already completed is SKIPPED
|
||||
(never rewritten, never raised) by
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||
|
||||
Regeneration: a config or presence change must rebuild future instances
|
||||
so they track the new state. :func:`regenerate_for_definition` covers
|
||||
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||
covers a single child's presence change (Feature 09). Both delete the
|
||||
affected open instances at or after today — never a completed instance,
|
||||
never the past — and re-run the walk over the rolling horizon
|
||||
``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||
|
||||
HOOK (Feature 09 presence services): the presence business layer that
|
||||
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||
presence-state write so a child's future instances track its presence:
|
||||
``set_presence_pattern`` (including clearing/setting an empty pattern),
|
||||
deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
``create_presence_override``, and ``delete_presence_override``. A
|
||||
schedule delete changes presence state too (the child becomes
|
||||
always-present), so it must regenerate just like the others. This hook
|
||||
is documented, not yet wired: no presence business layer exists yet, and
|
||||
the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||
— never call :func:`regenerate_for_child` from inside the DAO layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_instances import QuestInstancesDao
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import QuestInstancesDao, _today
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
@@ -101,6 +125,8 @@ async def materialize(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
child_ids: list[int] | None = None,
|
||||
) -> int:
|
||||
"""Generate quest instances for the closed range [start_date, end_date].
|
||||
|
||||
@@ -110,6 +136,12 @@ async def materialize(
|
||||
of instances upserted for the range (idempotent — a re-run refreshes
|
||||
the same rows, never duplicates them).
|
||||
|
||||
``child_ids`` optionally scopes the walk to exactly those children:
|
||||
assignees outside the list are never written, so a caller that only
|
||||
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||
after a presence change) cannot touch any unrelated child's rows.
|
||||
When omitted (or None) the walk covers every assignee as before.
|
||||
|
||||
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
|
||||
@@ -129,6 +161,7 @@ async def materialize(
|
||||
load_materialization_input(database, start_date, end_date)
|
||||
)
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
child_filter = None if child_ids is None else set(child_ids)
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
@@ -150,6 +183,8 @@ async def materialize(
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
@@ -164,4 +199,104 @@ async def materialize(
|
||||
if written is not None:
|
||||
count += 1
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return count
|
||||
return count
|
||||
|
||||
|
||||
async def regenerate_for_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
) -> int:
|
||||
"""Regenerate a definition's future instances after a config change.
|
||||
|
||||
The Feature 06 edit/assignment/activation paths call this after a
|
||||
successful write so future instances track the new state: the
|
||||
definition's open instances at or after today are deleted (never a
|
||||
completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
|
||||
assignee set and windows re-materialize against today's state.
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||
re-materialization range share one anchor, and the walk's
|
||||
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||
range. Returns the number of instances the re-materialization
|
||||
upserted across the whole snapshot (idempotent — other active
|
||||
definitions' tuples refresh in place, never duplicate).
|
||||
"""
|
||||
today = _today()
|
||||
start_date = today.isoformat()
|
||||
end_date = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
await instances.delete_future_uncompleted(definition_id, start_date)
|
||||
return await materialize(database, start_date, end_date)
|
||||
|
||||
|
||||
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||
# presence business layer that lands in Feature 09 MUST call
|
||||
# ``regenerate_for_child`` after EVERY presence-state write, so future
|
||||
# instances track a child's changed presence: set_presence_pattern
|
||||
# (including clearing/setting an empty pattern), deleting/clearing the
|
||||
# presence schedule (PresenceSchedulesDao.delete),
|
||||
# create_presence_override, and delete_presence_override. A schedule
|
||||
# delete changes presence state too (the child becomes always-present),
|
||||
# so it must regenerate just like the others. It is deliberately NOT
|
||||
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
|
||||
# business rules), and no presence business layer exists yet to host the
|
||||
# call — see the module docstring note above.
|
||||
async def regenerate_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
) -> int:
|
||||
"""Regenerate a child's future instances after a presence change.
|
||||
|
||||
The Feature 09 presence services call this after every
|
||||
presence-state write — set_presence_pattern (including clearing or
|
||||
setting an empty pattern), deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
create_presence_override, and delete_presence_override — so a
|
||||
child's future instances track its new presence: the child's open
|
||||
instances at or after today are deleted across ALL its definitions
|
||||
(never a completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
|
||||
so the child's now-absent/present dates re-materialize against
|
||||
today's presence WITHOUT touching any other child's rows.
|
||||
|
||||
``child_id`` must be a plain int — bool and float are rejected
|
||||
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||
id must never mutate the wrong profile).
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||
delete's returned effective cutoff (see
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||
so the delete cutoff and the re-materialization window share ONE
|
||||
execution-day anchor even when the call is queued across midnight.
|
||||
Returns the number of instances the re-materialization upserted for
|
||||
the child across the whole snapshot.
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
|
||||
today = _today()
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
# Use the delete's returned effective cutoff as the materialize anchor,
|
||||
# NOT the independently-computed "today" above. If the call is queued
|
||||
# across midnight the delete clamps its cutoff to the execution-day;
|
||||
# materializing from the stale "today" (now yesterday) would trip the
|
||||
# walk's no-past guard and leave the child's future instances deleted
|
||||
# but not rebuilt. One anchor for both keeps them consistent.
|
||||
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
|
||||
child_id, today.isoformat()
|
||||
)
|
||||
start = datetime.date.fromisoformat(effective_cutoff)
|
||||
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
return await materialize(
|
||||
database, start.isoformat(), end_date, child_ids=[child_id]
|
||||
)
|
||||
|
||||
@@ -58,6 +58,7 @@ from .dao_rules import (
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_definition
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
@@ -341,6 +342,8 @@ async def edit_quest_definition(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
await regenerate_for_definition(database, definition_id)
|
||||
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
@@ -371,7 +374,7 @@ async def assign_child(
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
return await dao.add_assignee(definition_id, child_id)
|
||||
assignees = await dao.add_assignee(definition_id, child_id)
|
||||
except ValueError as error:
|
||||
# The DAO names only the child/definition; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
@@ -381,6 +384,8 @@ async def assign_child(
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
await regenerate_for_definition(database, definition_id)
|
||||
return assignees
|
||||
|
||||
|
||||
async def unassign_child(
|
||||
@@ -405,7 +410,9 @@ async def unassign_child(
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} does not exist"
|
||||
)
|
||||
return await dao.remove_assignee(definition_id, child_id)
|
||||
removed = await dao.remove_assignee(definition_id, child_id)
|
||||
await regenerate_for_definition(database, definition_id)
|
||||
return removed
|
||||
|
||||
|
||||
async def set_quest_definition_active(
|
||||
@@ -457,6 +464,7 @@ async def set_quest_definition_active(
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(rule_record)
|
||||
)
|
||||
await regenerate_for_definition(database, definition_id)
|
||||
return CreatedQuestDefinition(
|
||||
definition=updated,
|
||||
rule=rule,
|
||||
|
||||
+491
-12
@@ -7,16 +7,31 @@ 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_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.materialize import (
|
||||
materialize,
|
||||
regenerate_for_child,
|
||||
regenerate_for_definition,
|
||||
)
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
edit_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -207,15 +222,33 @@ def test_materialize_is_idempotent(tmp_path) -> None:
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
dao = QuestInstancesDao(database)
|
||||
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
|
||||
assert first == 7
|
||||
first_ids = sorted(
|
||||
r.id
|
||||
for r in await dao.list_by_date_range(child.id, start_iso, end_iso)
|
||||
)
|
||||
assert len(first_ids) == 7
|
||||
assert len(set(first_ids)) == 7
|
||||
|
||||
# Re-running over the same range refreshes the SAME physical rows:
|
||||
# the ON CONFLICT path updates the snapshot columns in place and
|
||||
# never delete + reinserts, so every primary-key id is stable.
|
||||
for _ in range(2):
|
||||
count = await materialize(database, start_iso, end_iso)
|
||||
assert count == 7
|
||||
run_ids = sorted(
|
||||
r.id
|
||||
for r in await dao.list_by_date_range(
|
||||
child.id, start_iso, end_iso
|
||||
)
|
||||
)
|
||||
assert run_ids == first_ids
|
||||
|
||||
# And the widened key never duplicated: exactly one physical row
|
||||
# per (definition_id, child_id, due_date, window).
|
||||
records = await dao.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
|
||||
@@ -227,6 +260,65 @@ def test_materialize_is_idempotent(tmp_path) -> None:
|
||||
_with_db(tmp_path, "materialize-idempotent.db")(_body)
|
||||
|
||||
|
||||
def test_materialize_skips_completed_instance_on_re_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=2)).isoformat()
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||
[child.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
|
||||
await materialize(database, start_iso, end_iso)
|
||||
dao = QuestInstancesDao(database)
|
||||
records = await dao.list_by_date_range(child.id, start_iso, end_iso)
|
||||
assert len(records) == 3
|
||||
completed = records[0]
|
||||
|
||||
await CompletionEventsDao(database).append(
|
||||
completed.id,
|
||||
child.id,
|
||||
"completed",
|
||||
"user",
|
||||
f"{start_iso}T08:00:00+00:00",
|
||||
True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
|
||||
# Change the definition so the tuple would otherwise regenerate:
|
||||
# the morning window's due time moves from 09:00 to 10:15.
|
||||
await edit_quest_definition(
|
||||
database,
|
||||
created.definition.id,
|
||||
windows=[("morning", "10:15")],
|
||||
)
|
||||
|
||||
# Re-running must skip the completed instance (no exception) and
|
||||
# leave it untouched, while the open days regenerate to 10:15.
|
||||
count = await materialize(database, start_iso, end_iso)
|
||||
assert count == 2
|
||||
|
||||
refreshed = await dao.list_by_date_range(child.id, start_iso, end_iso)
|
||||
by_due_date = {r.due_date: r for r in refreshed}
|
||||
survived = by_due_date[completed.due_date]
|
||||
assert survived.id == completed.id
|
||||
assert survived.due_time == "09:00"
|
||||
assert survived.generated_at == completed.generated_at
|
||||
|
||||
others = [r for r in refreshed if r.id != completed.id]
|
||||
assert len(others) == 2
|
||||
assert {r.due_time for r in others} == {"10:15"}
|
||||
return count
|
||||
|
||||
_with_db(tmp_path, "materialize-skip-completed.db")(_body)
|
||||
|
||||
|
||||
async def _materialize_with_change(
|
||||
database, start_iso, end_iso, change
|
||||
) -> int:
|
||||
@@ -561,4 +653,391 @@ def test_materialize_skips_inactive_definitions(tmp_path) -> None:
|
||||
assert count == 0
|
||||
return count
|
||||
|
||||
_with_db(tmp_path, "materialize-inactive.db")(_body)
|
||||
_with_db(tmp_path, "materialize-inactive.db")(_body)
|
||||
|
||||
|
||||
def test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database):
|
||||
children = ChildrenDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
|
||||
today = datetime.date.today()
|
||||
horizon_end = today + datetime.timedelta(days=14)
|
||||
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")],
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
|
||||
dao = QuestInstancesDao(database)
|
||||
await materialize(database, start_iso, horizon_end.isoformat())
|
||||
baseline = await dao.list_by_date_range(
|
||||
child.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
assert len(baseline) == 15
|
||||
|
||||
# Park a completion on tomorrow. The rule rewrite below gives the
|
||||
# new weekly shape a weekday two days out, so tomorrow never fires
|
||||
# under it — only the completion's immutability keeps the row.
|
||||
completed_due = (today + datetime.timedelta(days=1)).isoformat()
|
||||
completed = next(r for r in baseline if r.due_date == completed_due)
|
||||
await CompletionEventsDao(database).append(
|
||||
completed.id,
|
||||
child.id,
|
||||
"completed",
|
||||
"user",
|
||||
f"{completed_due}T08:00:00+00:00",
|
||||
True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
|
||||
# Rewrite the rule through the DAO (not the business layer) so
|
||||
# this test drives ``regenerate_for_definition`` directly.
|
||||
new_weekday = (today + datetime.timedelta(days=2)).weekday()
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={new_weekday},
|
||||
start_date=start_iso,
|
||||
)
|
||||
await QuestDefinitionsDao(database).edit_definition(
|
||||
definition_id, rule=schedule_rule_to_storage(weekly)
|
||||
)
|
||||
|
||||
await regenerate_for_definition(database, definition_id)
|
||||
|
||||
records = await dao.list_by_date_range(
|
||||
child.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
by_due = {r.due_date: r for r in records}
|
||||
|
||||
# The completed row survived untouched (same physical id).
|
||||
assert by_due[completed_due].id == completed.id
|
||||
|
||||
# Every other horizon day matches the new weekly rule exactly.
|
||||
for offset in range(15):
|
||||
day = today + datetime.timedelta(days=offset)
|
||||
iso = day.isoformat()
|
||||
if iso == completed_due:
|
||||
assert iso in by_due
|
||||
else:
|
||||
assert (iso in by_due) == occurs_on(weekly, day)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "regenerate.db")(_body)
|
||||
|
||||
|
||||
def test_edit_quest_definition_regenerates_future_instances(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
children = ChildrenDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
|
||||
today = datetime.date.today()
|
||||
horizon_end = today + datetime.timedelta(days=14)
|
||||
start_iso = today.isoformat()
|
||||
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
|
||||
dao = QuestInstancesDao(database)
|
||||
await materialize(database, start_iso, horizon_end.isoformat())
|
||||
|
||||
# The business-layer edit wires regeneration: no explicit
|
||||
# ``regenerate_for_definition`` call is made here.
|
||||
new_weekday = (today + datetime.timedelta(days=2)).weekday()
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={new_weekday},
|
||||
start_date=start_iso,
|
||||
)
|
||||
await edit_quest_definition(database, definition_id, rule=weekly)
|
||||
|
||||
records = await dao.list_by_date_range(
|
||||
child.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
by_due = {r.due_date: r for r in records}
|
||||
for offset in range(15):
|
||||
day = today + datetime.timedelta(days=offset)
|
||||
assert (day.isoformat() in by_due) == occurs_on(weekly, day)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "edit-regenerates.db")(_body)
|
||||
|
||||
|
||||
def test_regenerate_for_child_rebuilds_horizon_preserves_history(
|
||||
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)
|
||||
|
||||
today = datetime.date.today()
|
||||
horizon_end = today + datetime.timedelta(days=14)
|
||||
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")],
|
||||
)
|
||||
|
||||
dao = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
|
||||
# Instances across the full horizon: 15 daily rows.
|
||||
await materialize(database, start_iso, horizon_end.isoformat())
|
||||
all_future = await dao.list_by_date_range(
|
||||
child.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
assert len(all_future) == 15
|
||||
|
||||
# A completed instance on tomorrow: immutable, must be spared.
|
||||
completed_due = (today + datetime.timedelta(days=1)).isoformat()
|
||||
completed = next(r for r in all_future if r.due_date == completed_due)
|
||||
await events.append(
|
||||
completed.id,
|
||||
child.id,
|
||||
"completed",
|
||||
"user",
|
||||
f"{completed_due}T08:00:00+00:00",
|
||||
True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
|
||||
# A past instance (yesterday): raw insert bypasses the walk's
|
||||
# no-past guard so the child-scoped delete has history to spare.
|
||||
past_due = (today - datetime.timedelta(days=1)).isoformat()
|
||||
await database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, 'morning', ?, '09:00', ?)",
|
||||
(created.definition.id, child.id, past_due, NOW),
|
||||
)
|
||||
|
||||
# An ABSENT override covering three future dates that had
|
||||
# instances: days 2..4 after today.
|
||||
absent_start = (today + datetime.timedelta(days=2)).isoformat()
|
||||
absent_end = (today + datetime.timedelta(days=4)).isoformat()
|
||||
override = await overrides.create(
|
||||
child.id, absent_start, absent_end, False, note="away"
|
||||
)
|
||||
|
||||
await regenerate_for_child(database, child.id)
|
||||
|
||||
records = await dao.list_by_date_range(
|
||||
child.id, past_due, horizon_end.isoformat()
|
||||
)
|
||||
by_due = {r.due_date: r for r in records}
|
||||
|
||||
# The now-absent days' instances are gone.
|
||||
for offset in range(2, 5):
|
||||
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||
assert iso not in by_due
|
||||
|
||||
# The completed instance survived untouched (same physical id).
|
||||
assert by_due[completed_due].id == completed.id
|
||||
|
||||
# The past instance survived.
|
||||
assert past_due in by_due
|
||||
|
||||
# Every non-absent future day still has its instance.
|
||||
for offset in range(15):
|
||||
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||
if 2 <= offset <= 4:
|
||||
continue
|
||||
assert iso in by_due
|
||||
|
||||
# Remove the override and regenerate again: the absent days return.
|
||||
await overrides.delete(override.id)
|
||||
await regenerate_for_child(database, child.id)
|
||||
|
||||
records = await dao.list_by_date_range(
|
||||
child.id, past_due, horizon_end.isoformat()
|
||||
)
|
||||
by_due = {r.due_date: r for r in records}
|
||||
|
||||
for offset in range(15):
|
||||
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||
assert iso in by_due
|
||||
|
||||
# The completed instance is still the same untouched row, and the
|
||||
# past instance is still there.
|
||||
assert by_due[completed_due].id == completed.id
|
||||
assert by_due[completed_due].due_time == "09:00"
|
||||
assert past_due in by_due
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "regenerate-child.db")(_body)
|
||||
|
||||
|
||||
def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A call queued across midnight must not delete-then-fail-to-rebuild.
|
||||
|
||||
``regenerate_for_child`` reads "today" once as a cutoff hint, but the
|
||||
delete clamps that cutoff to the execution-day under its own lock and
|
||||
RETURNS the effective cutoff; the re-materialization window is then
|
||||
anchored to the same returned day. This test fakes a midnight rollover
|
||||
between the two reads: the caller's hint is day0 while the delete
|
||||
executes on day1. The rebuild must start on day1 (so the walk's
|
||||
no-past guard never fires) and the now-past day0 instance must survive.
|
||||
"""
|
||||
day0 = datetime.date.today() + datetime.timedelta(days=30)
|
||||
day1 = day0 + datetime.timedelta(days=1)
|
||||
|
||||
clock = {"rolled": False, "reads": 0}
|
||||
|
||||
def _fake_today() -> datetime.date:
|
||||
if not clock["rolled"]:
|
||||
return day0
|
||||
clock["reads"] += 1
|
||||
return day0 if clock["reads"] == 1 else day1
|
||||
|
||||
import custom_components.nestquest.dao_instances as dao_instances_module
|
||||
import custom_components.nestquest.materialize as materialize_module
|
||||
|
||||
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
|
||||
monkeypatch.setattr(materialize_module, "_today", _fake_today)
|
||||
|
||||
async def _body(database):
|
||||
children = ChildrenDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
|
||||
day0_iso = day0.isoformat()
|
||||
horizon_end = day0 + datetime.timedelta(days=14)
|
||||
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=day0_iso),
|
||||
[child.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
dao = QuestInstancesDao(database)
|
||||
|
||||
# Seed the horizon while the clock reads day0.
|
||||
await materialize(database, day0_iso, horizon_end.isoformat())
|
||||
|
||||
# Roll the clock forward: every read after the caller's single
|
||||
# cutoff hint now returns day1 (the delete's execution-day).
|
||||
clock["rolled"] = True
|
||||
clock["reads"] = 0
|
||||
|
||||
rebuilt = await regenerate_for_child(database, child.id)
|
||||
|
||||
# No raise occurred; the rebuilt window is anchored on the delete's
|
||||
# execution-day (day1), so day1..day1+14 all exist.
|
||||
assert rebuilt == 15
|
||||
day1_iso = day1.isoformat()
|
||||
day1_horizon_end = day1 + datetime.timedelta(days=14)
|
||||
assert len(await dao.list_by_date_range(
|
||||
child.id, day1_iso, day1_horizon_end.isoformat()
|
||||
)) == 15
|
||||
|
||||
# day0 was the caller's "today" but rolled over to the past by the
|
||||
# time the delete ran; it must survive (never delete the past).
|
||||
assert await dao.get(
|
||||
created.definition.id, child.id, day0_iso, "morning"
|
||||
) is not None
|
||||
return rebuilt
|
||||
|
||||
_with_db(tmp_path, "regenerate-child-midnight.db")(_body)
|
||||
|
||||
|
||||
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
||||
"""Regenerating one child must never touch another child's rows."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
children = ChildrenDao(database)
|
||||
overrides = PresenceOverridesDao(database)
|
||||
a = await children.create("Ada", NOW)
|
||||
b = await children.create("Bo", NOW)
|
||||
|
||||
today = datetime.date.today()
|
||||
horizon_end = today + datetime.timedelta(days=14)
|
||||
start_iso = today.isoformat()
|
||||
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||
[a.id, b.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
|
||||
dao = QuestInstancesDao(database)
|
||||
await materialize(database, start_iso, horizon_end.isoformat())
|
||||
|
||||
# Baseline for child b, keyed by due_date, so we can prove its rows
|
||||
# are byte-for-byte unchanged afterward.
|
||||
baseline_b = {
|
||||
r.due_date: (r.id, r.due_time, r.generated_at)
|
||||
for r in await dao.list_by_date_range(
|
||||
b.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
}
|
||||
assert len(baseline_b) == 15
|
||||
|
||||
# An ABSENT override for child a only, covering days 2..4.
|
||||
absent_start = (today + datetime.timedelta(days=2)).isoformat()
|
||||
absent_end = (today + datetime.timedelta(days=4)).isoformat()
|
||||
await overrides.create(
|
||||
a.id, absent_start, absent_end, False, note="away"
|
||||
)
|
||||
|
||||
await regenerate_for_child(database, a.id)
|
||||
|
||||
# Child a's now-absent days vanished.
|
||||
a_by_due = {
|
||||
r.due_date: r
|
||||
for r in await dao.list_by_date_range(
|
||||
a.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
}
|
||||
for offset in range(2, 5):
|
||||
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||
assert iso not in a_by_due
|
||||
|
||||
# Child b is untouched: same physical rows, same snapshot columns.
|
||||
b_records = await dao.list_by_date_range(
|
||||
b.id, start_iso, horizon_end.isoformat()
|
||||
)
|
||||
assert len(b_records) == 15
|
||||
for r in b_records:
|
||||
assert (r.id, r.due_time, r.generated_at) == baseline_b[r.due_date]
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
|
||||
|
||||
|
||||
def test_regenerate_for_child_rejects_non_int_child_id(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
for bad in (True, False, 1.0, "1"):
|
||||
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||
await regenerate_for_child(database, bad)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "regenerate-child-badid.db")(_body)
|
||||
@@ -1279,11 +1279,19 @@ def test_set_quest_definition_active_nonexistent_definition_names_field(
|
||||
_with_db(tmp_path, "set-active-missing-definition.db")(_body)
|
||||
|
||||
|
||||
def test_set_quest_definition_active_never_deletes_or_touches_instances(
|
||||
def test_set_quest_definition_active_preserves_completed_and_history(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Deactivation hides the definition but deletes nothing, and
|
||||
pre-existing instances and completion history survive unchanged."""
|
||||
"""Deactivation stops future generation; reactivation resumes it.
|
||||
|
||||
The Feature 07 wiring makes both transitions regenerate the
|
||||
definition's rolling horizon: future OPEN instances are removed on
|
||||
deactivation and re-materialized on reactivation. A pre-existing
|
||||
COMPLETED instance and its completion events always survive both
|
||||
transitions unchanged (regeneration never touches completed rows or
|
||||
the past), and the append-only completion-events table is never
|
||||
rewritten.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
@@ -1310,9 +1318,6 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances(
|
||||
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"
|
||||
)
|
||||
@@ -1332,29 +1337,25 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances(
|
||||
definition_id
|
||||
)] == ["morning"]
|
||||
# The pre-existing instance and completion event survive with
|
||||
# unchanged ids and contents, and no new rows appeared.
|
||||
# unchanged ids and contents, and no new event rows appeared.
|
||||
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 set_quest_definition_active(database, definition_id, True)
|
||||
assert (await definitions.get(definition_id)).is_active is True
|
||||
# Reactivation regenerates future OPEN instances, but the
|
||||
# completed instance and its history survive the sweep unchanged.
|
||||
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
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "set-active-never-deletes.db")(_body)
|
||||
_with_db(tmp_path, "set-active-preserves-completed.db")(_body)
|
||||
|
||||
|
||||
def _gated_hass(
|
||||
@@ -1841,16 +1842,15 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None:
|
||||
"""End-to-end lifecycle preserves completed rows and append-only history.
|
||||
|
||||
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.
|
||||
Each stage (create -> edit rule/windows -> assign a second child ->
|
||||
unassign the original child -> deactivate -> reactivate) now
|
||||
regenerates the definition's future OPEN instances (Feature 07
|
||||
wiring), but a pre-existing COMPLETED instance and its completion
|
||||
events survive every stage unchanged — the completed row keeps its
|
||||
id and snapshot columns, the completion-events table stays append-only.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
@@ -1876,24 +1876,18 @@ def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
|
||||
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():
|
||||
async def _assert_history_preserved():
|
||||
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()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# Edit rule and windows together; the definition's rule row is
|
||||
# rewritten in place and its window set replaced.
|
||||
@@ -1914,32 +1908,32 @@ def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
|
||||
)
|
||||
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()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# 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()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# 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()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# 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()
|
||||
await _assert_history_preserved()
|
||||
reactivated = await set_quest_definition_active(
|
||||
database, definition_id, True
|
||||
)
|
||||
assert reactivated.definition.is_active is True
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
|
||||
return reactivated
|
||||
|
||||
|
||||
Reference in New Issue
Block a user