From cbd29f5ab791dba9be1263bdce87a00d1b46f6c9 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 08:11:19 -0400 Subject: [PATCH 01/28] Map ScheduleRule models to and from schedule_rules storage (Feature 06, task 1) (#66) --- custom_components/nestquest/dao_rules.py | 212 +++++++++++++++++++ tests/test_dao_rules.py | 251 +++++++++++++++++++++++ 2 files changed, 463 insertions(+) diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index d5090ee..d994791 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -40,6 +40,7 @@ from .const import QUEST_WINDOWS from .dao_children import ChildRecord, _child_from_row, _connection_lock from .dao_children import _CHILD_COLUMNS from .db import NestQuestDatabase +from .recurrence import RuleType, RuleValidationError, ScheduleRule #: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" #: in update methods: passing ``None`` must mean clearing a nullable @@ -119,6 +120,26 @@ class ScheduleRuleRecord: end_date: str | None +@dataclass(frozen=True) +class ScheduleRuleStorage: + """The eight value columns a ScheduleRule maps to (no id column). + + ``id`` is assigned by the database on insert, so the model<->storage + mapping concerns only the value columns. ``nth_weekday_weekday`` is + deliberately absent here: for MONTHLY_WEEKDAY its value is folded + into ``weekday_set`` as that column's single CSV element. + """ + + rule_type: str + interval: int + weekday_set: str | None + day_of_month: int | None + nth_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + @dataclass(frozen=True) class QuestDefinitionRecord: """One row of ``quest_definitions`` (without its assignees).""" @@ -188,6 +209,197 @@ def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: ) +#: Storage columns that are forbidden for each rule type. A populated +#: forbidden column means the row's shape is invalid and must raise on +#: read, never be silently dropped. ``weekday_set`` is absent for +#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a +#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``. +_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = { + "weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY), + "day_of_month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_WEEKDAY, + ), + "nth_weekday": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.YEARLY, + ), + "month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.MONTHLY_WEEKDAY, + ), +} + + +def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage: + """Map a validated ScheduleRule onto its storage columns. + + ``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and + MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as + a sorted CSV list. MONTHLY_WEEKDAY folds its model-only + ``nth_weekday_weekday`` into ``weekday_set`` as that list's single + element; every other type leaves the column as its own set + (WEEKLY/CUSTOM_DAYS) or NULL. + """ + shape = rule.rule_type + if shape is RuleType.MONTHLY_WEEKDAY: + weekday_set = str(rule.nth_weekday_weekday) + elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set)) + else: + weekday_set = None + return ScheduleRuleStorage( + rule_type=shape.storage_value, + interval=rule.interval, + weekday_set=weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + + +def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: + """Reconstruct a ScheduleRule from storage columns. + + Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is + populated, and raises RuleValidationError rather than silently + mis-decoding an ambiguous or invalid shape. + """ + rule_type = storage.rule_type + if rule_type == "daily": + shape = RuleType.DAILY + elif rule_type == "weekly": + shape = RuleType.WEEKLY + elif rule_type == "monthly": + shape = _disambiguate_monthly(storage) + elif rule_type == "yearly": + shape = RuleType.YEARLY + elif rule_type == "custom": + shape = RuleType.CUSTOM_DAYS + else: + raise RuleValidationError( + f"unknown storage rule_type {rule_type!r}" + ) + + _reject_forbidden_storage_fields(storage, shape) + + weekday_set = None + nth_weekday_weekday = None + if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = _weekdays_from_csv(storage.weekday_set) + elif shape is RuleType.MONTHLY_WEEKDAY: + nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set) + + return ScheduleRule( + rule_type=shape, + interval=storage.interval, + weekday_set=weekday_set, + day_of_month=storage.day_of_month, + nth_weekday=storage.nth_weekday, + nth_weekday_weekday=nth_weekday_weekday, + month=storage.month, + start_date=storage.start_date, + end_date=storage.end_date, + ) + + +def _reject_forbidden_storage_fields( + storage: ScheduleRuleStorage, shape: RuleType +) -> None: + """Raise unless every populated column is permitted for ``shape``. + + The model constructor rejects a forbidden ``day_of_month``, + ``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it + (it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily + row carrying ``weekday_set="0"`` would otherwise decode as a valid + daily rule and silently lose the stored data. This validates every + shape column explicitly so a populated-forbidden column raises + instead of being discarded. + """ + for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items(): + value = getattr(storage, field) + if value is not None and shape in forbidden_shapes: + raise RuleValidationError( + f"{shape.value} storage rows must not set {field}, " + f"got {value!r}" + ) + + +def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType: + """Resolve the shared 'monthly' storage value to a model type. + + MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names + ``nth_weekday`` only. Neither, or both, is ambiguous and raises. + """ + day_of_month = storage.day_of_month + nth_weekday = storage.nth_weekday + if day_of_month is not None and nth_weekday is None: + return RuleType.MONTHLY_DAY + if nth_weekday is not None and day_of_month is None: + return RuleType.MONTHLY_WEEKDAY + raise RuleValidationError( + "storage rule_type 'monthly' is ambiguous: must name exactly one " + "of day_of_month or nth_weekday, got " + f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}" + ) + + +def _weekdays_from_csv(value: str | None) -> frozenset[int]: + """Parse a weekday CSV column into a frozenset of ints. + + ``None`` and ``""`` both yield an empty frozenset (the caller decides + whether empty is legal); a non-integer token raises + RuleValidationError rather than leaking a raw ValueError. + """ + if value is None or value == "": + return frozenset() + try: + entries = [int(token) for token in value.split(",")] + except ValueError: + raise RuleValidationError( + f"weekday_set must be a CSV of integers, got {value!r}" + ) from None + return frozenset(entries) + + +def _single_weekday_from_csv(value: str | None) -> int: + """The one weekday a MONTHLY_WEEKDAY stores in weekday_set. + + Parses the CSV into tokens FIRST and requires exactly one token, + so a duplicate pair like ``"1,1"`` is rejected rather than silently + canonicalized to a single weekday on re-storage. An empty or + multi-element column raises RuleValidationError. + """ + if value is None or value == "": + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + tokens = value.split(",") + if len(tokens) != 1: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + try: + return int(tokens[0]) + except ValueError: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must be an integer, " + f"got {value!r}" + ) from None + + class ScheduleRulesDao: """Typed async access to the ``schedule_rules`` table.""" diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index 8784e73..34565b3 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -9,13 +9,21 @@ import pytest from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_rules import ( ScheduleRuleRecord, + ScheduleRuleStorage, ScheduleRulesDao, QuestDefinitionRecord, QuestDefinitionWindowRecord, QuestDefinitionsDao, + schedule_rule_from_storage, + schedule_rule_to_storage, ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.recurrence import ( + RuleType, + RuleValidationError, + ScheduleRule, +) def _run(coro): @@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( assert create_won is False +# --------------------------------------------------------------------------- +# ScheduleRule <-> storage mapping +# --------------------------------------------------------------------------- + + +_RULE_STORAGE_CASES = [ + ( + ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"), + ScheduleRuleStorage( + "daily", 1, None, None, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.DAILY, + interval=3, + start_date="2026-09-01", + end_date="2026-12-31", + ), + ScheduleRuleStorage( + "daily", 3, None, None, None, None, "2026-09-01", "2026-12-31" + ), + ), + ( + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2, 0, 4}, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "weekly", 1, "0,2,4", None, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, + weekday_set={1, 3, 5}, + interval=2, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "custom", 2, "1,3,5", None, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, + day_of_month=15, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 1, None, 15, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, + day_of_month=31, + interval=3, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 3, None, 31, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=2, + nth_weekday_weekday=1, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 1, "1", None, 2, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=-1, + nth_weekday_weekday=4, + interval=2, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 2, "4", None, -1, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01" + ), + ScheduleRuleStorage( + "yearly", 1, None, None, None, 6, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.YEARLY, + month=2, + day_of_month=29, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "yearly", 1, None, 29, None, 2, "2026-09-01", None + ), + ), +] + + +@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES) +def test_schedule_rule_to_storage_maps_each_type(rule, storage) -> None: + assert schedule_rule_to_storage(rule) == storage + + +@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES) +def test_schedule_rule_from_storage_rebuilds_each_type(rule, storage) -> None: + assert schedule_rule_from_storage(storage) == rule + + +@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES) +def test_schedule_rule_round_trips_losslessly_both_directions( + rule, storage +) -> None: + assert schedule_rule_from_storage(schedule_rule_to_storage(rule)) == rule + assert schedule_rule_to_storage(schedule_rule_from_storage(storage)) == storage + + +@pytest.mark.parametrize( + "storage", + [ + # monthly with neither day_of_month nor nth_weekday + ScheduleRuleStorage( + "monthly", 1, None, None, None, None, "2026-09-01", None + ), + # monthly with both day_of_month and nth_weekday + ScheduleRuleStorage( + "monthly", 1, None, 15, 2, None, "2026-09-01", None + ), + # MONTHLY_WEEKDAY whose weekday_set is None + ScheduleRuleStorage( + "monthly", 1, None, None, 2, None, "2026-09-01", None + ), + # MONTHLY_WEEKDAY whose weekday_set is empty + ScheduleRuleStorage("monthly", 1, "", None, 2, None, "2026-09-01", None), + # MONTHLY_WEEKDAY whose weekday_set is multi-element + ScheduleRuleStorage( + "monthly", 1, "1,3", None, 2, None, "2026-09-01", None + ), + # unknown storage rule_type + ScheduleRuleStorage( + "sometimes", 1, None, None, None, None, "2026-09-01", None + ), + ], +) +def test_schedule_rule_from_storage_rejects_ambiguous_or_invalid( + storage, +) -> None: + with pytest.raises(RuleValidationError): + schedule_rule_from_storage(storage) + + +def test_schedule_rule_from_storage_rejects_malformed_weekday_csv() -> None: + with pytest.raises(RuleValidationError): + schedule_rule_from_storage( + ScheduleRuleStorage( + "weekly", 1, "0,2,x", None, None, None, "2026-09-01", None + ) + ) + with pytest.raises(RuleValidationError): + schedule_rule_from_storage( + ScheduleRuleStorage( + "weekly", 1, "7", None, None, None, "2026-09-01", None + ) + ) + + +@pytest.mark.parametrize( + "storage", + [ + # DAILY carrying shape columns it must not hold. + ScheduleRuleStorage( + "daily", 1, "0", None, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "daily", 1, None, 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "daily", 1, None, None, 2, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "daily", 1, None, None, None, 6, "2026-09-01", None + ), + # WEEKLY carrying shape columns it must not hold. + ScheduleRuleStorage( + "weekly", 1, "0,2", 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "weekly", 1, "0,2", None, None, 6, "2026-09-01", None + ), + # CUSTOM_DAYS carrying shape columns it must not hold. + ScheduleRuleStorage( + "custom", 1, "0,2", 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "custom", 1, "0,2", None, None, 6, "2026-09-01", None + ), + # MONTHLY_DAY carrying weekday_set (folded-only column) or month. + ScheduleRuleStorage( + "monthly", 1, "1", 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "monthly", 1, None, 15, None, 6, "2026-09-01", None + ), + # MONTHLY_WEEKDAY carrying a forbidden month. + ScheduleRuleStorage( + "monthly", 1, "1", None, 2, 6, "2026-09-01", None + ), + # YEARLY carrying weekday_set or nth_weekday. + ScheduleRuleStorage( + "yearly", 1, "0", None, None, 6, "2026-09-01", None + ), + ScheduleRuleStorage( + "yearly", 1, None, None, 2, 6, "2026-09-01", None + ), + ], +) +def test_schedule_rule_from_storage_rejects_forbidden_fields(storage) -> None: + with pytest.raises(RuleValidationError): + schedule_rule_from_storage(storage) + + +def test_schedule_rule_from_storage_rejects_duplicate_weekday_token() -> None: + """A MONTHLY_WEEKDAY with a duplicate CSV token ("1,1") must raise, + never silently canonicalize to a single weekday on re-storage.""" + with pytest.raises(RuleValidationError): + schedule_rule_from_storage( + ScheduleRuleStorage( + "monthly", 1, "1,1", None, 2, None, "2026-09-01", None + ) + ) + + # --------------------------------------------------------------------------- # quest_definitions: create / get # --------------------------------------------------------------------------- -- 2.55.0 From d9cdac87d71554d84b830d946e9525e437b8c2a9 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 08:41:56 -0400 Subject: [PATCH 02/28] Create a quest definition with rule, assignees, and windows (Feature 06, task 2) (#67) --- custom_components/nestquest/dao_rules.py | 149 +++++- .../nestquest/quest_definitions.py | 228 +++++++++ tests/test_quest_definitions.py | 463 ++++++++++++++++++ 3 files changed, 826 insertions(+), 14 deletions(-) create mode 100644 custom_components/nestquest/quest_definitions.py create mode 100644 tests/test_quest_definitions.py diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index d994791..672c064 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -163,6 +163,22 @@ class QuestDefinitionWindowRecord: due_time: str | None +@dataclass(frozen=True) +class QuestDefinitionSnapshot: + """A definition plus its assignees and windows, read atomically. + + Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + with the assignees and windows read inside the SAME transaction that + inserted them, so the snapshot is a consistent creation-time view — + a concurrent assignee or window mutation cannot change it between + the write and the read. + """ + + definition: QuestDefinitionRecord + assignees: list[ChildRecord] + windows: list[QuestDefinitionWindowRecord] + + _RULE_COLUMNS = ( "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " "month, start_date, end_date" @@ -400,6 +416,37 @@ def _single_weekday_from_csv(value: str | None) -> int: ) from None +async def _insert_rule_row( + database: NestQuestDatabase, storage: ScheduleRuleStorage +) -> int: + """Insert one ``schedule_rules`` row and return its new id. + + Runs inside the caller's transaction (no lock or transaction of its + own) so a multi-table write can persist the rule together with the + rows that reference it. The caller is responsible for validating + the storage fields — :meth:`ScheduleRulesDao.create` validates the + date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + receives storage already mapped from a validated + :class:`~.recurrence.ScheduleRule`. + """ + result = await database.execute( + "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " + "day_of_month, nth_weekday, month, start_date, end_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + storage.rule_type, + storage.interval, + storage.weekday_set, + storage.day_of_month, + storage.nth_weekday, + storage.month, + storage.start_date, + storage.end_date, + ), + ) + return result.lastrowid + + class ScheduleRulesDao: """Typed async access to the ``schedule_rules`` table.""" @@ -430,22 +477,20 @@ class ScheduleRulesDao: _validate_date(start_date, "start_date") if end_date is not None: _validate_date(end_date, "end_date") - result = await self._database.execute( - "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " - "day_of_month, nth_weekday, month, start_date, end_date) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - rule_type, - interval, - weekday_set, - day_of_month, - nth_weekday, - month, - start_date, - end_date, + rule_id = await _insert_rule_row( + self._database, + ScheduleRuleStorage( + rule_type=rule_type, + interval=interval, + weekday_set=weekday_set, + day_of_month=day_of_month, + nth_weekday=nth_weekday, + month=month, + start_date=start_date, + end_date=end_date, ), ) - rule = await self.get(result.lastrowid) + rule = await self.get(rule_id) assert rule is not None return rule @@ -605,6 +650,82 @@ class QuestDefinitionsDao: assert definition is not None return definition + async def create_with_rule_and_windows( + self, + title: str, + schedule_rule: ScheduleRuleStorage, + created_at: str, + assignee_child_ids: list[int], + windows: list[tuple[str, str | None]], + *, + description: str | None = None, + icon: str | None = None, + ) -> QuestDefinitionSnapshot: + """Insert a rule, definition, assignees and windows atomically. + + The whole write — the schedule rule row, the definition row, + every assignee link and every window declaration — runs inside + ONE transaction under the connection lock, so a validation or + write failure rolls the lot back together (all-or-nothing): the + caller can never observe a definition with a missing rule, + assignee or window, and a rejected create leaves no partial + rows. + + Returns a :class:`QuestDefinitionSnapshot` whose assignees and + windows are read back inside the same transaction, so the + returned view cannot be raced by a concurrent assignee/window + mutation after the write. + + The rule arrives as pre-validated storage fields (mapped from a + :class:`~.recurrence.ScheduleRule` via + :func:`schedule_rule_to_storage`); its dates are re-validated + here so a hand-built storage can never persist a malformed date. + The definition-level ``due_time`` column is deliberately left + NULL — per-window due times supersede it (D-008). Assignees are + validated as existing-and-active at insert time, and windows are + validated for name and strict HH:MM due time, matching + :meth:`upsert_window`. + """ + _validate_date(schedule_rule.start_date, "start_date") + if schedule_rule.end_date is not None: + _validate_date(schedule_rule.end_date, "end_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + for child_id in assignee_child_ids: + await self._validate_assignable_child(child_id) + rule_id = await _insert_rule_row(self._database, schedule_rule) + result = await self._database.execute( + "INSERT INTO quest_definitions (title, description, " + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (title, description, icon, rule_id, None, 1, created_at), + ) + definition_id = result.lastrowid + for child_id in assignee_child_ids: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + definition = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + assert definition is not None + return QuestDefinitionSnapshot( + definition=definition, + assignees=assignees, + windows=window_records, + ) + async def _validate_rule_exists(self, schedule_rule_id: int) -> None: """Raise ValueError unless the rule exists. diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py new file mode 100644 index 0000000..28283e1 --- /dev/null +++ b/custom_components/nestquest/quest_definitions.py @@ -0,0 +1,228 @@ +"""Business layer for creating quest definitions (Feature 06). + +Sits between the Feature 09 service gate and the typed +:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the +all-or-nothing create here, and never touch the DAO SQL directly. +:func:`create_quest_definition` is the create path; there is no +permission check here (that is Feature 09). + +Validation policy mirrors :mod:`.children`: + +- ``title`` is required and trimmed; empty or whitespace-only raises + ValueError. +- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule` + (its constructor is total — rule validation is NOT re-implemented + here). +- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``); + every id is a plain int (bools and floats rejected, since SQLite would + bind them onto a real child) and must reference an existing ACTIVE + child (the DAO enforces the active/exists check inside the same + transaction). Duplicate ids are rejected here, before the composite + primary key could surface them as an :class:`sqlite3.IntegrityError`. +- ``windows`` must be a non-empty list (strictly a ``list``); each entry + is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare + name (no due time) or a ``(name, due_time)`` pair whose due time is a + strict 24-hour HH:MM string. Duplicate window names are rejected + here, before the composite primary key could surface them as an + :class:`sqlite3.IntegrityError`. + +The rule, definition, assignees and windows are persisted in ONE +transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`), +so a rejected or failed create persists nothing. The definition-level +``due_time`` column is superseded by per-window due times (D-008) and is +never set here. ``created_at`` is stamped UTC ISO-8601 at second +precision, matching :mod:`.children`. +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass + +from .dao_children import ChildRecord +from .dao_rules import ( + QuestDefinitionRecord, + QuestDefinitionWindowRecord, + QuestDefinitionsDao, + _validate_time, + _validate_window, + schedule_rule_from_storage, + schedule_rule_to_storage, +) +from .db import NestQuestDatabase +from .recurrence import ScheduleRule + + +@dataclass(frozen=True) +class CreatedQuestDefinition: + """The persisted result of :func:`create_quest_definition`. + + ``definition`` is the stored row; ``rule`` is the schedule rule + decoded back through the storage mapping; ``assignees`` and + ``windows`` are the definition's linked child records and window + declarations, read through the DAO. + """ + + definition: QuestDefinitionRecord + rule: ScheduleRule + assignees: list[ChildRecord] + windows: list[QuestDefinitionWindowRecord] + + +def _now_stamp() -> str: + """Return the module's strict UTC second-precision stamp.""" + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ) + + +def _validate_text( + value: object, field: str, *, required: bool = False +) -> str | None: + """Validate a free-text field and return it trimmed. + + ``None`` raises when ``required`` and otherwise returns None. A + non-string value is rejected; an empty result is rejected for + required fields. + """ + if value is None: + if required: + raise ValueError(f"{field} is required") + return None + if not isinstance(value, str): + raise ValueError(f"{field} must be a string, got {value!r}") + trimmed = value.strip() + if required and not trimmed: + raise ValueError(f"{field} is required") + return trimmed + + +def _validate_assignee_ids(value: object) -> list[int]: + """Return the assignee ids as plain ints, or raise naming the field. + + The argument must be a non-empty list — strictly a ``list``: tuples + and other sequences are rejected. Each entry must be a real int + (bools and floats rejected) so SQLite cannot bind ``True`` onto + child 1 or a float onto a neighbouring profile. + """ + if type(value) is not list: + raise ValueError( + f"assignee_child_ids must be a list, got {type(value).__name__}" + ) + if not value: + raise ValueError("assignee_child_ids must not be empty") + ids: list[int] = [] + for entry in value: + if isinstance(entry, bool) or not isinstance(entry, int): + raise ValueError( + "assignee_child_ids must contain integer child ids, " + f"got {entry!r}" + ) + ids.append(entry) + if len(set(ids)) != len(ids): + raise ValueError( + "assignee_child_ids must not contain duplicate child ids" + ) + return ids + + +def _normalize_windows( + value: object, +) -> list[tuple[str, str | None]]: + """Normalize the windows argument to ``(name, due_time)`` pairs. + + The outer argument must be a non-empty list — strictly a ``list``: + tuples and other sequences are rejected, while each individual entry + may still be a ``(window, due_time)`` tuple. A bare window name + (due time None) is also accepted. The name must be a + :data:`~.const.QUEST_WINDOWS` spelling and a present due time must be + a strict 24-hour HH:MM, both validated up front so a malformed + window is rejected before any write. + """ + if type(value) is not list: + raise ValueError( + f"windows must be a list, got {type(value).__name__}" + ) + if not value: + raise ValueError("windows must not be empty") + normalized: list[tuple[str, str | None]] = [] + seen: set[str] = set() + for index, entry in enumerate(value): + if isinstance(entry, str): + name: object = entry + due_time: object = None + elif isinstance(entry, (tuple, list)) and len(entry) == 2: + name, due_time = entry[0], entry[1] + else: + raise ValueError( + f"windows[{index}] must be a window name or a " + f"(name, due_time) pair, got {entry!r}" + ) + _validate_window(name) + if due_time is not None: + _validate_time(due_time, "due_time") + if name in seen: + raise ValueError( + f"windows must not contain duplicate window {name!r}" + ) + seen.add(name) + normalized.append((name, due_time)) + return normalized + + +async def create_quest_definition( + database: NestQuestDatabase, + title: str, + rule: ScheduleRule, + assignee_child_ids: list[int], + windows: list[str | tuple[str, str | None]], + *, + description: str | None = None, + icon: str | None = None, +) -> CreatedQuestDefinition: + """Create a quest definition (rule + assignees + windows) atomically. + + Validates the arguments up front, then persists the schedule rule, + the definition, its assignees and its windows in one transaction via + the DAO. Returns the stored definition together with its decoded + rule, assignees and windows — all read back inside that same + transaction, so the returned bundle is a consistent creation + snapshot. Raises ValueError on any rejected argument; a rejected or + failed create persists nothing. + """ + name = _validate_text(title, "title", required=True) + assert name is not None + if not isinstance(rule, ScheduleRule): + raise ValueError(f"rule must be a ScheduleRule, got {rule!r}") + assignee_ids = _validate_assignee_ids(assignee_child_ids) + window_specs = _normalize_windows(windows) + description_value = _validate_text(description, "description") + icon_value = _validate_text(icon, "icon") + + storage = schedule_rule_to_storage(rule) + dao = QuestDefinitionsDao(database) + try: + snapshot = await dao.create_with_rule_and_windows( + name, + storage, + _now_stamp(), + assignee_ids, + window_specs, + description=description_value, + icon=icon_value, + ) + except ValueError as error: + # The DAO's assignee check names only the child; re-raise so the + # public contract names the field the caller actually passed. + message = str(error) + if message.startswith("child "): + raise ValueError( + f"assignee_child_ids: {message}" + ) from error + raise + decoded_rule = schedule_rule_from_storage(storage) + return CreatedQuestDefinition( + definition=snapshot.definition, + rule=decoded_rule, + assignees=snapshot.assignees, + windows=snapshot.windows, + ) diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py new file mode 100644 index 0000000..1f99808 --- /dev/null +++ b/tests/test_quest_definitions.py @@ -0,0 +1,463 @@ +"""Tests for quest_definitions.py: the Feature 06 create business layer.""" +from __future__ import annotations + +import asyncio +import re + +import pytest + +from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.dao_rules import ( + QuestDefinitionsDao, + ScheduleRulesDao, + schedule_rule_to_storage, +) +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.quest_definitions import ( + CreatedQuestDefinition, + 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) -> tuple: + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + await apply_migrations(database) + children = ChildrenDao(database) + rules = ScheduleRulesDao(database) + definitions = QuestDefinitionsDao(database) + child = await children.create("Ada", NOW) + return database, children, rules, definitions, child + + +def _with_db(tmp_path, name): + """Run an async body against a prepared database, closing it after.""" + + def _run_test(body): + async def _main(): + database, children, rules, definitions, child = await _prepare( + tmp_path / name + ) + try: + return await body(database, children, rules, definitions, child) + finally: + await database.close() + + return _run(_main()) + + return _run_test + + +def _daily_rule() -> ScheduleRule: + return ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-14") + + +# --------------------------------------------------------------------------- +# create_quest_definition: successful creates +# --------------------------------------------------------------------------- + + +def test_create_one_child_one_window(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + rule = _daily_rule() + result = await create_quest_definition( + database, + " Brush teeth ", + rule, + [child.id], + ["morning"], + description=" Twice daily ", + icon=" mdi:tooth ", + ) + assert isinstance(result, CreatedQuestDefinition) + definition = result.definition + assert definition.title == "Brush teeth" + assert definition.description == "Twice daily" + assert definition.icon == "mdi:tooth" + assert definition.is_active is True + # D-008: the definition-level due_time column is never set here. + assert definition.due_time is None + # Timestamp is stamped UTC at second precision by the business layer. + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", + definition.created_at, + ) + # The rule round-trips through the storage mapping. + assert result.rule == rule + assert definition.schedule_rule_id is not None + rule_row = await rules.get(definition.schedule_rule_id) + assert rule_row is not None + assert rule_row.rule_type == "daily" + # Assignees and windows are read back through the DAO. + assert [c.id for c in result.assignees] == [child.id] + assert [w.window for w in result.windows] == ["morning"] + assert result.windows[0].due_time is None + return result + + _with_db(tmp_path, "create-one.db")(_body) + + +def test_create_several_children_several_windows_with_due_times( + tmp_path, +) -> None: + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + cleo = await children.create("Cleo", NOW) + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0, 2, 4}, + start_date="2026-09-14", + ) + result = await create_quest_definition( + database, + "Chore", + rule, + [child.id, bo.id, cleo.id], + [("morning", "09:00"), ("evening", "19:30")], + ) + assert sorted(c.id for c in result.assignees) == sorted( + [child.id, bo.id, cleo.id] + ) + by_name = {w.window: w.due_time for w in result.windows} + assert by_name == {"morning": "09:00", "evening": "19:30"} + assert result.rule == rule + stored = await definitions.get(result.definition.id) + assert stored is not None + assert stored.due_time is None + return result + + _with_db(tmp_path, "create-many.db")(_body) + + +# --------------------------------------------------------------------------- +# create_quest_definition: argument rejections +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["", " ", "\t\n", None]) +def test_create_rejects_empty_title(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="title is required"): + await create_quest_definition( + database, bad, _daily_rule(), [child.id], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-title.db")(_body) + + +def test_create_rejects_non_string_title(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="title must be a string"): + await create_quest_definition( + database, 42, _daily_rule(), [child.id], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-title-type.db")(_body) + + +@pytest.mark.parametrize("bad", [None, "daily", 42, {"rule_type": "daily"}]) +def test_create_rejects_non_schedule_rule(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="rule must be a ScheduleRule"): + await create_quest_definition( + database, "Brush teeth", bad, [child.id], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-rule.db")(_body) + + +@pytest.mark.parametrize("bad", [[]]) +def test_create_rejects_empty_assignees(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must not be empty" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), bad, ["morning"] + ) + return None + + _with_db(tmp_path, "create-empty-assignees.db")(_body) + + +@pytest.mark.parametrize("bad", [(1,), (1, 2), "1", {1}]) +def test_create_rejects_non_list_assignees(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must be a list" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), bad, ["morning"] + ) + return None + + _with_db(tmp_path, "create-non-list-assignees.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.5, "1", None]) +def test_create_rejects_non_int_assignee(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must contain integer" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [bad], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-assignee.db")(_body) + + +def test_create_rejects_duplicate_assignees(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must not contain duplicate" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id, child.id], + ["morning"], + ) + return None + + _with_db(tmp_path, "create-duplicate-assignees.db")(_body) + + +def test_create_rejects_inactive_child(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + inactive = await children.create("Bo", NOW) + await children.set_active(inactive.id, False) + with pytest.raises( + ValueError, match="assignee_child_ids.*inactive" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [inactive.id], + ["morning"], + ) + return None + + _with_db(tmp_path, "create-inactive-child.db")(_body) + + +def test_create_rejects_unknown_child(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids.*does not exist" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [999], ["morning"] + ) + return None + + _with_db(tmp_path, "create-unknown-child.db")(_body) + + +@pytest.mark.parametrize("bad", [[]]) +def test_create_rejects_empty_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="windows must not be empty"): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], bad + ) + return None + + _with_db(tmp_path, "create-empty-windows.db")(_body) + + +@pytest.mark.parametrize( + "bad", [("morning",), ("morning", "afternoon"), "morning", {"morning"}] +) +def test_create_rejects_non_list_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="windows must be a list"): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], bad + ) + return None + + _with_db(tmp_path, "create-non-list-windows.db")(_body) + + +@pytest.mark.parametrize("bad", [["noon"], [("MORNING", None)], [("", None)]]) +def test_create_rejects_invalid_window(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="window must be one of"): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], bad + ) + return None + + _with_db(tmp_path, "create-bad-window.db")(_body) + + +def test_create_rejects_duplicate_windows(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="windows must not contain duplicate" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning", "morning"], + ) + with pytest.raises( + ValueError, match="windows must not contain duplicate" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "09:00"), ("morning", "10:00")], + ) + return None + + _with_db(tmp_path, "create-duplicate-windows.db")(_body) + + +@pytest.mark.parametrize("bad", ["9:30", "25:00", "09:60", "0900"]) +def test_create_rejects_malformed_due_time(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="HH:MM"): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", bad)], + ) + return None + + _with_db(tmp_path, "create-bad-due-time.db")(_body) + + +# --------------------------------------------------------------------------- +# create_quest_definition: a rejected create persists nothing +# --------------------------------------------------------------------------- + + +async def _assert_only_baseline_rows( + rules, definitions, child, baseline_definition +) -> None: + """Assert the baseline rule/definition/assignee/window rows survive + unchanged and no second row of any of them appeared. + + A rejected create must leave each table's row count exactly as it + was: still one rule, one definition, one assignee, one window — + checked through the DAO (id 2 must be absent everywhere, id 1 + intact), since the leak guard forbids raw SQL in this test file. + """ + assert await rules.get(1) is not None + assert await rules.get(2) is None + assert await definitions.get(1) == baseline_definition + assert await definitions.get(2) is None + assert [c.id for c in await definitions.list_assignees(1)] == [child.id] + assert await definitions.list_assignees(2) == [] + assert [w.window for w in await definitions.list_windows(1)] == [ + "morning" + ] + assert await definitions.list_windows(2) == [] + + +def test_rejected_create_writes_nothing(tmp_path) -> None: + """A rejected create leaves every quest row count unchanged. + + Two rejection paths are forced: an in-transaction rejection (an + inactive assignee fails validation inside the transaction and must + roll the rule/definition back together) and a pre-write rejection + (an invalid window is caught in the business layer before any write). + Both must leave the baseline single-rule/single-definition state + untouched, with no second row in any of the four tables. + """ + async def _body(database, children, rules, definitions, child): + baseline_rule = await rules.create("daily", "2026-09-14") + baseline_definition = await definitions.create( + "Baseline", baseline_rule.id, NOW, assignee_child_ids=[child.id] + ) + await definitions.upsert_window(baseline_definition.id, "morning") + inactive = await children.create("Bo", NOW) + await children.set_active(inactive.id, False) + rule = _daily_rule() + + # In-transaction rejection: the inactive child fails validation + # inside the transaction, rolling the rule and definition back. + with pytest.raises(ValueError, match="inactive"): + await create_quest_definition( + database, "Brush teeth", rule, [inactive.id], ["morning"] + ) + await _assert_only_baseline_rows( + rules, definitions, child, baseline_definition + ) + + # Pre-write rejection: an invalid window is caught up front, so + # no transaction ever opens. + with pytest.raises(ValueError, match="window must be one of"): + await create_quest_definition( + database, "Brush teeth", rule, [child.id], [("noon", None)] + ) + await _assert_only_baseline_rows( + rules, definitions, child, baseline_definition + ) + return None + + _with_db(tmp_path, "create-rejected-nothing.db")(_body) + + +def test_post_write_failure_rolls_back_everything(tmp_path) -> None: + """A failure after some writes rolls all four tables back together. + + Deterministic post-write trigger: the DAO method inserts the rule, + the definition and the assignee rows, THEN validates the window name + and raises ValueError for a window outside const.QUEST_WINDOWS. + That failure lands after three writes inside the transaction, so the + rule, definition and assignee rows must all roll back — no partial + rows survive in any of the four tables. + """ + async def _body(database, children, rules, definitions, child): + storage = schedule_rule_to_storage(_daily_rule()) + dao = QuestDefinitionsDao(database) + with pytest.raises(ValueError, match="window must be one of"): + await dao.create_with_rule_and_windows( + "Brush teeth", + storage, + NOW, + [child.id], + [("noon", None)], + ) + # All four tables are empty: the earlier rule, definition and + # assignee writes were rolled back, and no window row was added. + assert await rules.get(1) is None + assert await definitions.get(1) is None + assert await definitions.list_active() == [] + assert await definitions.list_assignees(1) == [] + assert await definitions.list_windows(1) == [] + return None + + _with_db(tmp_path, "post-write-rollback.db")(_body) -- 2.55.0 From 802258d431f75d564ab304eb96029ccc74d58917 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 09:06:11 -0400 Subject: [PATCH 03/28] Edit a quest definition's metadata, schedule rule, and windows (Feature 06, task 3) (#68) --- custom_components/nestquest/dao_rules.py | 143 +++++- .../nestquest/quest_definitions.py | 105 ++++- tests/test_quest_definitions.py | 419 ++++++++++++++++++ 3 files changed, 657 insertions(+), 10 deletions(-) diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 672c064..c15f585 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -165,16 +165,18 @@ class QuestDefinitionWindowRecord: @dataclass(frozen=True) class QuestDefinitionSnapshot: - """A definition plus its assignees and windows, read atomically. + """A definition plus its rule, assignees and windows, read atomically. Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` - with the assignees and windows read inside the SAME transaction that - inserted them, so the snapshot is a consistent creation-time view — - a concurrent assignee or window mutation cannot change it between - the write and the read. + and :meth:`QuestDefinitionsDao.edit_definition` with the rule, + assignees and windows read inside the SAME transaction that wrote + them, so the snapshot is a consistent creation/edit-time view — a + concurrent rule, assignee or window mutation cannot change it + between the write and the read. """ definition: QuestDefinitionRecord + rule: ScheduleRuleRecord assignees: list[ChildRecord] windows: list[QuestDefinitionWindowRecord] @@ -329,6 +331,28 @@ def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: ) +def schedule_rule_storage_from_record( + record: ScheduleRuleRecord, +) -> ScheduleRuleStorage: + """Copy a rule row's value columns into a storage mapping. + + ``ScheduleRuleRecord`` is the read shape (it carries ``id``); + :func:`schedule_rule_from_storage` consumes the value-only + :class:`ScheduleRuleStorage` shape, so callers re-reading a stored + rule convert through this helper. + """ + return ScheduleRuleStorage( + rule_type=record.rule_type, + interval=record.interval, + weekday_set=record.weekday_set, + day_of_month=record.day_of_month, + nth_weekday=record.nth_weekday, + month=record.month, + start_date=record.start_date, + end_date=record.end_date, + ) + + def _reject_forbidden_storage_fields( storage: ScheduleRuleStorage, shape: RuleType ) -> None: @@ -719,9 +743,118 @@ class QuestDefinitionsDao: definition = await self.get(definition_id) assignees = await self.list_assignees(definition_id) window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + rule_id + ) assert definition is not None + assert rule_record is not None return QuestDefinitionSnapshot( definition=definition, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + + async def edit_definition( + self, + definition_id: int, + *, + title: str | None | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + rule: ScheduleRuleStorage | object = _UNSET, + windows: list[tuple[str, str | None]] | object = _UNSET, + ) -> QuestDefinitionSnapshot: + """Edit a definition's metadata, rule and windows atomically. + + Mirrors :meth:`create_with_rule_and_windows`: everything runs + inside ONE transaction under the connection lock, and the + returned snapshot's rule, assignees and windows are read back + inside that transaction. Arguments default to the module sentinel + ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL + NULL so optional metadata (description, icon) can be cleared. + + - ``title``/``description``/``icon`` update the definition row + in place; the definition-level ``due_time`` column is never + written (per-window due times supersede it, D-008). + - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) + overwrites the referenced ``schedule_rules`` row in place: the + definition keeps its ``schedule_rule_id``, so no new or + orphaned rule row appears. + - ``windows`` REPLACES the whole window set: the current rows + are deleted and the given set re-inserted, so missing windows + are added, changed due times updated and removed windows + dropped. + + Assignment is deliberately not settable here (multi-assignee is + managed by :meth:`add_assignee`/:meth:`remove_assignee`), and + activation is untouched. Raises ValueError when the definition + does not exist. Edits change future instances only: no + ``quest_instances`` or ``completion_events`` row is touched. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self.get(definition_id) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("title", title), + ("description", description), + ("icon", icon), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if assignments: + parameters.append(definition_id) + await self._database.execute( + f"UPDATE quest_definitions SET " + f"{', '.join(assignments)} WHERE id = ?", + tuple(parameters), + ) + if rule is not _UNSET: + await ScheduleRulesDao(self._database).update( + definition.schedule_rule_id, + rule_type=rule.rule_type, + interval=rule.interval, + weekday_set=rule.weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + if windows is not _UNSET: + await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + updated = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + updated.schedule_rule_id + ) + assert updated is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=updated, + rule=rule_record, assignees=assignees, windows=window_records, ) diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index 28283e1..b574f08 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -1,10 +1,11 @@ -"""Business layer for creating quest definitions (Feature 06). +"""Business layer for creating and editing quest definitions (Feature 06). Sits between the Feature 09 service gate and the typed :class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the -all-or-nothing create here, and never touch the DAO SQL directly. -:func:`create_quest_definition` is the create path; there is no -permission check here (that is Feature 09). +all-or-nothing create/edit here, and never touch the DAO SQL directly. +:func:`create_quest_definition` is the create path and +:func:`edit_quest_definition` the edit path; there is no permission +check here (that is Feature 09). Validation policy mirrors :mod:`.children`: @@ -43,9 +44,12 @@ from .dao_rules import ( QuestDefinitionRecord, QuestDefinitionWindowRecord, QuestDefinitionsDao, + ScheduleRuleStorage, + _UNSET, _validate_time, _validate_window, schedule_rule_from_storage, + schedule_rule_storage_from_record, schedule_rule_to_storage, ) from .db import NestQuestDatabase @@ -54,7 +58,7 @@ from .recurrence import ScheduleRule @dataclass(frozen=True) class CreatedQuestDefinition: - """The persisted result of :func:`create_quest_definition`. + """The persisted result of a create or edit. ``definition`` is the stored row; ``rule`` is the schedule rule decoded back through the storage mapping; ``assignees`` and @@ -169,6 +173,18 @@ def _normalize_windows( return normalized +def _validate_definition_id(value: object) -> int: + """Reject non-int definition ids (bools included) before any lookup. + + SQLite binds Python bools as integers, so ``True`` would silently + address definition 1 and a float would round — malformed service + input must never mutate the wrong definition. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"definition_id must be an integer, got {value!r}") + return value + + async def create_quest_definition( database: NestQuestDatabase, title: str, @@ -226,3 +242,82 @@ async def create_quest_definition( assignees=snapshot.assignees, windows=snapshot.windows, ) + + +async def edit_quest_definition( + database: NestQuestDatabase, + definition_id: int, + *, + title: str | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + rule: ScheduleRule | object = _UNSET, + windows: list[str | tuple[str, str | None]] | object = _UNSET, +) -> CreatedQuestDefinition: + """Edit a quest definition's metadata, rule and windows atomically. + + Arguments default to the sentinel ``_UNSET`` meaning "leave this + field alone". A provided ``title`` is validated like create's + (required and trimmed); ``description`` and ``icon`` are optional + and may be cleared by passing ``None`` explicitly. A provided + ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule` + and REPLACES the definition's schedule rule IN PLACE — the existing + ``schedule_rules`` row is updated, never orphaned or duplicated. A + provided ``windows`` list REPLACES the whole window set (each entry + a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due + time), matching create's non-empty contract. Assignment is NOT + settable here. Raises ValueError when the definition does not + exist or any argument is rejected. + + Returns the updated definition together with its decoded rule, + assignees and windows — all read back inside the DAO's transaction, + so the returned bundle is a consistent edit snapshot. Edits change + future instances only; no ``quest_instances`` or + ``completion_events`` row is touched. + """ + _validate_definition_id(definition_id) + + title_value: str | None | object = _UNSET + if title is not _UNSET: + name = _validate_text(title, "title", required=True) + assert name is not None + title_value = name + + description_value: str | None | object = _UNSET + if description is not _UNSET: + description_value = _validate_text(description, "description") + + icon_value: str | None | object = _UNSET + if icon is not _UNSET: + icon_value = _validate_text(icon, "icon") + + rule_storage: ScheduleRuleStorage | object = _UNSET + if rule is not _UNSET: + if not isinstance(rule, ScheduleRule): + raise ValueError(f"rule must be a ScheduleRule, got {rule!r}") + rule_storage = schedule_rule_to_storage(rule) + + window_specs: list[tuple[str, str | None]] | object = _UNSET + if windows is not _UNSET: + 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, + ) + + decoded_rule = schedule_rule_from_storage( + schedule_rule_storage_from_record(snapshot.rule) + ) + + return CreatedQuestDefinition( + definition=snapshot.definition, + rule=decoded_rule, + assignees=snapshot.assignees, + windows=snapshot.windows, + ) diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 1f99808..2f91b82 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -17,6 +17,7 @@ from custom_components.nestquest.migrations import apply_migrations from custom_components.nestquest.quest_definitions import ( CreatedQuestDefinition, create_quest_definition, + edit_quest_definition, ) from custom_components.nestquest.recurrence import RuleType, ScheduleRule @@ -461,3 +462,421 @@ def test_post_write_failure_rolls_back_everything(tmp_path) -> None: return None _with_db(tmp_path, "post-write-rollback.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_quest_definition: successful edits +# --------------------------------------------------------------------------- + + +def test_edit_title_description_icon(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning"], + description="Old description", + icon="mdi:old", + ) + result = await edit_quest_definition( + database, + created.definition.id, + title=" Floss ", + description=" New description ", + icon=" mdi:new ", + ) + assert result.definition.title == "Floss" + assert result.definition.description == "New description" + assert result.definition.icon == "mdi:new" + # Untouched fields survive the edit unchanged. + assert result.definition.id == created.definition.id + assert result.definition.schedule_rule_id == created.definition.schedule_rule_id + assert result.definition.due_time is None + assert result.definition.is_active is True + assert result.definition.created_at == created.definition.created_at + # Rule, assignees and windows are read back through the DAO. + assert result.rule == _daily_rule() + assert [c.id for c in result.assignees] == [child.id] + assert [w.window for w in result.windows] == ["morning"] + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.title == "Floss" + assert stored.description == "New description" + assert stored.icon == "mdi:new" + return None + + _with_db(tmp_path, "edit-metadata.db")(_body) + + +def test_edit_omitted_fields_left_unchanged(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning"], + description="Keep me", + ) + result = await edit_quest_definition( + database, created.definition.id, title="Renamed" + ) + assert result.definition.title == "Renamed" + assert result.definition.description == "Keep me" + assert result.definition.icon is None + return None + + _with_db(tmp_path, "edit-omitted.db")(_body) + + +def test_edit_clears_optional_metadata(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning"], + description="Old", + icon="mdi:old", + ) + result = await edit_quest_definition( + database, + created.definition.id, + description=None, + icon=None, + ) + assert result.definition.description is None + assert result.definition.icon is None + return None + + _with_db(tmp_path, "edit-clear-metadata.db")(_body) + + +def test_edit_rule_updates_existing_row_in_place(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + rule_id = created.definition.schedule_rule_id + assert rule_id == 1 + new_rule = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={1, 3}, + start_date="2026-09-14", + ) + result = await edit_quest_definition( + database, created.definition.id, rule=new_rule + ) + assert result.rule == new_rule + # The definition keeps the SAME rule row: no new or orphaned row. + assert result.definition.schedule_rule_id == rule_id + rule_row = await rules.get(rule_id) + assert rule_row is not None + assert rule_row.rule_type == "weekly" + assert rule_row.weekday_set == "1,3" + assert await rules.get(rule_id + 1) is None + return None + + _with_db(tmp_path, "edit-rule-in-place.db")(_body) + + +def test_edit_rule_shape_change_clears_stale_columns(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + created = await create_quest_definition( + database, "Chore", weekly, [child.id], ["morning"] + ) + rule_id = created.definition.schedule_rule_id + result = await edit_quest_definition( + database, created.definition.id, rule=_daily_rule() + ) + assert result.rule == _daily_rule() + rule_row = await rules.get(rule_id) + assert rule_row is not None + # The old weekly shape's weekday_set must be cleared, not left stale. + assert rule_row.rule_type == "daily" + assert rule_row.weekday_set is None + return None + + _with_db(tmp_path, "edit-rule-shape.db")(_body) + + +def test_edit_replaces_windows(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "07:00"), ("evening", "19:00")], + ) + result = await edit_quest_definition( + database, + created.definition.id, + windows=[("morning", "08:00"), ("afternoon", "12:00")], + ) + by_name = {w.window: w.due_time for w in result.windows} + # morning's due time changed, afternoon added, evening removed. + assert by_name == {"morning": "08:00", "afternoon": "12:00"} + persisted = { + w.window: w.due_time + for w in await definitions.list_windows(created.definition.id) + } + assert persisted == {"morning": "08:00", "afternoon": "12:00"} + return None + + _with_db(tmp_path, "edit-replace-windows.db")(_body) + + +def test_edit_preserves_assignees(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id, bo.id], ["morning"] + ) + result = await edit_quest_definition( + database, + created.definition.id, + title="Renamed", + rule=ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0}, + start_date="2026-09-14", + ), + windows=[("evening", "20:00")], + ) + assert sorted(c.id for c in result.assignees) == sorted([child.id, bo.id]) + persisted = [ + c.id for c in await definitions.list_assignees(created.definition.id) + ] + assert sorted(persisted) == sorted([child.id, bo.id]) + return None + + _with_db(tmp_path, "edit-preserves-assignees.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_quest_definition: argument rejections +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["", " ", None]) +def test_edit_rejects_empty_title(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="title is required"): + await edit_quest_definition( + database, created.definition.id, title=bad + ) + return None + + _with_db(tmp_path, "edit-bad-title.db")(_body) + + +@pytest.mark.parametrize("bad", [None, "weekly", 42, {"rule_type": "daily"}]) +def test_edit_rejects_non_schedule_rule(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="rule must be a ScheduleRule"): + await edit_quest_definition( + database, created.definition.id, rule=bad + ) + return None + + _with_db(tmp_path, "edit-bad-rule.db")(_body) + + +@pytest.mark.parametrize("bad", [["noon"], [("MORNING", None)], [("", None)]]) +def test_edit_rejects_invalid_window(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="window must be one of"): + await edit_quest_definition( + database, created.definition.id, windows=bad + ) + return None + + _with_db(tmp_path, "edit-bad-window.db")(_body) + + +@pytest.mark.parametrize("bad", ["9:30", "25:00", "09:60", "0900"]) +def test_edit_rejects_malformed_due_time(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="HH:MM"): + await edit_quest_definition( + database, created.definition.id, windows=[("morning", bad)] + ) + return None + + _with_db(tmp_path, "edit-bad-due-time.db")(_body) + + +@pytest.mark.parametrize("bad", [[]]) +def test_edit_rejects_empty_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="windows must not be empty"): + await edit_quest_definition( + database, created.definition.id, windows=bad + ) + return None + + _with_db(tmp_path, "edit-empty-windows.db")(_body) + + +@pytest.mark.parametrize("bad", [("morning",), "morning", {"morning"}]) +def test_edit_rejects_non_list_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="windows must be a list"): + await edit_quest_definition( + database, created.definition.id, windows=bad + ) + return None + + _with_db(tmp_path, "edit-non-list-windows.db")(_body) + + +def test_edit_rejects_nonexistent_definition(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="does not exist"): + await edit_quest_definition(database, 999, title="X") + return None + + _with_db(tmp_path, "edit-missing-definition.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): + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await edit_quest_definition(database, bad, title="X") + return None + + _with_db(tmp_path, "edit-bad-definition-id.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_quest_definition: consistent snapshot and rollback (CQ-01, CQ-02) +# --------------------------------------------------------------------------- + + +def test_concurrent_edit_omitted_rule_snapshot_consistent(tmp_path) -> None: + """Two concurrent edits never return a mixed-time snapshot. + + One edit changes windows only (rule omitted); the other changes the + rule only (windows omitted). Because each edit reads its rule, + assignees and windows back inside its own locked transaction, the + windows-only edit reports the rule as it was AT THAT EDIT's + transaction: the ORIGINAL rule when it ran first, the NEW rule when + it ran second. That yields a strict invariant — the windows-only + result's rule is the original exactly when the rule-only result + still saw the original windows — which a post-transaction rule read + would violate. + """ + 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", + ) + windows_result, rule_result = await asyncio.gather( + edit_quest_definition( + database, + created.definition.id, + windows=[("evening", "19:00")], + ), + edit_quest_definition(database, created.definition.id, rule=weekly), + ) + # Each edit reports its own change... + assert [w.window for w in windows_result.windows] == ["evening"] + assert rule_result.rule == weekly + # ...and the pair is self-consistent: the windows-only edit saw + # the original rule iff the rule-only edit saw the original + # windows (they ran in that order). + assert (windows_result.rule == _daily_rule()) == ( + [w.window for w in rule_result.windows] == ["evening"] + ) + return None + + _with_db(tmp_path, "edit-concurrent-snapshot.db")(_body) + + +def test_edit_post_write_failure_rolls_back_everything(tmp_path) -> None: + """A post-write failure during an edit rolls every table back together. + + Deterministic trigger: call the DAO's edit directly with a window + name outside const.QUEST_WINDOWS. The DAO applies the metadata and + rule writes and the window delete, THEN validates the window name + and raises ValueError. That failure lands after several writes + inside the transaction, so the metadata, rule and window changes + must all roll back — the baseline definition, rule and windows + survive unchanged. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "07:00")], + ) + rule_id = created.definition.schedule_rule_id + weekly_storage = schedule_rule_to_storage( + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + ) + dao = QuestDefinitionsDao(database) + with pytest.raises(ValueError, match="window must be one of"): + await dao.edit_definition( + created.definition.id, + title="Changed", + rule=weekly_storage, + windows=[("noon", None)], + ) + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.title == "Brush teeth" + rule_row = await rules.get(rule_id) + assert rule_row is not None + assert rule_row.rule_type == "daily" + assert rule_row.weekday_set is None + by_name = { + w.window: w.due_time + for w in await definitions.list_windows(created.definition.id) + } + assert by_name == {"morning": "07:00"} + assert [ + c.id for c in await definitions.list_assignees(created.definition.id) + ] == [child.id] + return None + + _with_db(tmp_path, "edit-post-write-rollback.db")(_body) -- 2.55.0 From 4606d7ddd0efa24a857c07788706b5edce92257b Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 09:21:21 -0400 Subject: [PATCH 04/28] Assign and unassign children on a quest definition (Feature 06, task 4) (#69) --- custom_components/nestquest/dao_rules.py | 9 +- .../nestquest/quest_definitions.py | 74 ++++- tests/test_quest_definitions.py | 270 ++++++++++++++++++ 3 files changed, 350 insertions(+), 3 deletions(-) diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index c15f585..d1338e4 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -962,14 +962,18 @@ class QuestDefinitionsDao: async def add_assignee( self, definition_id: int, child_id: int - ) -> None: + ) -> list[ChildRecord]: """Assign ``child_id`` to the definition (idempotent). Validates the assignee inside the same serialized transaction as the INSERT (existing and active AT ASSIGNMENT TIME), so a child deactivated concurrently cannot become an assignee. Assigning an already-assigned child is a no-op, mirroring the - composite primary key's storage-level guarantee. Changes + composite primary key's storage-level guarantee. Returns the + definition's assignees read back INSIDE the same transaction + that performed the write, so the returned roster is a + consistent post-assignment snapshot — a concurrent assign or + unassign cannot race it after the write commits. Changes future instances only: already-generated instances and completion history are never rewritten here. """ @@ -990,6 +994,7 @@ class QuestDefinitionsDao: "ON CONFLICT (definition_id, child_id) DO NOTHING", (definition_id, child_id), ) + return await self.list_assignees(definition_id) async def remove_assignee( self, definition_id: int, child_id: int diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index b574f08..b438a03 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -4,7 +4,8 @@ Sits between the Feature 09 service gate and the typed :class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the all-or-nothing create/edit here, and never touch the DAO SQL directly. :func:`create_quest_definition` is the create path and -:func:`edit_quest_definition` the edit path; there is no permission +:func:`edit_quest_definition` the edit path; :func:`assign_child` and +:func:`unassign_child` are the assignment paths. There is no permission check here (that is Feature 09). Validation policy mirrors :mod:`.children`: @@ -185,6 +186,18 @@ def _validate_definition_id(value: object) -> int: return value +def _validate_child_id(value: object) -> int: + """Reject non-int child ids (bools included) before any lookup. + + SQLite binds Python bools as integers, so ``True`` would silently + address child 1 and a float would round — malformed service input + must never mutate the wrong profile. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"child_id must be an integer, got {value!r}") + return value + + async def create_quest_definition( database: NestQuestDatabase, title: str, @@ -321,3 +334,62 @@ async def edit_quest_definition( assignees=snapshot.assignees, windows=snapshot.windows, ) + + +async def assign_child( + database: NestQuestDatabase, + definition_id: int, + child_id: int, +) -> list[ChildRecord]: + """Assign ``child_id`` to the definition (idempotent). + + Validates that both ids are plain ints (bools and floats rejected) + and that the child exists and is active — the DAO enforces the + active/exists check inside its transaction, and also confirms the + definition exists. Assigning an already-assigned child is a no-op + (no duplicate row). Returns the definition's assignees read back + inside the DAO's same transaction, a consistent post-assignment + roster (a concurrent assign/unassign cannot race it after the + write). Raises ValueError on any rejected argument. Only the + ``quest_definition_assignees`` link is written; existing instances + and completion history are never touched. + """ + _validate_definition_id(definition_id) + _validate_child_id(child_id) + dao = QuestDefinitionsDao(database) + try: + return 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. + message = str(error) + if message.startswith("child "): + raise ValueError(f"child_id: {message}") from error + if message.startswith("quest definition "): + raise ValueError(f"definition_id: {message}") from error + raise + + +async def unassign_child( + database: NestQuestDatabase, + definition_id: int, + child_id: int, +) -> bool: + """Unassign ``child_id`` from the definition; True when removed. + + Validates both ids are plain ints (bools and floats rejected) and + that the definition exists, then removes the (definition, child) + link. Returns True when a row was removed and False when the child + was not currently assigned. Raises ValueError on a rejected + argument. Only the ``quest_definition_assignees`` link is written; + existing instances and completion history are never touched. + """ + _validate_definition_id(definition_id) + _validate_child_id(child_id) + dao = QuestDefinitionsDao(database) + definition = await dao.get(definition_id) + if definition is None: + raise ValueError( + f"definition_id: quest definition {definition_id} does not exist" + ) + return await dao.remove_assignee(definition_id, child_id) diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 2f91b82..2206daa 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -16,8 +16,10 @@ from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations from custom_components.nestquest.quest_definitions import ( CreatedQuestDefinition, + assign_child, create_quest_definition, edit_quest_definition, + unassign_child, ) from custom_components.nestquest.recurrence import RuleType, ScheduleRule @@ -880,3 +882,271 @@ def test_edit_post_write_failure_rolls_back_everything(tmp_path) -> None: return None _with_db(tmp_path, "edit-post-write-rollback.db")(_body) + + +# --------------------------------------------------------------------------- +# assign_child / unassign_child: the assignment business-layer paths +# --------------------------------------------------------------------------- + + +def test_assign_child_multi_assignee_success(tmp_path) -> None: + """Assigning a second child leaves both as assignees (D-008).""" + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + assignees = await assign_child(database, created.definition.id, bo.id) + assert sorted(c.id for c in assignees) == sorted([child.id, bo.id]) + return assignees + + _with_db(tmp_path, "assign-multi.db")(_body) + + +def test_assign_child_duplicate_is_noop(tmp_path) -> None: + """Assigning an already-assigned child adds no duplicate row.""" + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + assignees = await assign_child( + database, created.definition.id, child.id + ) + assert [c.id for c in assignees] == [child.id] + again = await assign_child( + database, created.definition.id, child.id + ) + assert [c.id for c in again] == [child.id] + return assignees + + _with_db(tmp_path, "assign-duplicate.db")(_body) + + +def test_assign_child_unknown_child_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises( + ValueError, match="child_id.*does not exist" + ): + await assign_child(database, created.definition.id, 999) + return None + + _with_db(tmp_path, "assign-unknown-child.db")(_body) + + +def test_assign_child_inactive_child_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + await children.set_active(bo.id, False) + with pytest.raises(ValueError, match="child_id.*inactive"): + await assign_child(database, created.definition.id, bo.id) + # The failed assignment leaves the roster unchanged. + assert [c.id for c in await definitions.list_assignees( + created.definition.id + )] == [child.id] + return None + + _with_db(tmp_path, "assign-inactive-child.db")(_body) + + +def test_assign_child_nonexistent_definition_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id.*does not exist" + ): + await assign_child(database, 999, child.id) + return None + + _with_db(tmp_path, "assign-missing-definition.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_assign_child_rejects_non_int_definition_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await assign_child(database, bad, child.id) + return None + + _with_db(tmp_path, "assign-bad-definition-id.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_assign_child_rejects_non_int_child_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="child_id must be an integer"): + await assign_child(database, created.definition.id, bad) + return None + + _with_db(tmp_path, "assign-bad-child-id.db")(_body) + + +def test_unassign_child_success_returns_true(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id, bo.id], + ["morning"], + ) + assert await unassign_child( + database, created.definition.id, bo.id + ) is True + assert [c.id for c in await definitions.list_assignees( + created.definition.id + )] == [child.id] + return None + + _with_db(tmp_path, "unassign-success.db")(_body) + + +def test_unassign_child_not_assigned_returns_false(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + assert await unassign_child( + database, created.definition.id, 999 + ) is False + return None + + _with_db(tmp_path, "unassign-not-assigned.db")(_body) + + +def test_unassign_child_nonexistent_definition_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id.*does not exist" + ): + await unassign_child(database, 999, child.id) + return None + + _with_db(tmp_path, "unassign-missing-definition.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_unassign_child_rejects_non_int_definition_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await unassign_child(database, bad, child.id) + return None + + _with_db(tmp_path, "unassign-bad-definition-id.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_unassign_child_rejects_non_int_child_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="child_id must be an integer"): + await unassign_child(database, created.definition.id, bad) + return None + + _with_db(tmp_path, "unassign-bad-child-id.db")(_body) + + +def test_assign_and_unassign_leave_instances_and_history_alone( + tmp_path, +) -> None: + """Assign/unassign write the assignees table only. + + An already-generated instance keeps its generation-time child_id, and + no completion event appears — the assignment paths touch neither + ``quest_instances`` nor ``completion_events``. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + await database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, generated_at) VALUES (?, ?, 'morning', ?, ?)", + (created.definition.id, child.id, "2026-09-15", NOW), + ) + await assign_child(database, created.definition.id, bo.id) + await unassign_child(database, created.definition.id, child.id) + row = await database.fetch_one( + "SELECT child_id FROM quest_instances WHERE definition_id = ?", + (created.definition.id,), + ) + assert row == (child.id,) + events = await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) + assert events == (0,) + return None + + _with_db(tmp_path, "assign-unassign-history.db")(_body) + + +def test_concurrent_assign_unassign_returns_consistent_roster( + tmp_path, +) -> None: + """A concurrent unassign cannot strip the just-assigned child from + the roster that ``assign_child`` returns. + + ``assign_child`` returns the roster read inside the same locked + transaction as the INSERT, so the child it assigned is always present + in its result — even when an unassign of that same child races. The + pause hook holds ``list_assignees`` mid-flight so the unassign is + deterministically queued against the assignment's lock. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + + 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: + assign_task = asyncio.ensure_future( + assign_child(database, created.definition.id, bo.id) + ) + await started.wait() + unassign_task = asyncio.ensure_future( + unassign_child(database, created.definition.id, bo.id) + ) + await asyncio.sleep(0) + release.set() + assignees, removed = await asyncio.gather( + assign_task, unassign_task + ) + finally: + dao_rules.QuestDefinitionsDao.list_assignees = original_list + + assert bo.id in [c.id for c in assignees], ( + "assign_child must return a roster containing the child it " + "just assigned, even under a concurrent unassign" + ) + assert removed is True + return assignees + + _with_db(tmp_path, "assign-unassign-concurrent.db")(_body) -- 2.55.0 From 1b6973efd390ff2b2ae956b3517b56f2b4087d50 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 09:40:15 -0400 Subject: [PATCH 05/28] Deactivate and reactivate a quest definition (Feature 06, task 5) (#70) --- .../nestquest/quest_definitions.py | 71 ++++- tests/test_quest_definitions.py | 270 ++++++++++++++++++ 2 files changed, 335 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index b438a03..7d7fcf9 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -3,10 +3,11 @@ Sits between the Feature 09 service gate and the typed :class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the all-or-nothing create/edit here, and never touch the DAO SQL directly. -:func:`create_quest_definition` is the create path and -:func:`edit_quest_definition` the edit path; :func:`assign_child` and -:func:`unassign_child` are the assignment paths. There is no permission -check here (that is Feature 09). +:func:`create_quest_definition` is the create path, +:func:`edit_quest_definition` the edit path, :func:`assign_child` and +:func:`unassign_child` the assignment paths, and +:func:`set_quest_definition_active` the deactivate/reactivate path. +There is no permission check here (that is Feature 09). Validation policy mirrors :mod:`.children`: @@ -40,12 +41,13 @@ from __future__ import annotations import datetime from dataclasses import dataclass -from .dao_children import ChildRecord +from .dao_children import ChildRecord, _connection_lock from .dao_rules import ( QuestDefinitionRecord, QuestDefinitionWindowRecord, QuestDefinitionsDao, ScheduleRuleStorage, + ScheduleRulesDao, _UNSET, _validate_time, _validate_window, @@ -59,7 +61,7 @@ from .recurrence import ScheduleRule @dataclass(frozen=True) class CreatedQuestDefinition: - """The persisted result of a create or edit. + """The persisted result of a create, edit or activation change. ``definition`` is the stored row; ``rule`` is the schedule rule decoded back through the storage mapping; ``assignees`` and @@ -393,3 +395,60 @@ async def unassign_child( f"definition_id: quest definition {definition_id} does not exist" ) return await dao.remove_assignee(definition_id, child_id) + + +async def set_quest_definition_active( + database: NestQuestDatabase, + definition_id: int, + is_active: bool, +) -> CreatedQuestDefinition: + """Deactivate or reactivate a definition; returns the updated view. + + Flipping the flag is the only removal path (Feature 06 guardrail): + a deactivated definition stops future instance generation but its + row, schedule rule, assignees and windows all survive, and existing + instances plus completion history are never touched. Reactivation + re-enables future generation; generating or removing instances is + Feature 07 and does not happen here. + + ``definition_id`` must be a plain int (bools and floats rejected, + since SQLite would bind ``True`` onto definition 1) and + ``is_active`` must be a real bool — the DAO's ``int()`` would + otherwise silently coerce strings and numerics ("1", 0) into a + state the caller never asked for. Raises ValueError naming the + offending field when the id is malformed, ``is_active`` is not a + bool, or the definition does not exist. + + Atomicity: the existence check, the UPDATE and the readback of the + definition, its rule, assignees and windows all run inside the + connection-scoped lock, so the returned bundle is a consistent + snapshot that a concurrent mutation cannot race. + """ + _validate_definition_id(definition_id) + if not isinstance(is_active, bool): + raise ValueError(f"is_active must be a real bool, got {is_active!r}") + async with _connection_lock(database): + dao = QuestDefinitionsDao(database) + if await dao.get(definition_id) is None: + raise ValueError( + f"definition_id: quest definition {definition_id} " + "does not exist" + ) + await dao.set_active(definition_id, is_active) + updated = await dao.get(definition_id) + assert updated is not None + rule_record = await ScheduleRulesDao(database).get( + updated.schedule_rule_id + ) + assert rule_record is not None + assignees = await dao.list_assignees(definition_id) + windows = await dao.list_windows(definition_id) + rule = schedule_rule_from_storage( + schedule_rule_storage_from_record(rule_record) + ) + return CreatedQuestDefinition( + definition=updated, + rule=rule, + assignees=assignees, + windows=windows, + ) diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 2206daa..9266305 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -2,11 +2,16 @@ from __future__ import annotations import asyncio +import datetime import re import pytest from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, +) from custom_components.nestquest.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, @@ -19,6 +24,7 @@ from custom_components.nestquest.quest_definitions import ( assign_child, create_quest_definition, edit_quest_definition, + set_quest_definition_active, unassign_child, ) from custom_components.nestquest.recurrence import RuleType, ScheduleRule @@ -72,6 +78,15 @@ def _daily_rule() -> ScheduleRule: return ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-14") +def _today_iso() -> str: + """Today's date in ISO form, computed at call time (never cached). + + ``QuestInstancesDao.upsert`` refuses past due dates, so seeded + instances use a date that is always today-or-later at test time. + """ + return datetime.date.today().isoformat() + + # --------------------------------------------------------------------------- # create_quest_definition: successful creates # --------------------------------------------------------------------------- @@ -1150,3 +1165,258 @@ def test_concurrent_assign_unassign_returns_consistent_roster( return assignees _with_db(tmp_path, "assign-unassign-concurrent.db")(_body) + + +# --------------------------------------------------------------------------- +# set_quest_definition_active: deactivate / reactivate +# --------------------------------------------------------------------------- + + +def test_set_quest_definition_active_round_trip(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + definition_id = created.definition.id + + deactivated = await set_quest_definition_active( + database, definition_id, False + ) + assert isinstance(deactivated, CreatedQuestDefinition) + assert deactivated.definition.id == definition_id + assert deactivated.definition.is_active is False + # The decoded rule, assignees and windows form a consistent + # snapshot of the (unchanged) definition. + assert deactivated.rule == _daily_rule() + assert [c.id for c in deactivated.assignees] == [child.id] + assert [w.window for w in deactivated.windows] == ["morning"] + assert await definitions.list_active() == [] + + reactivated = await set_quest_definition_active( + database, definition_id, True + ) + assert reactivated.definition.is_active is True + assert [d.id for d in await definitions.list_active()] == [ + definition_id + ] + return reactivated + + _with_db(tmp_path, "set-active-round-trip.db")(_body) + + +@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"]) +def test_set_quest_definition_active_rejects_non_bool(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises( + ValueError, match="is_active must be a real bool" + ): + await set_quest_definition_active( + database, created.definition.id, bad + ) + # The refused transition left the definition active. + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.is_active is True + return None + + _with_db(tmp_path, "set-active-non-bool.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.5, "1", None]) +def test_set_quest_definition_active_rejects_non_int_definition_id( + tmp_path, bad +) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await set_quest_definition_active(database, bad, False) + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.is_active is True + return None + + _with_db(tmp_path, "set-active-non-int-id.db")(_body) + + +def test_set_quest_definition_active_nonexistent_definition_names_field( + tmp_path, +) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id.*does not exist" + ): + await set_quest_definition_active(database, 999, False) + return None + + _with_db(tmp_path, "set-active-missing-definition.db")(_body) + + +def test_set_quest_definition_active_never_deletes_or_touches_instances( + tmp_path, +) -> None: + """Deactivation hides the definition but deletes nothing, and + pre-existing instances and completion history survive unchanged.""" + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + definition_id = created.definition.id + rule_id = created.definition.schedule_rule_id + + # Seed a real instance and a real completion event against the + # definition BEFORE deactivation, so the test can detect a + # delete or mutation of pre-existing data (not just an empty + # table staying empty). + 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" + ) + + await set_quest_definition_active(database, definition_id, False) + + # The definition row survives (deactivated, not deleted). + stored = await definitions.get(definition_id) + assert stored is not None + assert stored.is_active is False + # Its rule, assignee and window rows all survive too. + assert await rules.get(rule_id) is not None + assert [c.id for c in await definitions.list_assignees( + definition_id + )] == [child.id] + assert [w.window for w in await definitions.list_windows( + definition_id + )] == ["morning"] + # The pre-existing instance and completion event survive with + # unchanged ids and contents, and no new 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 + 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) + + +def _gated_hass( + armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event +): + """A hass mock whose executor gates the FIRST ``_execute`` job after + arming — the UPDATE of whichever ``set_quest_definition_active`` + call starts first — proving the caller holds the connection lock at + that moment and the second caller must queue behind it.""" + from unittest.mock import MagicMock + + hass = MagicMock() + + async def _gated_executor(fn, *args): + if armed["active"]: + if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor + return hass + + +def test_set_quest_definition_active_concurrent_opposite_transitions_return_own( + tmp_path, +) -> None: + """Racing deactivate/reactivate calls each report their own request. + + Deterministic gate on task A's UPDATE: task B queues behind the + connection lock and lands the opposite transition after A. Without + the lock, A's readback could see B's write and report B's value. + """ + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "active-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + + armed["active"] = True + task_a = asyncio.ensure_future( + set_quest_definition_active( + database, created.definition.id, False + ) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + set_quest_definition_active( + database, created.definition.id, True + ) + ) + await asyncio.sleep(0) + gate_open.set() + record_a, record_b = await asyncio.gather(task_a, task_b) + + # Each caller gets back exactly the state it requested. + assert record_a.definition.is_active is False + assert record_b.definition.is_active is True + # Both snapshots stay coherent: the decoded rule and the + # assignees are those of the definition they were read from. + assert record_a.rule == _daily_rule() + assert record_b.rule == _daily_rule() + assert [c.id for c in record_a.assignees] == [child.id] + assert [c.id for c in record_b.assignees] == [child.id] + # B landed last: the stored row is B's (read via the DAO — + # the four-table leak guard forbids raw probes here). + stored = await QuestDefinitionsDao(database).get( + created.definition.id + ) + assert stored is not None + assert stored.is_active is True + finally: + await database.close() + + _run(_main()) -- 2.55.0 From 1432dba74c5f728a30e3bff21c8ba1023a6cb88d Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 09:49:54 -0400 Subject: [PATCH 06/28] Quest definition query helpers for materialization (Feature 06, task 6) (#71) --- .../nestquest/quest_definitions.py | 100 ++++++++- tests/test_quest_definitions.py | 201 ++++++++++++++++++ 2 files changed, 300 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index 7d7fcf9..a38c240 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -49,6 +49,7 @@ from .dao_rules import ( ScheduleRuleStorage, ScheduleRulesDao, _UNSET, + _validate_date, _validate_time, _validate_window, schedule_rule_from_storage, @@ -56,7 +57,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) @@ -452,3 +453,100 @@ async def set_quest_definition_active( assignees=assignees, windows=windows, ) + + +async def _bundle_definition( + database: NestQuestDatabase, + dao: QuestDefinitionsDao, + definition: QuestDefinitionRecord, +) -> CreatedQuestDefinition: + """Assemble a definition's rich view: decoded rule + assignees + windows. + + Reads the schedule rule through :class:`~.dao_rules.ScheduleRulesDao` + and the assignees and windows through the DAO, then wraps them in the + same :class:`CreatedQuestDefinition` shape the create/edit/activate + paths return, so every query helper here reuses one snapshot type + rather than inventing a second one. + """ + rule_record = await ScheduleRulesDao(database).get( + definition.schedule_rule_id + ) + assert rule_record is not None + rule = schedule_rule_from_storage( + schedule_rule_storage_from_record(rule_record) + ) + assignees = await dao.list_assignees(definition.id) + windows = await dao.list_windows(definition.id) + return CreatedQuestDefinition( + definition=definition, + rule=rule, + assignees=assignees, + windows=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. + """ + dao = QuestDefinitionsDao(database) + definitions = await dao.list_active() + return [ + await _bundle_definition(database, dao, definition) + for definition in definitions + ] + + +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. + """ + _validate_child_id(child_id) + dao = QuestDefinitionsDao(database) + definitions = await dao.list_by_child(child_id) + bundles = [ + await _bundle_definition(database, dao, definition) + for definition in definitions + ] + 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. + """ + _validate_date(date, "date") + target = datetime.datetime.strptime(date, "%Y-%m-%d").date() + dao = QuestDefinitionsDao(database) + definitions = await dao.list_active() + bundles = [ + await _bundle_definition(database, dao, definition) + for definition in definitions + ] + return [bundle for bundle in bundles if occurs_on(bundle.rule, target)] diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 9266305..81ae99c 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -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, ) @@ -1420,3 +1423,201 @@ 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) -- 2.55.0 From e60a9892ffc5cab73460f525caf6ed9828067096 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 09:58:15 -0400 Subject: [PATCH 07/28] Tests for the quest definition business layer (Feature 06, task 7) (#72) --- tests/test_quest_definitions.py | 110 ++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 81ae99c..aa4b9fb 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -1621,3 +1621,113 @@ def test_list_definitions_firing_on_rejects_malformed_date(tmp_path, bad) -> Non return None _with_db(tmp_path, "list-firing-on-bad-date.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) -- 2.55.0 From fa40e21dc2c5e026412bfe873cff9c723c8ca59e Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 12:06:47 -0400 Subject: [PATCH 08/28] Fix feature-integration review findings F06-001 and F06-002 (#74) --- custom_components/nestquest/dao_rules.py | 60 +++++ .../nestquest/quest_definitions.py | 89 ++++---- tests/test_quest_definitions.py | 213 ++++++++++++++++++ 3 files changed, 317 insertions(+), 45 deletions(-) diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index d1338e4..5669368 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -907,6 +907,66 @@ 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 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, diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index a38c240..ca38322 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -44,6 +44,7 @@ from dataclasses import dataclass from .dao_children import ChildRecord, _connection_lock from .dao_rules import ( QuestDefinitionRecord, + QuestDefinitionSnapshot, QuestDefinitionWindowRecord, QuestDefinitionsDao, ScheduleRuleStorage, @@ -318,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) @@ -455,33 +465,25 @@ async def set_quest_definition_active( ) -async def _bundle_definition( - database: NestQuestDatabase, - dao: QuestDefinitionsDao, - definition: QuestDefinitionRecord, +def _bundle_snapshot( + snapshot: QuestDefinitionSnapshot, ) -> CreatedQuestDefinition: - """Assemble a definition's rich view: decoded rule + assignees + windows. + """Decode a DAO snapshot into the layer's rich view. - Reads the schedule rule through :class:`~.dao_rules.ScheduleRulesDao` - and the assignees and windows through the DAO, then wraps them in the - same :class:`CreatedQuestDefinition` shape the create/edit/activate - paths return, so every query helper here reuses one snapshot type - rather than inventing a second one. + 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_record = await ScheduleRulesDao(database).get( - definition.schedule_rule_id - ) - assert rule_record is not None rule = schedule_rule_from_storage( - schedule_rule_storage_from_record(rule_record) + schedule_rule_storage_from_record(snapshot.rule) ) - assignees = await dao.list_assignees(definition.id) - windows = await dao.list_windows(definition.id) return CreatedQuestDefinition( - definition=definition, + definition=snapshot.definition, rule=rule, - assignees=assignees, - windows=windows, + assignees=snapshot.assignees, + windows=snapshot.windows, ) @@ -494,14 +496,14 @@ async def list_active_definitions( 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. + :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) - definitions = await dao.list_active() - return [ - await _bundle_definition(database, dao, definition) - for definition in definitions - ] + snapshots = await dao.list_snapshots_active() + return [_bundle_snapshot(snapshot) for snapshot in snapshots] async def list_definitions_for_child( @@ -515,15 +517,12 @@ async def list_definitions_for_child( 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. + its decoded rule, assignees and windows, read atomically by the DAO. """ _validate_child_id(child_id) dao = QuestDefinitionsDao(database) - definitions = await dao.list_by_child(child_id) - bundles = [ - await _bundle_definition(database, dao, definition) - for definition in definitions - ] + 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) @@ -540,13 +539,13 @@ async def list_definitions_firing_on( 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) - definitions = await dao.list_active() - bundles = [ - await _bundle_definition(database, dao, definition) - for definition in definitions - ] + 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)] diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index aa4b9fb..758a01a 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -786,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 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): @@ -1623,6 +1641,201 @@ def test_list_definitions_firing_on_rejects_malformed_date(tmp_path, bad) -> Non _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 # --------------------------------------------------------------------------- -- 2.55.0 From 9836a6f5248f040b4e13098665d872b7e3ee00e1 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 14:01:20 -0400 Subject: [PATCH 09/28] Core generation walk: assignees x windows fan-out with presence (Feature 07, task 1) (#75) --- custom_components/nestquest/dao_instances.py | 111 ++++ custom_components/nestquest/dao_presence.py | 57 +- custom_components/nestquest/dao_rules.py | 60 +- custom_components/nestquest/materialize.py | 167 ++++++ tests/test_materialize.py | 564 +++++++++++++++++++ 5 files changed, 957 insertions(+), 2 deletions(-) create mode 100644 custom_components/nestquest/materialize.py create mode 100644 tests/test_materialize.py diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 93437f1..841c1a7 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -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, diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index 5279a25..cf1707a 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -276,4 +276,59 @@ class PresenceOverridesDao: "DELETE FROM presence_overrides WHERE id = ?", (override_id,), ) - return result.rowcount > 0 \ No newline at end of file + 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 \ No newline at end of file diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 5669368..ec3f6d9 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -951,6 +951,21 @@ class QuestDefinitionsDao: 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]: @@ -1189,4 +1204,47 @@ class QuestDefinitionsDao: if not child[0]: raise ValueError( f"child {child_id} is inactive and cannot be assigned" - ) \ No newline at end of file + ) + + +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 \ No newline at end of file diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py new file mode 100644 index 0000000..5c4de5b --- /dev/null +++ b/custom_components/nestquest/materialize.py @@ -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 \ No newline at end of file diff --git a/tests/test_materialize.py b/tests/test_materialize.py new file mode 100644 index 0000000..86dfe31 --- /dev/null +++ b/tests/test_materialize.py @@ -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) \ No newline at end of file -- 2.55.0 From 4544f7839034cc7580a4bde732c5275931661cac Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 14:04:02 -0400 Subject: [PATCH 10/28] Three-run idempotency with stable instance ids (Feature 07, task 2) (#76) --- tests/test_materialize.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 86dfe31..48c5c06 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -207,15 +207,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 -- 2.55.0 From 168bb7b2f131385040201e98b1e7e1286f65ca5b Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 14:09:42 -0400 Subject: [PATCH 11/28] Skip completed instances during materialization (Feature 07, task 3) (#77) --- custom_components/nestquest/dao_instances.py | 22 ++++--- custom_components/nestquest/materialize.py | 12 ++-- tests/test_materialize.py | 65 +++++++++++++++++++- 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 841c1a7..8c9e852 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -314,9 +314,13 @@ 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) @@ -359,12 +363,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) " diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 5c4de5b..577533c 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -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,9 +32,11 @@ 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`. """ from __future__ import annotations diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 48c5c06..bd5025d 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -7,7 +7,10 @@ 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.db import NestQuestDatabase @@ -15,6 +18,7 @@ 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, + edit_quest_definition, ) from custom_components.nestquest.recurrence import RuleType, ScheduleRule @@ -245,6 +249,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: -- 2.55.0 From 21465316657f9128ed8052cf7c10b146b666900a Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 14:24:22 -0400 Subject: [PATCH 12/28] Regenerate future instances on definition change (Feature 07, task 4) (#78) --- custom_components/nestquest/materialize.py | 35 ++++- .../nestquest/quest_definitions.py | 12 +- tests/test_materialize.py | 138 +++++++++++++++++- tests/test_quest_definitions.py | 66 ++++----- 4 files changed, 208 insertions(+), 43 deletions(-) diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 577533c..a5e5abf 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -42,6 +42,7 @@ from __future__ import annotations import datetime +from .const import DEFAULT_HORIZON_DAYS from .dao_instances import QuestInstancesDao from .dao_rules import ( _validate_date, @@ -166,4 +167,36 @@ async def materialize( if written is not None: count += 1 cursor += datetime.timedelta(days=1) - return count \ No newline at end of file + 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 — + ``datetime.date.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 = datetime.date.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) diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index ca38322..817f7fc 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -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, diff --git a/tests/test_materialize.py b/tests/test_materialize.py index bd5025d..e114678 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -12,15 +12,25 @@ 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.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_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 +from custom_components.nestquest.recurrence import ( + RuleType, + ScheduleRule, + occurs_on, +) def _run(coro): @@ -642,4 +652,124 @@ def test_materialize_skips_inactive_definitions(tmp_path) -> None: assert count == 0 return count - _with_db(tmp_path, "materialize-inactive.db")(_body) \ No newline at end of file + _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) \ No newline at end of file diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 758a01a..2770113 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -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 -- 2.55.0 From ca26ced59f08be75906e420d9c832ad580fea5be Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 14:50:50 -0400 Subject: [PATCH 13/28] Regenerate future instances on presence change (Feature 07, task 5) (#79) --- custom_components/nestquest/dao_instances.py | 65 ++++- custom_components/nestquest/materialize.py | 106 +++++++- tests/test_materialize.py | 270 ++++++++++++++++++- 3 files changed, 432 insertions(+), 9 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 8c9e852..c948aa8 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -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: " @@ -326,7 +338,7 @@ class QuestInstancesDao: _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 " @@ -334,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: " @@ -475,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 = ? " @@ -487,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. diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index a5e5abf..342e626 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -37,13 +37,34 @@ 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 .const import DEFAULT_HORIZON_DAYS -from .dao_instances import QuestInstancesDao +from .dao_instances import QuestInstancesDao, _today from .dao_rules import ( _validate_date, load_materialization_input, @@ -104,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]. @@ -113,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 @@ -132,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), @@ -153,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: @@ -186,17 +218,85 @@ async def regenerate_for_definition( assignee set and windows re-materialize against today's state. "today" is computed the same way the walk computes it — - ``datetime.date.today()`` — so the delete cutoff and the + :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 = datetime.date.today() + 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] + ) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e114678..62523fd 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -19,6 +19,7 @@ from custom_components.nestquest.dao_rules import ( from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.materialize import ( materialize, + regenerate_for_child, regenerate_for_definition, ) from custom_components.nestquest.migrations import apply_migrations @@ -772,4 +773,271 @@ def test_edit_quest_definition_regenerates_future_instances(tmp_path) -> None: assert (day.isoformat() in by_due) == occurs_on(weekly, day) return None - _with_db(tmp_path, "edit-regenerates.db")(_body) \ No newline at end of file + _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) \ No newline at end of file -- 2.55.0 From be6367b600d7aa8fed7d1c07fc85db1e13a3c536 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 15:10:57 -0400 Subject: [PATCH 14/28] Schedule the daily generation run at rollover (Feature 07, task 6) (#80) --- custom_components/nestquest/__init__.py | 87 ++++++++++++++-- tests/conftest.py | 69 +++++++++++++ tests/test_lifecycle.py | 128 +++++++++++++++++++++++- 3 files changed, 276 insertions(+), 8 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 91a962d..52a5eeb 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -2,19 +2,30 @@ from __future__ import annotations import asyncio +import datetime import inspect import sqlite3 from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any +from zoneinfo import ZoneInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.event import async_track_time_change -from .const import CONF_ADMIN_USER_IDS, DOMAIN, LOGGER +from .const import ( + CONF_ADMIN_USER_IDS, + CONF_DAY_ROLLOVER_TIME, + DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_HORIZON_DAYS, + DOMAIN, + LOGGER, +) from .db import NestQuestDatabase +from .materialize import materialize as _materialize_run from .migrations import apply_migrations from .store import async_get_db_path from .admin_allowlist import seed_setup_admin @@ -224,6 +235,7 @@ class NestQuestRuntimeData: options: dict[str, Any] database: NestQuestDatabase remove_update_listener: Callable[[], Any] + remove_time_change_listener: Callable[[], Any] async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: @@ -248,6 +260,48 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: return owners +def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]: + """Return the (hour, minute) the daily rollover fires at from ``options``. + + The options flow validates ``day_rollover_time`` as a strict ``HH:MM`` + string, so a stored value never needs re-validation here; a missing key + falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`. + """ + rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME) + hour, _, minute = rollover.partition(":") + return int(hour), int(minute) + + +def _register_day_rollover_listener( + hass: HomeAssistant, + database: NestQuestDatabase, + options: dict[str, Any], +) -> Callable[[], Any]: + """Register the daily materialization listener and return its remover. + + Fires once per day at the configured local ``day_rollover_time`` and + materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. + "today" is computed in HA local time (``hass.config.time_zone``) rather + than the system clock, so the horizon tracks the household's own day. + The walk itself runs through :func:`~.materialize.materialize`, whose DB + access is executor-only. + """ + hour, minute = _day_rollover_hour_minute(options) + + async def _run_materialization(_now: datetime.datetime) -> None: + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + start_date = today.isoformat() + end_date = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + await _materialize_run(database, start_date, end_date) + + return async_track_time_change( + hass, _run_materialization, hour=hour, minute=minute + ) + + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" return True @@ -330,6 +384,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: owner_ids=owner_ids, ) remove_update_listener = entry.add_update_listener(_async_update_listener) + remove_time_change_listener = _register_day_rollover_listener( + hass, database, dict(entry.options) + ) except BaseException: await database.close() raise @@ -338,6 +395,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options=dict(entry.options), database=database, remove_update_listener=remove_update_listener, + remove_time_change_listener=remove_time_change_listener, ) entry.runtime_data = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data @@ -354,14 +412,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: runtime_data = getattr(entry, "runtime_data", None) if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) + remove_time_change_listener = getattr( + runtime_data, "remove_time_change_listener", None + ) unload_error: BaseException | None = None try: - if remove_update_listener is not None: - result = remove_update_listener() - if inspect.isawaitable(result): - await result - except BaseException as err: - unload_error = err + for remove_listener in ( + remove_update_listener, + remove_time_change_listener, + ): + if remove_listener is None: + continue + try: + result = remove_listener() + if inspect.isawaitable(result): + await result + except BaseException as err: + # Cancel EVERY listener even when an earlier remover + # raises: a skipped remover would leave a daily + # callback firing against a closed database after + # unload. The first error is retained and re-raised + # only after both removers (and the DB close) ran. + if unload_error is None: + unload_error = err finally: database = getattr(runtime_data, "database", None) if database is not None: diff --git a/tests/conftest.py b/tests/conftest.py index 5694471..ff8c5d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,7 @@ _HA_MODULES = ( "homeassistant.exceptions", "homeassistant.helpers", "homeassistant.helpers.config_validation", + "homeassistant.helpers.event", ) def _find_spec(name: str): @@ -83,6 +84,7 @@ for _parent, _child in ( ("homeassistant", "exceptions"), ("homeassistant", "helpers"), ("homeassistant.helpers", "config_validation"), + ("homeassistant.helpers", "event"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -242,6 +244,69 @@ class ListenerRegistry: return sum(len(listeners) for listeners in self._listeners.values()) +class TimeChangeRegistry: + """Models HA's time-change listener bookkeeping. + + ``track`` mirrors ``homeassistant.helpers.event.async_track_time_change``: + each call appends a record (action + hour/minute/second) and returns + a remove callable that reverses the registration. Tests inspect + ``registrations`` and exercise the newest action through :meth:`fire`. + """ + + def __init__(self, hass=None): + self._hass = hass + self._registrations: list[dict] = [] + + def track(self, action, hour=None, minute=None, second=None): + record = { + "action": action, + "hour": hour, + "minute": minute, + "second": second, + } + + def _remove(): + self._registrations.remove(record) + + record["remove"] = _remove + self._registrations.append(record) + return _remove + + @property + def registrations(self): + """Return a copy of the live registrations.""" + return list(self._registrations) + + @property + def size(self) -> int: + """Number of live registrations.""" + return len(self._registrations) + + async def fire(self, now=None, index: int = -1): + """Run a registered action as HA's scheduler would, awaiting coroutines.""" + result = self._registrations[index]["action"](now) + if inspect.isawaitable(result): + await result + + +def _async_track_time_change(hass, action, hour=None, minute=None, second=None): + """Mirror homeassistant.helpers.event.async_track_time_change. + + Exact HA 2024.6 signature — no ``local`` keyword: the local-time + wrapper is already this function. Routes to the registry attached to + ``hass`` (created on demand), so tests reach the registrations via + ``hass.time_change``. + """ + registry = getattr(hass, "time_change", None) + if registry is None: + registry = TimeChangeRegistry(hass) + hass.time_change = registry + return registry.track(action, hour=hour, minute=minute, second=second) + + +_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change + + def make_config_entry( entry_id: str = "test_entry", options: dict | None = None, @@ -303,6 +368,10 @@ def make_hass() -> tuple: hass.async_add_executor_job = _run_executor_job registry = ListenerRegistry(hass) + hass.time_change = TimeChangeRegistry(hass) + # A valid IANA time zone (the day-rollover listener reads it to + # compute "today" in HA local time). + hass.config.time_zone = "UTC" async def _async_reload(entry_id: str) -> None: registry.reloaded.append(entry_id) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index c3e0f17..f56864f 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -8,7 +8,11 @@ import pytest from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry -from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST +from custom_components.nestquest.const import ( + CONF_DAY_ROLLOVER_TIME, + DEFAULT_HORIZON_DAYS, + DOMAIN as DOMAIN_CONST, +) def _wire(entry, registry): @@ -237,6 +241,128 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non assert database.connected is False, "database leaked despite remover error" assert entry.runtime_data is None assert DOMAIN not in hass.data + assert hass.time_change.size == 0, "time-change listener leaked" + + +async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None: + """A raising update-listener remover must not skip the time-change cancel. + + Both removers are independent: the update-listener remover raising must + still cancel the daily time-change callback, whose action would otherwise + fire after unload against a closed database. + """ + + class _RemoveBoom(Exception): + pass + + def _remover(): + raise _RemoveBoom() + + entry = make_config_entry(entry_id="update-boom-time-cancel") + entry.add_update_listener = lambda listener: _remover + hass, _registry = make_hass() + await async_setup_entry(hass, entry) + assert hass.time_change.size == 1 + with pytest.raises(_RemoveBoom): + await async_unload_entry(hass, entry) + assert hass.time_change.size == 0, "time-change listener not cancelled" + assert entry.runtime_data is None + assert DOMAIN not in hass.data + + +async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None: + """Setup registers a daily rollover listener and stores its cancel fn.""" + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert hass.time_change.size == 1 + registration = hass.time_change.registrations[0] + assert registration["hour"] == 0 + assert registration["minute"] == 0 + assert callable(entry.runtime_data.remove_time_change_listener) + + +async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None: + """The listener fires at the entry option's rollover time, not the default.""" + entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry) + await async_setup_entry(hass, entry) + registration = hass.time_change.registrations[0] + assert registration["hour"] == 6 + assert registration["minute"] == 30 + + +async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None: + """Unload removes the time-change listener alongside the update listener.""" + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + assert hass.time_change.size == 1 + assert await async_unload_entry(hass, entry) is True + assert hass.time_change.size == 0 + + +async def test_rollover_time_change_reregisters_listener_at_new_time( + hass, make_entry +) -> None: + """A rollover-time options change re-registers the listener at the new time.""" + entry = _wire( + make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry + ) + await async_setup_entry(hass, entry) + assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0) + + # The options flow writes the new rollover time, then HA reloads the + # entry: unload tears down the old listener, setup re-registers it. + entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"} + await async_unload_entry(hass, entry) + assert hass.time_change.size == 0 + + assert await async_setup_entry(hass, entry) is True + assert hass.time_change.size == 1 + registration = hass.time_change.registrations[0] + assert (registration["hour"], registration["minute"]) == (6, 30) + + +async def test_day_rollover_listener_materializes_ha_local_horizon( + hass, make_entry, monkeypatch +) -> None: + """Firing the listener materializes today..today+horizon in HA local time.""" + import datetime + from unittest.mock import AsyncMock + from zoneinfo import ZoneInfo + + import custom_components.nestquest as nestquest + + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + database = entry.runtime_data.database + + fake = AsyncMock() + monkeypatch.setattr(nestquest, "_materialize_run", fake) + + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + expected_start = today.isoformat() + expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + + await hass.time_change.fire() + + fake.assert_awaited_once_with(database, expected_start, expected_end) + + +def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None: + """The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg). + + Setup drives the local-time wrapper directly, so passing ``local=`` must + raise TypeError — otherwise a regression in the production call would be + silently masked by the fake. + """ + import inspect as _inspect + + from homeassistant.helpers.event import async_track_time_change + + params = _inspect.signature(async_track_time_change).parameters + assert list(params) == ["hass", "action", "hour", "minute", "second"] + with pytest.raises(TypeError): + async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True) async def test_setup_applies_all_v1_tables(hass, make_entry) -> None: -- 2.55.0 From 5706d8b7a6ca41b40735fe098f41c0452d6856bd Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 15:42:10 -0400 Subject: [PATCH 15/28] Startup backfill + regenerate service (Feature 07, task 7) (#81) --- custom_components/nestquest/__init__.py | 132 ++++++++++++-- custom_components/nestquest/const.py | 3 + custom_components/nestquest/dao_instances.py | 104 +++++++---- custom_components/nestquest/materialize.py | 48 +++-- custom_components/nestquest/services.yaml | 12 ++ tests/conftest.py | 45 +++++ tests/test_lifecycle.py | 176 ++++++++++++++++++- tests/test_materialize.py | 42 ++++- tests/test_services.py | 41 +++++ 9 files changed, 529 insertions(+), 74 deletions(-) create mode 100644 custom_components/nestquest/services.yaml create mode 100644 tests/test_services.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 52a5eeb..6615679 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -23,6 +23,7 @@ from .const import ( DEFAULT_HORIZON_DAYS, DOMAIN, LOGGER, + SERVICE_REGENERATE, ) from .db import NestQuestDatabase from .materialize import materialize as _materialize_run @@ -272,6 +273,29 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]: return int(hour), int(minute) +async def _run_horizon_materialization( + hass: HomeAssistant, database: NestQuestDatabase +) -> None: + """Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. + + "today" is computed in HA local time (``hass.config.time_zone``) rather + than the system clock, so the horizon tracks the household's own day. + The walk itself runs through :func:`~.materialize.materialize`, whose DB + access is executor-only. + + This is the ONE generation path: the startup backfill, the daily rollover + listener and the ``regenerate`` service all funnel through it, so there is + no duplicated materialization logic. + """ + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + start_date = today.isoformat() + end_date = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + await _materialize_run(database, start_date, end_date, today=today) + + def _register_day_rollover_listener( hass: HomeAssistant, database: NestQuestDatabase, @@ -280,28 +304,69 @@ def _register_day_rollover_listener( """Register the daily materialization listener and return its remover. Fires once per day at the configured local ``day_rollover_time`` and - materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. - "today" is computed in HA local time (``hass.config.time_zone``) rather - than the system clock, so the horizon tracks the household's own day. - The walk itself runs through :func:`~.materialize.materialize`, whose DB - access is executor-only. + materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` + through the shared :func:`_run_horizon_materialization` path. """ hour, minute = _day_rollover_hour_minute(options) async def _run_materialization(_now: datetime.datetime) -> None: - time_zone = ZoneInfo(hass.config.time_zone) - today = datetime.datetime.now(time_zone).date() - start_date = today.isoformat() - end_date = ( - today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - await _materialize_run(database, start_date, end_date) + await _run_horizon_materialization(hass, database) return async_track_time_change( hass, _run_materialization, hour=hour, minute=minute ) +def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None: + """Return a connected database from the active runtime data, or None. + + The domain-global services (e.g. ``regenerate``) have no database of + their own; they resolve whichever config entry's database is currently + live at call time, so none of them can hold a stale handle to a closed + connection after another entry unloads. + """ + for runtime_data in hass.data.get(DOMAIN, {}).values(): + database = getattr(runtime_data, "database", None) + if database is not None and database.connected: + return database + return None + + +def _register_regenerate_service(hass: HomeAssistant) -> None: + """Register ``nestquest.regenerate`` once at domain scope. + + The service triggers a full on-demand regeneration over the rolling + horizon, running the SAME idempotent materialize path + (:func:`_run_horizon_materialization`) as the startup backfill and the + daily rollover listener. It is DOMAIN-global — registered exactly once + (guarded by ``has_service``) and NOT bound to any config entry — so the + handler resolves the currently-live database at call time and a name + collision or duplicate setup is impossible. Removal is handled by the + unload path only once the LAST entry is removed (see + :func:`_deregister_regenerate_service`). + """ + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + return + + async def _regenerate(_call: Any) -> None: + database = _find_live_database(hass) + if database is None: + _LOGGER.warning( + "NestQuest regenerate requested but no config entry has a " + "live database; ignoring" + ) + return + await _run_horizon_materialization(hass, database) + + hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) + + +def _deregister_regenerate_service(hass: HomeAssistant) -> None: + """Remove ``nestquest.regenerate`` when the last entry unloads.""" + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + hass.services.async_remove(DOMAIN, SERVICE_REGENERATE) + + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" return True @@ -359,6 +424,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await listener_hass.config_entries.async_reload(listener_entry.entry_id) database = await _async_open_database(hass, await async_get_db_path(hass)) + remove_update_listener: Callable[[], Any] | None = None + remove_time_change_listener: Callable[[], Any] | None = None try: # Seed the admin allowlist on first setup so the owner is never # locked out: an empty database takes the persisted admin copy @@ -387,9 +454,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_time_change_listener = _register_day_rollover_listener( hass, database, dict(entry.options) ) + # Backfill any days the daily listener missed while the integration + # was off (or freshly installed): run the one idempotent walk over + # [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs + # through the SAME path as the daily listener and regenerate service. + await _run_horizon_materialization(hass, database) + _register_regenerate_service(hass) except BaseException: + # A failure anywhere after the database is open must not leak the + # listeners already registered against that (now-closed) database: + # unwind EVERY remover we acquired before the failure, keeping the + # original error as the one raised. Removal is best-effort — a + # failing remover must not mask the setup error that triggered it. + for remove_listener in ( + remove_time_change_listener, + remove_update_listener, + ): + if remove_listener is None: + continue + try: + result = remove_listener() + if inspect.isawaitable(result): + await result + except BaseException: + pass await database.close() raise + assert remove_update_listener is not None + assert remove_time_change_listener is not None runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, options=dict(entry.options), @@ -406,16 +498,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" domain_data = hass.data.setdefault(DOMAIN, {}) runtime_data = domain_data.pop(entry.entry_id, None) - if not domain_data: + is_last = not domain_data + if is_last: hass.data.pop(DOMAIN, None) if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) + unload_error: BaseException | None = None if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) remove_time_change_listener = getattr( runtime_data, "remove_time_change_listener", None ) - unload_error: BaseException | None = None try: for remove_listener in ( remove_update_listener, @@ -439,10 +532,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = getattr(runtime_data, "database", None) if database is not None: await database.close() - if unload_error is not None: - if getattr(entry, "runtime_data", None) is not None: - entry.runtime_data = None - raise unload_error + # The ``regenerate`` service is DOMAIN-global: it is torn down only + # once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty), + # never when a sibling entry is still loaded. + if is_last: + _deregister_regenerate_service(hass) if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None + if unload_error is not None: + raise unload_error return True diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 8167e53..ce21568 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -9,6 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME) PLATFORMS: list[str] = [] +#: Service name for the on-demand regeneration service (``nestquest.regenerate``). +SERVICE_REGENERATE = "regenerate" + SQLITE_DB_FILENAME = "nestquest.db" # Configuration keys and default values diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index c948aa8..94f05b9 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -59,6 +59,34 @@ def _today() -> datetime.date: return datetime.date.today() +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the caller-resolved "today", or the host date when absent. + + The no-past guard and the delete cutoffs must compare due dates + against the SAME calendar date the materialization horizon was built + from. Home Assistant's configured time zone can lag the host clock + around midnight, so when the caller has already resolved the local + date (``today``) that anchor is authoritative; ``None`` falls back to + the host clock (:func:`_today`), which is exactly the previous + behavior for callers with no HA-local date. + """ + return _today() if today is None else today + + +def _reject_past(due_date: str, today_iso: str) -> None: + """Raise unless ``due_date`` is on or after ``today_iso``. + + The single no-past rule shared by :meth:`QuestInstancesDao.upsert` + and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never + generated in the past, measured against the resolved "today". + """ + if due_date < today_iso: + raise ValueError( + f"instances are never generated in the past: due_date " + f"{due_date!r} is before today {today_iso!r}" + ) + + def _validate_utc_timestamp(value: str, field: str) -> None: """Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp. @@ -178,6 +206,7 @@ class QuestInstancesDao: *, window: str, due_time: str | None = None, + today: datetime.date | None = None, ) -> QuestInstanceRecord: """Create the dated instance for the widened key idempotently. @@ -196,6 +225,10 @@ class QuestInstancesDao: - ``due_date`` must be TODAY or later: instances are never generated in the past (Feature 07). The caller's materialization horizon is future-dated by definition. + ``today`` optionally pins the resolved HA-local date the + guard compares against (falling back to the host clock when + omitted) so a household time zone behind the host around + midnight is not misread as "past". - ``window`` must be one of ``const.QUEST_WINDOWS``. - An existing instance WITH a completion event is IMMUTABLE: the conflict path must not rewrite its snapshot columns @@ -215,24 +248,20 @@ 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 = _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}" - ) + # A caller-supplied ``today`` (HA-local) is used for BOTH + # checks and never re-read, since it is already the anchor the + # batch runs against. + today_date = _resolve_today(today) + _reject_past(due_date, today_date.isoformat()) async with _connection_lock(self._database): async with self._database.transaction(): # 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 = _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 " - f"{today!r}" - ) + # holds across midnight rollovers. A pinned HA-local + # anchor is exempt from the re-read (see above). + if today is None: + today_date = _today() + _reject_past(due_date, today_date.isoformat()) definition = await self._database.fetch_one( "SELECT 1 FROM quest_definitions WHERE id = ?", (definition_id,), @@ -298,6 +327,7 @@ class QuestInstancesDao: *, window: str, due_time: str | None = None, + today: datetime.date | None = None, ) -> QuestInstanceRecord | None: """Insert one dated instance iff its tuple is still materializable. @@ -327,7 +357,8 @@ class QuestInstancesDao: 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 (instances are never generated in the past) is - preserved from :meth:`upsert` and still raises ValueError. An + preserved from :meth:`upsert` and still raises ValueError; ``today`` + optionally pins the HA-local anchor it compares against. 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 @@ -338,20 +369,13 @@ class QuestInstancesDao: _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 = _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}" - ) + today_date = _resolve_today(today) + _reject_past(due_date, today_date.isoformat()) async with _connection_lock(self._database): async with self._database.transaction(): - today = _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}" - ) + if today is None: + today_date = _today() + _reject_past(due_date, today_date.isoformat()) still_valid = await self._database.fetch_one( "SELECT 1 FROM quest_definitions d " "JOIN quest_definition_assignees a " @@ -463,7 +487,7 @@ class QuestInstancesDao: return [_instance_from_row(row) for row in rows] async def delete_future_uncompleted( - self, definition_id: int, cutoff_date: str + self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None ) -> int: """Delete the definition's open instances at/after cutoff. @@ -475,9 +499,11 @@ class QuestInstancesDao: is measured against TODAY (materialization regenerates the rolling horizon from now), so a caller cannot use this to rewrite past open instances either — the day-rollover missed - sweep (Feature 11) handles past-due instances instead. Any - instance with a completion event is never touched, regardless - of its date. Returns the number deleted. + sweep (Feature 11) handles past-due instances instead. ``today`` + optionally pins the HA-local anchor the clamp compares against + (falling back to the host clock when omitted). Any instance with + a completion event is never touched, regardless of its date. + Returns the number deleted. """ _validate_date(cutoff_date, "cutoff_date") async with _connection_lock(self._database): @@ -487,8 +513,8 @@ class QuestInstancesDao: # midnight cannot delete instances that became past # while it waited (backdating is clamped to the # execution date, not the call date). - today = _today().isoformat() - effective_cutoff = max(cutoff_date, today) + today_date = _resolve_today(today) + effective_cutoff = max(cutoff_date, today_date.isoformat()) result = await self._database.execute( "DELETE FROM quest_instances WHERE definition_id = ? " "AND due_date >= ? AND NOT EXISTS (" @@ -500,7 +526,7 @@ class QuestInstancesDao: return result.rowcount async def delete_future_uncompleted_for_child( - self, child_id: int, cutoff_date: str + self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None ) -> tuple[int, str]: """Delete the child's open instances at/after cutoff, across every definition, returning ``(deleted, effective_cutoff)``. @@ -511,8 +537,10 @@ class QuestInstancesDao: 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. + docstring). ``today`` optionally pins the HA-local anchor the clamp + compares against (falling back to the host clock when omitted). + 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 @@ -530,8 +558,8 @@ class QuestInstancesDao: # 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) + today_date = _resolve_today(today) + effective_cutoff = max(cutoff_date, today_date.isoformat()) result = await self._database.execute( "DELETE FROM quest_instances WHERE child_id = ? " "AND due_date >= ? AND NOT EXISTS (" diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 342e626..3f22e07 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -64,7 +64,7 @@ from __future__ import annotations import datetime from .const import DEFAULT_HORIZON_DAYS -from .dao_instances import QuestInstancesDao, _today +from .dao_instances import QuestInstancesDao, _resolve_today from .dao_rules import ( _validate_date, load_materialization_input, @@ -127,6 +127,7 @@ async def materialize( end_date: str, *, child_ids: list[int] | None = None, + today: datetime.date | None = None, ) -> int: """Generate quest instances for the closed range [start_date, end_date]. @@ -142,6 +143,14 @@ async def materialize( after a presence change) cannot touch any unrelated child's rows. When omitted (or None) the walk covers every assignee as before. + ``today`` optionally pins the resolved HA-local date the walk's + no-past guard compares against; it is threaded into every + :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When + omitted the host clock (``datetime.date.today``) is used, which is the + historical default. A caller that computed the horizon in Home + Assistant's configured time zone MUST pass that same date here so a + time zone behind the host around midnight is not rejected as "past". + 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 @@ -195,6 +204,7 @@ async def materialize( generated_at, window=window.window, due_time=window.due_time, + today=today, ) if written is not None: count += 1 @@ -205,6 +215,8 @@ async def materialize( async def regenerate_for_definition( database: NestQuestDatabase, definition_id: int, + *, + today: datetime.date | None = None, ) -> int: """Regenerate a definition's future instances after a config change. @@ -221,17 +233,24 @@ async def regenerate_for_definition( :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 + range. ``today`` optionally pins a caller-resolved HA-local date + (threaded into both the delete cutoff and the re-materialization) + so a household time zone behind the host around midnight stays + consistent. Returns the number of instances the re-materialization 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() + today_date = _resolve_today(today) + start_date = today_date.isoformat() + end_date = ( + today_date + 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) + await instances.delete_future_uncompleted( + definition_id, start_date, today=today + ) + return await materialize(database, start_date, end_date, today=today) # HOOK (Feature 09 presence services): ``regenerate_for_child`` is the @@ -250,6 +269,8 @@ async def regenerate_for_definition( async def regenerate_for_child( database: NestQuestDatabase, child_id: int, + *, + today: datetime.date | None = None, ) -> int: """Regenerate a child's future instances after a presence change. @@ -277,13 +298,16 @@ async def regenerate_for_child( :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. + ``today`` optionally pins a caller-resolved HA-local date, threaded + into the delete cutoff and the re-materialization for the same + host-vs-HA time-zone consistency. Returns the number of instances + 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() + today_date = _resolve_today(today) instances = QuestInstancesDao(database) # Use the delete's returned effective cutoff as the materialize anchor, @@ -293,10 +317,10 @@ async def regenerate_for_child( # 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() + child_id, today_date.isoformat(), today=today ) 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] + database, start.isoformat(), end_date, child_ids=[child_id], today=today ) diff --git a/custom_components/nestquest/services.yaml b/custom_components/nestquest/services.yaml new file mode 100644 index 0000000..eeae053 --- /dev/null +++ b/custom_components/nestquest/services.yaml @@ -0,0 +1,12 @@ +# Service declarations for the NestQuest integration (Home Assistant +# services.yaml metadata). Services are domain-scoped: nested under the +# "nestquest" domain, not repeated per config entry. + +regenerate: + name: Regenerate quest instances + description: >- + Regenerate every future quest instance for the household over the rolling + horizon, using the same idempotent materialization as the daily rollover + and the startup backfill. Safe to call at any time: a re-run refreshes the + same rows instead of duplicating them, and completed instances are never + touched. \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index ff8c5d6..b6603e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -307,6 +307,50 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None): _ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change +class ServiceRegistry: + """Models HA's ``hass.services`` service registry. + + ``async_register`` stores a handler under its (domain, service) key, + ``has_service`` reports registration, and ``async_remove`` removes it + — exactly the surface Home Assistant's ``ServiceRegistry`` exposes + (there is deliberately NO ``async_unregister`` alias here, so a caller + that uses the wrong method name fails instead of being silently + papered over). ``call`` invokes a stored handler the way HA's service + bus would — awaiting a coroutine result — so tests can exercise a + registered service end-to-end. + """ + + def __init__(self, hass=None): + self._hass = hass + self._services: dict[tuple[str, str], object] = {} + + def async_register(self, domain, service, func, schema=None, supports_response=None): + self._services[(domain, service)] = func + return None + + def has_service(self, domain, service): + return (domain, service) in self._services + + def async_remove(self, domain, service): + self._services.pop((domain, service), None) + return None + + async def call(self, domain, service, data=None): + func = self._services.get((domain, service)) + if func is None: + raise KeyError(f"service {domain}.{service} not registered") + call = SimpleNamespace(domain=domain, service=service, data=data or {}) + result = func(call) + if inspect.isawaitable(result): + await result + return result + + @property + def registered_services(self): + """The registered (domain, service) pairs.""" + return list(self._services) + + def make_config_entry( entry_id: str = "test_entry", options: dict | None = None, @@ -369,6 +413,7 @@ def make_hass() -> tuple: hass.async_add_executor_job = _run_executor_job registry = ListenerRegistry(hass) hass.time_change = TimeChangeRegistry(hass) + hass.services = ServiceRegistry(hass) # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index f56864f..68be6ce 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -11,6 +11,7 @@ from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_ from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, + SERVICE_REGENERATE, DOMAIN as DOMAIN_CONST, ) @@ -345,7 +346,9 @@ async def test_day_rollover_listener_materializes_ha_local_horizon( await hass.time_change.fire() - fake.assert_awaited_once_with(database, expected_start, expected_end) + fake.assert_awaited_once_with( + database, expected_start, expected_end, today=today + ) def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None: @@ -381,4 +384,173 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None: "quest_definitions", "schedule_rules", ] - await async_unload_entry(hass, entry) \ No newline at end of file + await async_unload_entry(hass, entry) + + +async def _seed_daily_child_and_definition(database, today) -> int: + """Create one always-present child + a daily definition starting today. + + Returns the child's id. No presence schedule means the child is present + every day, so the daily rule yields exactly one instance per horizon day. + """ + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.quest_definitions import ( + create_quest_definition, + ) + from custom_components.nestquest.recurrence import RuleType, ScheduleRule + + NOW = "2026-09-14T12:00:00+00:00" + child = await ChildrenDao(database).create("Ada", NOW) + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()), + [child.id], + ["morning"], + ) + return child.id + + +def _local_today(hass) -> "datetime.date": + import datetime + from zoneinfo import ZoneInfo + + return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + + +async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None: + """Setup materializes once so a restart backfills any missed days.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + from custom_components.nestquest.store import async_get_db_path + + # Seed the database BEFORE setup: a restart reopens an existing file that + # already holds definitions. The startup backfill must pick them up. + db_path = await async_get_db_path(hass) + pre = NestQuestDatabase(hass) + await pre.open(db_path) + await apply_migrations(pre) + child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) + await pre.close() + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + + today = _local_today(hass) + end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + +async def test_regenerate_service_registered_and_materializes( + hass, make_entry +) -> None: + """``nestquest.regenerate`` is registered and calling it regenerates.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + # A definition created AFTER setup has no instances yet (create does not + # materialize); the service must generate them on demand. + database = entry.runtime_data.database + child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) + + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + + today = _local_today(hass) + end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + records = await QuestInstancesDao(database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + +async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None: + """Unload removes the ``regenerate`` service so no stale entry leaks.""" + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + assert await async_unload_entry(hass, entry) is True + assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + +async def test_regenerate_service_is_domain_scoped_across_entries( + hass, make_entry +) -> None: + """The ``regenerate`` service is domain-global, not per-entry. + + With two loaded entries the service must be registered and functional; + unloading one entry must keep it registered and functional for the + remaining entry; unloading the final entry must remove it. + """ + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) + entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + # The domain-global handler resolves a live database from any entry. + database_b = entry_b.runtime_data.database + child_id = await _seed_daily_child_and_definition( + database_b, _local_today(hass) + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + today = _local_today(hass) + end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + records = await QuestInstancesDao(database_b).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + # Unloading one entry must not tear down the shared service. + assert await async_unload_entry(hass, entry_a) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + # It still works for the remaining entry. + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + + # Unloading the final entry removes the domain-global service. + assert await async_unload_entry(hass, entry_b) is True + assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + +async def test_setup_cleans_listeners_when_materialization_raises( + hass, make_entry, monkeypatch +) -> None: + """A startup-materialization failure rolls back every registered listener. + + The daily and update listeners are installed before the startup + materialization runs; when that walk raises, the database is closed but + the remover callables must also be invoked so no listener survives + firing against a closed database. + """ + import custom_components.nestquest as nestquest + + async def _boom(*_args, **_kwargs): + raise RuntimeError("materialization failed") + + monkeypatch.setattr(nestquest, "_materialize_run", _boom) + + entry = _wire(make_entry(), hass.registry) + with pytest.raises(RuntimeError, match="materialization failed"): + await async_setup_entry(hass, entry) + + assert hass.time_change.size == 0 + assert hass.registry.size == 0 + assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + assert not hasattr(entry, "runtime_data") \ No newline at end of file diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 62523fd..c20f226 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -340,7 +340,7 @@ async def _materialize_with_change( seen = {"n": 0} async def _gated(self, definition_id, child_id, due_date, generated_at, - *, window, due_time=None): + *, window, due_time=None, today=None): seen["n"] += 1 if seen["n"] == 1: seen["first"] = (definition_id, child_id, window) @@ -348,7 +348,7 @@ async def _materialize_with_change( await release.wait() return await original_upsert( self, definition_id, child_id, due_date, generated_at, - window=window, due_time=due_time, + window=window, due_time=due_time, today=today, ) QuestInstancesDao.upsert_if_valid = _gated @@ -630,6 +630,42 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None: _with_db(tmp_path, "materialize-bounds.db")(_body) +def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: + """The no-past guard compares against a caller-supplied HA-local today. + + A household time zone behind the host clock around midnight resolves a + HA-local "today" one calendar day earlier than the host's + ``date.today()``. The host-clock guard would reject that date as past, + so the resolved HA-local date must be threaded through ``materialize`` + and accepted instead of raising. + """ + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + ha_today = datetime.date.today() - datetime.timedelta(days=1) + start_iso = ha_today.isoformat() + end_iso = (ha_today + datetime.timedelta(days=14)).isoformat() + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + ) + + # Without the pinned HA-local today, the host-clock guard rejects + # the horizon start (ha_today is "yesterday" on the host). + with pytest.raises(ValueError, match="never generated in the past"): + await materialize(database, start_iso, end_iso) + + # With today=ha_today, the same range is accepted and materialized. + count = await materialize(database, start_iso, end_iso, today=ha_today) + assert count == 15 + return count + + _with_db(tmp_path, "materialize-ha-local-today.db")(_body) + + def test_materialize_skips_inactive_definitions(tmp_path) -> None: async def _body(database): from custom_components.nestquest.quest_definitions import ( @@ -914,10 +950,8 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight( 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) diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..68f2cde --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,41 @@ +"""Tests for the NestQuest services.yaml declaration file.""" +from __future__ import annotations + +import re +from pathlib import Path + +SERVICES_PATH = ( + Path(__file__).parent.parent + / "custom_components" + / "nestquest" + / "services.yaml" +) + + +def test_services_yaml_exists() -> None: + assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}" + + +def test_services_yaml_declares_regenerate() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), ( + "services.yaml must declare the 'regenerate' service" + ) + + +def test_services_yaml_regenerate_has_description_and_no_fields() -> None: + """The regenerate service is documented with a clear description and no + required fields (there is no ``fields`` block under it).""" + text = SERVICES_PATH.read_text(encoding="utf-8") + match = re.search(r"^regenerate:\s*$", text, re.MULTILINE) + assert match is not None + section = text[match.end():] + next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) + if next_service is not None: + section = section[: next_service.start()] + assert re.search(r"description:", section), ( + "regenerate must carry a description" + ) + assert "fields:" not in section, ( + "regenerate declares no required fields" + ) \ No newline at end of file -- 2.55.0 From f6aad9d20f840bc6d20facabae76af495b1ff651 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 16:06:41 -0400 Subject: [PATCH 16/28] HA-local timezone + DST boundary tests (Feature 07, task 8) (#82) --- .../nestquest/quest_definitions.py | 32 +- tests/test_timezones.py | 370 ++++++++++++++++++ 2 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 tests/test_timezones.py diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index 817f7fc..86ada89 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -271,6 +271,7 @@ async def edit_quest_definition( icon: str | None | object = _UNSET, rule: ScheduleRule | object = _UNSET, windows: list[str | tuple[str, str | None]] | object = _UNSET, + today: datetime.date | None = None, ) -> CreatedQuestDefinition: """Edit a quest definition's metadata, rule and windows atomically. @@ -292,6 +293,13 @@ async def edit_quest_definition( so the returned bundle is a consistent edit snapshot. Edits change future instances only; no ``quest_instances`` or ``completion_events`` row is touched. + + ``today`` optionally pins the caller-resolved HA-local date the + regeneration's no-past guard and horizon are anchored to (threaded + into :func:`~.materialize.regenerate_for_definition`); it is a plain + ``datetime.date`` with no HA import here — the Feature 09 service + supplies the hass-derived value. When omitted, regeneration falls + back to the host clock, same as before. """ _validate_definition_id(definition_id) @@ -342,7 +350,7 @@ async def edit_quest_definition( schedule_rule_storage_from_record(snapshot.rule) ) - await regenerate_for_definition(database, definition_id) + await regenerate_for_definition(database, definition_id, today=today) return CreatedQuestDefinition( definition=snapshot.definition, @@ -356,6 +364,8 @@ async def assign_child( database: NestQuestDatabase, definition_id: int, child_id: int, + *, + today: datetime.date | None = None, ) -> list[ChildRecord]: """Assign ``child_id`` to the definition (idempotent). @@ -369,6 +379,9 @@ async def assign_child( write). Raises ValueError on any rejected argument. Only the ``quest_definition_assignees`` link is written; existing instances and completion history are never touched. + + ``today`` optionally pins the caller-resolved HA-local date threaded + into the assignment's regeneration (see :func:`edit_quest_definition`). """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -384,7 +397,7 @@ async def assign_child( if message.startswith("quest definition "): raise ValueError(f"definition_id: {message}") from error raise - await regenerate_for_definition(database, definition_id) + await regenerate_for_definition(database, definition_id, today=today) return assignees @@ -392,6 +405,8 @@ async def unassign_child( database: NestQuestDatabase, definition_id: int, child_id: int, + *, + today: datetime.date | None = None, ) -> bool: """Unassign ``child_id`` from the definition; True when removed. @@ -401,6 +416,9 @@ async def unassign_child( was not currently assigned. Raises ValueError on a rejected argument. Only the ``quest_definition_assignees`` link is written; existing instances and completion history are never touched. + + ``today`` optionally pins the caller-resolved HA-local date threaded + into the unassignment's regeneration (see :func:`edit_quest_definition`). """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -411,7 +429,7 @@ async def unassign_child( f"definition_id: quest definition {definition_id} does not exist" ) removed = await dao.remove_assignee(definition_id, child_id) - await regenerate_for_definition(database, definition_id) + await regenerate_for_definition(database, definition_id, today=today) return removed @@ -419,6 +437,8 @@ async def set_quest_definition_active( database: NestQuestDatabase, definition_id: int, is_active: bool, + *, + today: datetime.date | None = None, ) -> CreatedQuestDefinition: """Deactivate or reactivate a definition; returns the updated view. @@ -441,6 +461,10 @@ async def set_quest_definition_active( definition, its rule, assignees and windows all run inside the connection-scoped lock, so the returned bundle is a consistent snapshot that a concurrent mutation cannot race. + + ``today`` optionally pins the caller-resolved HA-local date threaded + into the activation change's regeneration (see + :func:`edit_quest_definition`). """ _validate_definition_id(definition_id) if not isinstance(is_active, bool): @@ -464,7 +488,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) + await regenerate_for_definition(database, definition_id, today=today) return CreatedQuestDefinition( definition=updated, rule=rule, diff --git a/tests/test_timezones.py b/tests/test_timezones.py new file mode 100644 index 0000000..273b588 --- /dev/null +++ b/tests/test_timezones.py @@ -0,0 +1,370 @@ +"""Timezone tests for the single HA-local date source in the generation path. + +These pin the done-condition that every "today" read in the generation path +derives from ``hass.config.time_zone`` — not the host clock and not UTC — +and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the +no-past guard resolve the correct local dates around midnight and across the +daylight-saving transitions. +""" +from __future__ import annotations + +import asyncio +import datetime +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS +from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.quest_definitions import ( + assign_child, + create_quest_definition, + edit_quest_definition, +) +from custom_components.nestquest.recurrence import RuleType, ScheduleRule + +NOW = "2026-09-14T12:00:00+00:00" +NEW_YORK = "America/New_York" + + +def _make_hass_mock(): + from unittest.mock import AsyncMock, MagicMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +async def _prepare(path): + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + await apply_migrations(database) + return database + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _freeze_clock( + monkeypatch, instant: datetime.datetime +) -> tuple[datetime.datetime, list]: + """Freeze the integration's ``datetime`` clock to a fixed aware instant. + + Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so + the generation path's "today" reads this instant (localized to whatever + time zone it asks for) while every other module and the test harness keep + the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the + instant normalized to UTC and ``requested`` is the list of ``tzinfo`` + objects the code under test passed to ``now`` — so a test can prove the + generation path actually requested the configured HA time zone rather + than silently reading UTC. + """ + import custom_components.nestquest as nestquest + + frozen = instant.astimezone(datetime.timezone.utc) + requested: list = [] + + class _FrozenDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + requested.append(tz) + if tz is None: + return frozen.replace(tzinfo=None) + return frozen.astimezone(tz) + + fake_module = SimpleNamespace( + datetime=_FrozenDatetime, + timedelta=datetime.timedelta, + date=datetime.date, + ) + monkeypatch.setattr(nestquest, "datetime", fake_module) + return frozen, requested + + +def _assert_requested_ha_timezone(requested, time_zone: str) -> None: + """Assert ``now`` was called with (only) the configured HA time zone. + + This is what turns the DST tests into a real proof of the HA-local date + source: if the generation path regressed to ``now(timezone.utc)``, its + requested tz would be UTC and this fails. + """ + assert requested, "the generation path never called now(tz)" + assert all(tz == ZoneInfo(time_zone) for tz in requested) + + +def _tz_hass(time_zone: str): + """A minimal hass exposing only the ``config.time_zone`` the walk reads.""" + return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone)) + + +async def _seed_daily(database, start_iso: str) -> int: + """One always-present child + a daily definition starting ``start_iso``.""" + child = await ChildrenDao(database).create("Ada", NOW) + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + ) + return child.id + + +def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date: + return instant_utc.astimezone(ZoneInfo(time_zone)).date() + + +def _assert_contiguous_horizon(records, local_today: datetime.date) -> None: + """The horizon is exactly today..today+horizon, no off-by-one, no gap.""" + expected = { + (local_today + datetime.timedelta(days=offset)).isoformat() + for offset in range(DEFAULT_HORIZON_DAYS + 1) + } + assert {r.due_date for r in records} == expected + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + +def test_horizon_uses_newyork_local_date_after_local_midnight( + tmp_path, monkeypatch +) -> None: + """A run just after New York local midnight uses the local date. + + The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so + 04:05 UTC). The horizon must start on 2026-03-10 — the local calendar + date — not any host/UTC-derived value. + """ + instant = datetime.datetime( + 2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK) + ) + local_today = instant.date() + + async def _body(database): + hass = _tz_hass(NEW_YORK) + _frozen, requested = _freeze_clock(monkeypatch, instant) + import custom_components.nestquest as nestquest + + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + _assert_requested_ha_timezone(requested, NEW_YORK) + return len(records) + + async def _main(): + database = await _prepare(tmp_path / "tz-midnight.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1 + + +def test_horizon_uses_local_date_not_utc_when_host_ahead( + tmp_path, monkeypatch +) -> None: + """The walk's "today" is HA-local, never the UTC/system calendar date. + + FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but + New York local is still 2026-01-14 22:00 EST. The horizon must start on + 2026-01-14 (local), proving the UTC date did not win. + """ + instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc) + local_today = _local_date(instant, NEW_YORK) + assert local_today == datetime.date(2026, 1, 14) + assert instant.date() == datetime.date(2026, 1, 15) + + async def _body(database): + hass = _tz_hass(NEW_YORK) + _frozen, requested = _freeze_clock(monkeypatch, instant) + import custom_components.nestquest as nestquest + + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + _assert_requested_ha_timezone(requested, NEW_YORK) + return len(records) + + async def _main(): + database = await _prepare(tmp_path / "tz-host-ahead.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1 + + +def test_horizon_resolves_spring_forward_day_without_off_by_one( + tmp_path, monkeypatch +) -> None: + """Across the 2026-03-08 spring-forward, today and the horizon stay local. + + Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to + 2026-03-08 local — the lost hour must not skip or repeat a calendar day — + and the full horizon remains 15 contiguous days. + """ + zone = ZoneInfo(NEW_YORK) + before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST + after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT + assert before.date() == after.date() == datetime.date(2026, 3, 8) + + async def _body(database): + hass = _tz_hass(NEW_YORK) + import custom_components.nestquest as nestquest + + requested_batches: list[list] = [] + for instant in (before, after): + _frozen, requested = _freeze_clock(monkeypatch, instant) + requested_batches.append(requested) + local_today = instant.date() + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + requested_all = [tz for batch in requested_batches for tz in batch] + _assert_requested_ha_timezone(requested_all, NEW_YORK) + return local_today + + async def _main(): + database = await _prepare(tmp_path / "tz-spring-forward.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == datetime.date(2026, 3, 8) + + +def test_horizon_resolves_fall_back_day_without_off_by_one( + tmp_path, monkeypatch +) -> None: + """Across the 2026-11-01 fall-back, today and the horizon stay local. + + The repeated 01:00-02:00 hour must not resolve to the wrong calendar + day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30 + (``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01 + — and both, along with 03:30 EST after the jump, materialize the same + 15 contiguous-day horizon with no off-by-one. + """ + zone = ZoneInfo(NEW_YORK) + first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT + second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST + after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST + assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1) + assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds + + async def _body(database): + hass = _tz_hass(NEW_YORK) + import custom_components.nestquest as nestquest + + requested_batches: list[list] = [] + for instant in (first, second, after): + _frozen, requested = _freeze_clock(monkeypatch, instant) + requested_batches.append(requested) + local_today = instant.date() + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + requested_all = [tz for batch in requested_batches for tz in batch] + _assert_requested_ha_timezone(requested_all, NEW_YORK) + return local_today + + async def _main(): + database = await _prepare(tmp_path / "tz-fall-back.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == datetime.date(2026, 11, 1) + + +def test_definition_business_regeneration_uses_pinned_today_not_host( + tmp_path, monkeypatch +) -> None: + """``edit_quest_definition``/``assign_child`` anchor regeneration on + the caller-pinned HA-local ``today``, not the host clock. + + The host clock (the ``_today`` fallback) is pinned one day AHEAD of the + HA-local ``today``; the guard would reject the local "today" as past + (and the horizon would skip it) if it consulted the host clock. Both + business operations must regenerate against the pinned date and keep + that first local day, proving the threaded ``today`` wins. + """ + import custom_components.nestquest.dao_instances as dao_instances_module + + pinned = datetime.date(2026, 1, 14) + host_today = datetime.date(2026, 1, 15) + monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today) + + async def _body(database): + children = ChildrenDao(database) + dao = QuestInstancesDao(database) + horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + + a = await children.create("Ada", NOW) + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()), + [a.id], + [("morning", "09:00")], + ) + definition_id = created.definition.id + + # edit anchors on pinned: the horizon starts at the local day the + # host clock calls "past", so its presence proves pinned won. + await edit_quest_definition( + database, + definition_id, + windows=[("morning", "10:15")], + today=pinned, + ) + a_records = await dao.list_by_date_range( + a.id, pinned.isoformat(), horizon_end.isoformat() + ) + assert [r.due_date for r in a_records][0] == pinned.isoformat() + assert {r.due_time for r in a_records} == {"10:15"} + assert len(a_records) == DEFAULT_HORIZON_DAYS + 1 + + # assign anchors on pinned too: the new child's regeneration keeps + # the same pinned-local first day rather than skipping to the host day. + b = await children.create("Bo", NOW) + await assign_child(database, definition_id, b.id, today=pinned) + b_records = await dao.list_by_date_range( + b.id, pinned.isoformat(), horizon_end.isoformat() + ) + assert [r.due_date for r in b_records][0] == pinned.isoformat() + assert len(b_records) == DEFAULT_HORIZON_DAYS + 1 + return definition_id + + async def _main(): + database = await _prepare(tmp_path / "tz-definition-regen.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) is not None -- 2.55.0 From 353d88e3afb48bc5ec494edc07958b4ed3c0d5f1 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 16:12:08 -0400 Subject: [PATCH 17/28] Clamp start to today (no past instances) (Feature 07, task 9) (#83) --- custom_components/nestquest/materialize.py | 15 +++++ tests/test_materialize.py | 64 +++++++++++++++++++--- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 3f22e07..e75c040 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -151,6 +151,14 @@ async def materialize( Assistant's configured time zone MUST pass that same date here so a time zone behind the host around midnight is not rejected as "past". + ``start_date`` is clamped up to the resolved "today" (the same + ``today`` anchor threaded into the no-past guard), so a caller that + passes a ``start_date`` already in the past never yields a + past-dated instance: the walk simply begins on today and skips every + earlier date. A range entirely in the past therefore produces zero + instances rather than an error, and the DAO's no-past guard remains + the authoritative backstop under the lock. + All inputs are read in ONE locked transaction; each tuple's write then goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`, which re-validates the tuple and its current ``due_time`` in the same @@ -184,6 +192,13 @@ async def materialize( start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date() end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date() + # Clamp a past start up to today (the HA-local anchor when the caller + # pinned one, else the host clock) so the walk can never emit a + # past-dated instance; the DAO's no-past guard remains the backstop. + # A range entirely in the past therefore yields zero instances. + today_date = _resolve_today(today) + start = max(start, today_date) + count = 0 cursor = start while cursor <= end: diff --git a/tests/test_materialize.py b/tests/test_materialize.py index c20f226..e4b29d8 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -631,13 +631,14 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None: def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: - """The no-past guard compares against a caller-supplied HA-local today. + """The clamp and no-past guard compare against a caller-supplied HA-local today. A household time zone behind the host clock around midnight resolves a HA-local "today" one calendar day earlier than the host's - ``date.today()``. The host-clock guard would reject that date as past, - so the resolved HA-local date must be threaded through ``materialize`` - and accepted instead of raising. + ``date.today()``. The walk clamps its start to that anchor: without + a pinned HA-local date the effective start is the host's today (one + day after ha_today), so the now-past day is skipped rather than + rejected, and only the host-today-onward days materialize. """ async def _body(database): children = ChildrenDao(database) @@ -653,12 +654,13 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: ["morning"], ) - # Without the pinned HA-local today, the host-clock guard rejects - # the horizon start (ha_today is "yesterday" on the host). - with pytest.raises(ValueError, match="never generated in the past"): - await materialize(database, start_iso, end_iso) + # Without a pinned HA-local today, the walk clamps its start to the + # host's today (one day after ha_today); the past day is dropped and + # the 14 host-today-onward days materialize. + without_pin = await materialize(database, start_iso, end_iso) + assert without_pin == 14 - # With today=ha_today, the same range is accepted and materialized. + # With today=ha_today, the full 15-day horizon materializes. count = await materialize(database, start_iso, end_iso, today=ha_today) assert count == 15 return count @@ -666,6 +668,50 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: _with_db(tmp_path, "materialize-ha-local-today.db")(_body) +def test_materialize_clamps_past_start_to_today(tmp_path) -> None: + """A past start_date is clamped to today: no past-dated instance is created. + + A caller passing a start one month in the past never yields a + past-dated instance: the walk clamps its effective start up to today + and materializes only dates on/after today, leaving the past untouched + rather than raising or corrupting state. + """ + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = (today - datetime.timedelta(days=30)).isoformat() + end_iso = (today + datetime.timedelta(days=14)).isoformat() + + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + ) + + count = await materialize(database, start_iso, end_iso, today=today) + assert count == 15 + + dao = QuestInstancesDao(database) + records = await dao.list_by_date_range(child.id, start_iso, end_iso) + assert len(records) == 15 + assert all(r.due_date >= today.isoformat() for r in records) + + # The past month is empty: no past-dated instance was ever created. + past_records = await dao.list_by_date_range( + child.id, + start_iso, + (today - datetime.timedelta(days=1)).isoformat(), + ) + assert past_records == [] + return count + + _with_db(tmp_path, "materialize-past-start.db")(_body) + + def test_materialize_skips_inactive_definitions(tmp_path) -> None: async def _body(database): from custom_components.nestquest.quest_definitions import ( -- 2.55.0 From c001aca62d3cd2c169db192abab80dd24ef1974c Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 16:38:56 -0400 Subject: [PATCH 18/28] End-to-end 28-day generation (Feature 07, task 10) (#84) --- tests/test_materialize.py | 336 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e4b29d8..390b3ea 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -1113,6 +1113,342 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None: _with_db(tmp_path, "regenerate-child-scoped.db")(_body) +#: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month, +#: so the two-week custody cycle is week-aligned and the yearly rule's +#: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window +#: (pin ``today=anchor`` throughout so the walk never clamps this fixed +#: calendar date up to the host clock). +_E2E_ANCHOR = "2026-06-01" + +#: Hand-checked oracle over 2026-06-01 .. 2026-06-28 (69 entries). Each row +#: is one independent (definition, child, window, due_date, due_time); the +#: definition/child slugs are substituted with runtime ids, but the dates are +#: literal, reviewed by hand — no recurrence/presence math is re-run here. +#: Custody (2-week cycle anchored on the Monday): A present weeks 0 and 2, +#: B present weeks 1 and 3, C present every day. +_E2E_EXPECTED = [ + # daily [A, C] morning 09:00, every day. A present weeks 0, 2. + ("daily", "A", "morning", "2026-06-01", "09:00"), + ("daily", "A", "morning", "2026-06-02", "09:00"), + ("daily", "A", "morning", "2026-06-03", "09:00"), + ("daily", "A", "morning", "2026-06-04", "09:00"), + ("daily", "A", "morning", "2026-06-05", "09:00"), + ("daily", "A", "morning", "2026-06-06", "09:00"), + ("daily", "A", "morning", "2026-06-07", "09:00"), + ("daily", "A", "morning", "2026-06-15", "09:00"), + ("daily", "A", "morning", "2026-06-16", "09:00"), + ("daily", "A", "morning", "2026-06-17", "09:00"), + ("daily", "A", "morning", "2026-06-18", "09:00"), + ("daily", "A", "morning", "2026-06-19", "09:00"), + ("daily", "A", "morning", "2026-06-20", "09:00"), + ("daily", "A", "morning", "2026-06-21", "09:00"), + # daily C is present every day: all 28 days. + ("daily", "C", "morning", "2026-06-01", "09:00"), + ("daily", "C", "morning", "2026-06-02", "09:00"), + ("daily", "C", "morning", "2026-06-03", "09:00"), + ("daily", "C", "morning", "2026-06-04", "09:00"), + ("daily", "C", "morning", "2026-06-05", "09:00"), + ("daily", "C", "morning", "2026-06-06", "09:00"), + ("daily", "C", "morning", "2026-06-07", "09:00"), + ("daily", "C", "morning", "2026-06-08", "09:00"), + ("daily", "C", "morning", "2026-06-09", "09:00"), + ("daily", "C", "morning", "2026-06-10", "09:00"), + ("daily", "C", "morning", "2026-06-11", "09:00"), + ("daily", "C", "morning", "2026-06-12", "09:00"), + ("daily", "C", "morning", "2026-06-13", "09:00"), + ("daily", "C", "morning", "2026-06-14", "09:00"), + ("daily", "C", "morning", "2026-06-15", "09:00"), + ("daily", "C", "morning", "2026-06-16", "09:00"), + ("daily", "C", "morning", "2026-06-17", "09:00"), + ("daily", "C", "morning", "2026-06-18", "09:00"), + ("daily", "C", "morning", "2026-06-19", "09:00"), + ("daily", "C", "morning", "2026-06-20", "09:00"), + ("daily", "C", "morning", "2026-06-21", "09:00"), + ("daily", "C", "morning", "2026-06-22", "09:00"), + ("daily", "C", "morning", "2026-06-23", "09:00"), + ("daily", "C", "morning", "2026-06-24", "09:00"), + ("daily", "C", "morning", "2026-06-25", "09:00"), + ("daily", "C", "morning", "2026-06-26", "09:00"), + ("daily", "C", "morning", "2026-06-27", "09:00"), + ("daily", "C", "morning", "2026-06-28", "09:00"), + # weekly [B, C] Mondays, morning 07:00 + evening 19:00. + # B present weeks 1, 3 -> Mondays 06-08 and 06-22. + ("weekly", "B", "morning", "2026-06-08", "07:00"), + ("weekly", "B", "evening", "2026-06-08", "19:00"), + ("weekly", "B", "morning", "2026-06-22", "07:00"), + ("weekly", "B", "evening", "2026-06-22", "19:00"), + # C present every Monday: 06-01, 06-08, 06-15, 06-22. + ("weekly", "C", "morning", "2026-06-01", "07:00"), + ("weekly", "C", "evening", "2026-06-01", "19:00"), + ("weekly", "C", "morning", "2026-06-08", "07:00"), + ("weekly", "C", "evening", "2026-06-08", "19:00"), + ("weekly", "C", "morning", "2026-06-15", "07:00"), + ("weekly", "C", "evening", "2026-06-15", "19:00"), + ("weekly", "C", "morning", "2026-06-22", "07:00"), + ("weekly", "C", "evening", "2026-06-22", "19:00"), + # month_day [C] the 15th, morning 08:00. 2026-06-15 is the 15th. + ("month_day", "C", "morning", "2026-06-15", "08:00"), + # month_wd [C] 2nd Wednesday, morning 07:30. June 2026's 2nd + # Wednesday is 06-10 (the 1st is 06-03). + ("month_wd", "C", "morning", "2026-06-10", "07:30"), + # yearly [C] 1st of June, afternoon 12:00. Fires on 06-01. + ("yearly", "C", "afternoon", "2026-06-01", "12:00"), + # custom [A, C] Saturdays + Sundays, evening 20:00. + # A present weeks 0, 2 -> 06-06, 06-07, 06-20, 06-21. + ("custom", "A", "evening", "2026-06-06", "20:00"), + ("custom", "A", "evening", "2026-06-07", "20:00"), + ("custom", "A", "evening", "2026-06-20", "20:00"), + ("custom", "A", "evening", "2026-06-21", "20:00"), + # C present every weekend day. + ("custom", "C", "evening", "2026-06-06", "20:00"), + ("custom", "C", "evening", "2026-06-07", "20:00"), + ("custom", "C", "evening", "2026-06-13", "20:00"), + ("custom", "C", "evening", "2026-06-14", "20:00"), + ("custom", "C", "evening", "2026-06-20", "20:00"), + ("custom", "C", "evening", "2026-06-21", "20:00"), + ("custom", "C", "evening", "2026-06-27", "20:00"), + ("custom", "C", "evening", "2026-06-28", "20:00"), +] + +# The tuples that must disappear when Cleo (C) is overridden to ABSENT for +# 2026-06-04 .. 2026-06-06 (a mid-window stretch): her two daily chores and +# the Saturday-evening custom chore on 06-06. +_E2E_REMOVED = [ + ("daily", "C", "morning", "2026-06-04", "09:00"), + ("daily", "C", "morning", "2026-06-05", "09:00"), + ("daily", "C", "morning", "2026-06-06", "09:00"), + ("custom", "C", "evening", "2026-06-06", "20:00"), +] + + +def _resolve_e2e(raw, def_ids, child_ids) -> set: + """Substitute the oracle's definition/child slugs with runtime ids.""" + return { + (def_ids[slug], child_ids[child], window, due_date, due_time) + for slug, child, window, due_date, due_time in raw + } + + +async def _collect_keyed(database, child_ids, start, end) -> dict: + """Return {(definition_id, child_id, window, due_date): (id, due_time)}. + + The stable parts of an instance — its widened key, its physical id and + its due_time snapshot — with the refreshable ``generated_at`` stamp + deliberately excluded. Comparing two of these captures the real + re-run contract: no rows added/removed, ids stable (upsert refreshes + in place, never delete + reinsert), and due_time untouched. + """ + dao = QuestInstancesDao(database) + keyed = {} + for child_id in child_ids: + for record in await dao.list_by_date_range(child_id, start, end): + key = ( + record.definition_id, + record.child_id, + record.window, + record.due_date, + ) + keyed[key] = (record.id, record.due_time) + return keyed + + +async def _collect_records(database, child_ids, start, end) -> set: + """Return the full 7-field instance records over the children/range. + + Includes the ``generated_at`` stamp, so this is only used for children + that should NEVER be written (Ada/Bo during Cleo's override): their + records are truly byte-for-byte untouched, unlike the idempotency + re-run where the stamp legitimately refreshes. + """ + dao = QuestInstancesDao(database) + records = set() + for child_id in child_ids: + for record in await dao.list_by_date_range(child_id, start, end): + records.add( + ( + record.id, + record.definition_id, + record.child_id, + record.window, + record.due_date, + record.due_time, + record.generated_at, + ) + ) + return records + + +def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None: + import custom_components.nestquest.materialize as materialize_module + + # Give each materialize run a DISTINCT batch stamp: production upserts + # rewrite generated_at on every re-run, so a shared pinned stamp would + # merely hide that rewrite. The idempotency assertion below therefore + # ignores generated_at and checks the stable columns only. + stamps = {"n": 0} + + def _distinct_stamp() -> str: + stamps["n"] += 1 + return f"2026-06-01T00:00:{stamps['n']:02d}+00:00" + + monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp) + + async def _body(database): + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + from custom_components.nestquest.presence import PresenceSchedule + + children = ChildrenDao(database) + schedules = PresenceSchedulesDao(database) + overrides = PresenceOverridesDao(database) + + a = await children.create("Ada", NOW) # present weeks 0, 2 + b = await children.create("Bo", NOW) # present weeks 1, 3 + c = await children.create("Cleo", NOW) # no schedule: always present + + anchor = datetime.date.fromisoformat(_E2E_ANCHOR) + start_iso = anchor.isoformat() + end_iso = (anchor + datetime.timedelta(days=27)).isoformat() + + # Two-week custody with OPPOSITE weeks, encoded through the + # PresenceSchedule model on the fixed Monday anchor. + all_week = frozenset(range(7)) + a_schedule = PresenceSchedule(a.id, 2, anchor, {0: all_week, 1: frozenset()}) + b_schedule = PresenceSchedule(b.id, 2, anchor, {0: frozenset(), 1: all_week}) + await schedules.upsert_by_child(a.id, 2, start_iso, a_schedule.encode()) + await schedules.upsert_by_child(b.id, 2, start_iso, b_schedule.encode()) + + def_ids = {} + def_ids["daily"] = (await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [a.id, c.id], + [("morning", "09:00")], + )).definition.id + def_ids["weekly"] = (await create_quest_definition( + database, + "Weekly Monday chore", + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0}, + start_date=start_iso, + ), + [b.id, c.id], + [("morning", "07:00"), ("evening", "19:00")], + )).definition.id + def_ids["month_day"] = (await create_quest_definition( + database, + "Monthly 15th chore", + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, + day_of_month=15, + start_date=start_iso, + ), + [c.id], + [("morning", "08:00")], + )).definition.id + def_ids["month_wd"] = (await create_quest_definition( + database, + "Monthly 2nd-Wednesday chore", + ScheduleRule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=2, + nth_weekday_weekday=2, + start_date=start_iso, + ), + [c.id], + [("morning", "07:30")], + )).definition.id + def_ids["yearly"] = (await create_quest_definition( + database, + "Yearly first-of-June chore", + ScheduleRule( + rule_type=RuleType.YEARLY, + month=anchor.month, + day_of_month=1, + start_date=start_iso, + ), + [c.id], + [("afternoon", "12:00")], + )).definition.id + def_ids["custom"] = (await create_quest_definition( + database, + "Weekend chore", + ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, + weekday_set={5, 6}, + start_date=start_iso, + ), + [a.id, c.id], + [("evening", "20:00")], + )).definition.id + + child_ids = {"A": a.id, "B": b.id, "C": c.id} + expected = _resolve_e2e(_E2E_EXPECTED, def_ids, child_ids) + removed = _resolve_e2e(_E2E_REMOVED, def_ids, child_ids) + all_children = (a.id, b.id, c.id) + + # First run: every (definition, child, window, date) tuple, exactly. + first = await materialize(database, start_iso, end_iso, today=anchor) + assert first == len(expected) + assert await _collect_instances( + database, all_children, start_iso, end_iso + ) == expected + + # Re-run contract: the upsert refreshes each existing row IN PLACE, + # so the widened key set, the physical ids and each due_time are all + # stable (only generated_at may refresh to the new run's stamp). + first_keyed = await _collect_keyed( + database, all_children, start_iso, end_iso + ) + second = await materialize(database, start_iso, end_iso, today=anchor) + assert second == len(expected) + assert await _collect_keyed( + database, all_children, start_iso, end_iso + ) == first_keyed + + # Capture Ada and Bo's complete records BEFORE the override, so we + # can prove they are untouched afterward. + a_before = await _collect_records(database, (a.id,), start_iso, end_iso) + b_before = await _collect_records(database, (b.id,), start_iso, end_iso) + + # Cleo is ABSENT 2026-06-04 .. 2026-06-06 (mid-window). + await overrides.create( + c.id, "2026-06-04", "2026-06-06", False, note="away" + ) + + # regenerate_for_child deletes + rebuilds CLEO ONLY (Ada and Bo are + # never touched). It rebuilds a 14-day rolling horizon though, so + # the child-scoped materialize below restores the remainder of the + # 28-day window without ever writing another child. + await regenerate_for_child(database, c.id, today=anchor) + await materialize( + database, start_iso, end_iso, child_ids=[c.id], today=anchor + ) + + # Ada and Bo are byte-for-byte untouched: id, snapshot columns and + # generated_at alike. + assert await _collect_records( + database, (a.id,), start_iso, end_iso + ) == a_before + assert await _collect_records( + database, (b.id,), start_iso, end_iso + ) == b_before + + # Exactly Cleo's override-covered tuples vanish; every other tuple + # survives. + remaining = await _collect_instances( + database, all_children, start_iso, end_iso + ) + assert remaining == expected - removed + assert remaining.isdisjoint(removed) + return first + + _with_db(tmp_path, "materialize-e2e.db")(_body) + + def test_regenerate_for_child_rejects_non_int_child_id(tmp_path) -> None: async def _body(database): for bad in (True, False, 1.0, "1"): -- 2.55.0 From 3add2d2f484fda5707edfc964c639df50b90a886 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 17:07:10 -0400 Subject: [PATCH 19/28] Thread configured horizon_days (Feature 07 integration fix) (#86) --- custom_components/nestquest/__init__.py | 68 +++++--- custom_components/nestquest/materialize.py | 50 +++++- .../nestquest/quest_definitions.py | 32 +++- tests/test_lifecycle.py | 125 ++++++++++++++ tests/test_materialize.py | 153 ++++++++++++++++++ tests/test_quest_definitions.py | 69 ++++++++ 6 files changed, 465 insertions(+), 32 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 6615679..8cc9b79 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers.event import async_track_time_change from .const import ( CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DOMAIN, @@ -273,10 +274,28 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]: return int(hour), int(minute) +def _configured_horizon_days(options: dict[str, Any]) -> int: + """Return the configured generation horizon, validated, else the default. + + The options flow already validates ``horizon_days`` as an integer >= 1, + but a stored ``entry.options`` dict could still carry a partial or + hand-edited value. Re-checking here — and falling back to + :data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value + — keeps a malformed option from shrinking or exploding the materialized + horizon. + """ + value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + return DEFAULT_HORIZON_DAYS + return value + + async def _run_horizon_materialization( - hass: HomeAssistant, database: NestQuestDatabase + hass: HomeAssistant, + database: NestQuestDatabase, + horizon_days: int = DEFAULT_HORIZON_DAYS, ) -> None: - """Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. + """Materialize the rolling horizon ``[today, today + horizon_days]``. "today" is computed in HA local time (``hass.config.time_zone``) rather than the system clock, so the horizon tracks the household's own day. @@ -285,13 +304,15 @@ async def _run_horizon_materialization( This is the ONE generation path: the startup backfill, the daily rollover listener and the ``regenerate`` service all funnel through it, so there is - no duplicated materialization logic. + no duplicated materialization logic. ``horizon_days`` sizes the window + and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the + entry's validated option value through. """ time_zone = ZoneInfo(hass.config.time_zone) today = datetime.datetime.now(time_zone).date() start_date = today.isoformat() end_date = ( - today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + today + datetime.timedelta(days=horizon_days) ).isoformat() await _materialize_run(database, start_date, end_date, today=today) @@ -304,31 +325,34 @@ def _register_day_rollover_listener( """Register the daily materialization listener and return its remover. Fires once per day at the configured local ``day_rollover_time`` and - materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` - through the shared :func:`_run_horizon_materialization` path. + materializes the rolling horizon ``[today, today + horizon_days]`` + through the shared :func:`_run_horizon_materialization` path, using the + entry's configured (validated) ``horizon_days``. """ hour, minute = _day_rollover_hour_minute(options) + horizon_days = _configured_horizon_days(options) async def _run_materialization(_now: datetime.datetime) -> None: - await _run_horizon_materialization(hass, database) + await _run_horizon_materialization(hass, database, horizon_days) return async_track_time_change( hass, _run_materialization, hour=hour, minute=minute ) -def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None: - """Return a connected database from the active runtime data, or None. +def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: + """Return a live runtime record with a connected database, or None. The domain-global services (e.g. ``regenerate``) have no database of - their own; they resolve whichever config entry's database is currently - live at call time, so none of them can hold a stale handle to a closed - connection after another entry unloads. + their own; they resolve whichever config entry's runtime data is + currently live at call time, so none of them can hold a stale handle + to a closed connection after another entry unloads. The record also + carries that entry's options (e.g. the configured horizon). """ for runtime_data in hass.data.get(DOMAIN, {}).values(): database = getattr(runtime_data, "database", None) if database is not None and database.connected: - return database + return runtime_data return None @@ -349,14 +373,17 @@ def _register_regenerate_service(hass: HomeAssistant) -> None: return async def _regenerate(_call: Any) -> None: - database = _find_live_database(hass) - if database is None: + runtime_data = _find_live_runtime_data(hass) + if runtime_data is None: _LOGGER.warning( "NestQuest regenerate requested but no config entry has a " "live database; ignoring" ) return - await _run_horizon_materialization(hass, database) + horizon_days = _configured_horizon_days(runtime_data.options) + await _run_horizon_materialization( + hass, runtime_data.database, horizon_days + ) hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) @@ -456,9 +483,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Backfill any days the daily listener missed while the integration # was off (or freshly installed): run the one idempotent walk over - # [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs - # through the SAME path as the daily listener and regenerate service. - await _run_horizon_materialization(hass, database) + # [today, today + horizon_days] once at setup, using the entry's + # configured horizon. This runs through the SAME path as the daily + # listener and regenerate service. + await _run_horizon_materialization( + hass, database, _configured_horizon_days(dict(entry.options)) + ) _register_regenerate_service(hass) except BaseException: # A failure anywhere after the database is open must not leak the diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index e75c040..c32d3d9 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -44,7 +44,8 @@ definition edits / reassignment (Features 06/07); :func:`regenerate_for_child` covers a single child's presence change (Feature 09). Both delete the affected open instances at or after today — never a completed instance, never the past — and re-run the walk over the rolling horizon -``[today, today + DEFAULT_HORIZON_DAYS]``. +``[today, today + horizon_days]`` (``horizon_days`` defaults to +``DEFAULT_HORIZON_DAYS``). HOOK (Feature 09 presence services): the presence business layer that lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY @@ -83,6 +84,31 @@ def _now_stamp() -> str: ) +def _resolve_horizon_days(horizon_days: int | None) -> int: + """Validate a regeneration horizon and return the resolved day count. + + ``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool + (SQLite would happily bind ``True``/``False`` onto an int), a non-int, + or a value below 1 raises ValueError naming the field — the validation + runs BEFORE any future-instance delete so a malformed horizon can never + delete rows it then fails to rebuild. A negative horizon would + otherwise delete first and only then be rejected by + :func:`materialize`'s inverted-range check; ``0``/``False`` would + silently shrink the window to a single day. + """ + if horizon_days is None: + return DEFAULT_HORIZON_DAYS + if ( + isinstance(horizon_days, bool) + or not isinstance(horizon_days, int) + or horizon_days < 1 + ): + raise ValueError( + f"horizon_days must be a positive integer, got {horizon_days!r}" + ) + return horizon_days + + def _decode_rule(snapshot) -> ScheduleRule: """Decode a snapshot's rule record back into a ScheduleRule. @@ -232,6 +258,7 @@ async def regenerate_for_definition( definition_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> int: """Regenerate a definition's future instances after a config change. @@ -241,7 +268,7 @@ async def regenerate_for_definition( completed instance, never the past) via :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`, then the materialization walk re-runs over the rolling horizon - ``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule, + ``[today, today + horizon_days]`` so the (changed) rule, assignee set and windows re-materialize against today's state. "today" is computed the same way the walk computes it — @@ -251,14 +278,18 @@ async def regenerate_for_definition( range. ``today`` optionally pins a caller-resolved HA-local date (threaded into both the delete cutoff and the re-materialization) so a household time zone behind the host around midnight stays - consistent. Returns the number of instances the re-materialization + consistent. ``horizon_days`` sizes the re-materialization window; + when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`. + A bool, non-int, or sub-1 value raises ValueError BEFORE any delete. + Returns the number of instances the re-materialization upserted across the whole snapshot (idempotent — other active definitions' tuples refresh in place, never duplicate). """ + horizon = _resolve_horizon_days(horizon_days) today_date = _resolve_today(today) start_date = today_date.isoformat() end_date = ( - today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + today_date + datetime.timedelta(days=horizon) ).isoformat() instances = QuestInstancesDao(database) @@ -286,6 +317,7 @@ async def regenerate_for_child( child_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> int: """Regenerate a child's future instances after a presence change. @@ -299,7 +331,7 @@ async def regenerate_for_child( (never a completed instance, never the past) via :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`, then the materialization walk re-runs over the rolling horizon - ``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only, + ``[today, today + horizon_days]`` scoped to that child only, so the child's now-absent/present dates re-materialize against today's presence WITHOUT touching any other child's rows. @@ -315,13 +347,17 @@ async def regenerate_for_child( execution-day anchor even when the call is queued across midnight. ``today`` optionally pins a caller-resolved HA-local date, threaded into the delete cutoff and the re-materialization for the same - host-vs-HA time-zone consistency. Returns the number of instances + host-vs-HA time-zone consistency. ``horizon_days`` sizes the + re-materialization window; when omitted it falls back to + :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value + raises ValueError BEFORE any delete. Returns the number of instances the re-materialization upserted for the child across the whole snapshot. """ if isinstance(child_id, bool) or not isinstance(child_id, int): raise ValueError(f"child_id must be an integer, got {child_id!r}") + horizon = _resolve_horizon_days(horizon_days) today_date = _resolve_today(today) instances = QuestInstancesDao(database) @@ -335,7 +371,7 @@ async def regenerate_for_child( child_id, today_date.isoformat(), today=today ) start = datetime.date.fromisoformat(effective_cutoff) - end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + end_date = (start + datetime.timedelta(days=horizon)).isoformat() return await materialize( database, start.isoformat(), end_date, child_ids=[child_id], today=today ) diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index 86ada89..d301aa4 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -272,6 +272,7 @@ async def edit_quest_definition( rule: ScheduleRule | object = _UNSET, windows: list[str | tuple[str, str | None]] | object = _UNSET, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> CreatedQuestDefinition: """Edit a quest definition's metadata, rule and windows atomically. @@ -299,7 +300,10 @@ async def edit_quest_definition( into :func:`~.materialize.regenerate_for_definition`); it is a plain ``datetime.date`` with no HA import here — the Feature 09 service supplies the hass-derived value. When omitted, regeneration falls - back to the host clock, same as before. + back to the host clock, same as before. ``horizon_days`` sizes the + regeneration's re-materialization window the same way (threaded into + :func:`~.materialize.regenerate_for_definition`); it defaults to + :data:`~.const.DEFAULT_HORIZON_DAYS` when omitted. """ _validate_definition_id(definition_id) @@ -350,7 +354,9 @@ async def edit_quest_definition( schedule_rule_storage_from_record(snapshot.rule) ) - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return CreatedQuestDefinition( definition=snapshot.definition, @@ -366,6 +372,7 @@ async def assign_child( child_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> list[ChildRecord]: """Assign ``child_id`` to the definition (idempotent). @@ -382,6 +389,8 @@ async def assign_child( ``today`` optionally pins the caller-resolved HA-local date threaded into the assignment's regeneration (see :func:`edit_quest_definition`). + ``horizon_days`` optionally sizes the regeneration's re-materialization + window the same way. """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -397,7 +406,9 @@ async def assign_child( if message.startswith("quest definition "): raise ValueError(f"definition_id: {message}") from error raise - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return assignees @@ -407,6 +418,7 @@ async def unassign_child( child_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> bool: """Unassign ``child_id`` from the definition; True when removed. @@ -419,6 +431,8 @@ async def unassign_child( ``today`` optionally pins the caller-resolved HA-local date threaded into the unassignment's regeneration (see :func:`edit_quest_definition`). + ``horizon_days`` optionally sizes the regeneration's re-materialization + window the same way. """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -429,7 +443,9 @@ async def unassign_child( f"definition_id: quest definition {definition_id} does not exist" ) removed = await dao.remove_assignee(definition_id, child_id) - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return removed @@ -439,6 +455,7 @@ async def set_quest_definition_active( is_active: bool, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> CreatedQuestDefinition: """Deactivate or reactivate a definition; returns the updated view. @@ -464,7 +481,8 @@ async def set_quest_definition_active( ``today`` optionally pins the caller-resolved HA-local date threaded into the activation change's regeneration (see - :func:`edit_quest_definition`). + :func:`edit_quest_definition`). ``horizon_days`` optionally sizes the + regeneration's re-materialization window the same way. """ _validate_definition_id(definition_id) if not isinstance(is_active, bool): @@ -488,7 +506,9 @@ async def set_quest_definition_active( rule = schedule_rule_from_storage( schedule_rule_storage_from_record(rule_record) ) - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return CreatedQuestDefinition( definition=updated, rule=rule, diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 68be6ce..b81f3f9 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -10,6 +10,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, SERVICE_REGENERATE, DOMAIN as DOMAIN_CONST, @@ -351,6 +352,39 @@ async def test_day_rollover_listener_materializes_ha_local_horizon( ) +async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None: + """The daily run materializes only the configured horizon window.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + + # A definition created AFTER setup has no instances yet (create does not + # materialize); firing the daily listener must generate only the horizon. + child_id = await _seed_daily_child_and_definition( + database, _local_today(hass) + ) + await hass.time_change.fire() + + today = _local_today(hass) + end = (today + datetime.timedelta(days=5)).isoformat() + records = await QuestInstancesDao(database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == 6 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await QuestInstancesDao(database).list_by_date_range( + child_id, beyond, beyond_end + ) == [] + + def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None: """The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg). @@ -447,6 +481,63 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None: assert len(records) == DEFAULT_HORIZON_DAYS + 1 +async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None: + """Setup backfills only the configured (non-default) horizon window.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + from custom_components.nestquest.store import async_get_db_path + + db_path = await async_get_db_path(hass) + pre = NestQuestDatabase(hass) + await pre.open(db_path) + await apply_migrations(pre) + child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) + await pre.close() + + entry = _wire( + make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + + today = _local_today(hass) + end = (today + datetime.timedelta(days=5)).isoformat() + records = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child_id, today.isoformat(), end) + assert len(records) == 6 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child_id, beyond, beyond_end) == [] + + +def test_configured_horizon_days_validates_and_falls_back() -> None: + """The configured horizon is validated; a malformed value uses the default.""" + import custom_components.nestquest as nestquest + + assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5 + assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS + assert ( + nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True}) + == DEFAULT_HORIZON_DAYS + ) + assert ( + nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"}) + == DEFAULT_HORIZON_DAYS + ) + assert ( + nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0}) + == DEFAULT_HORIZON_DAYS + ) + + async def test_regenerate_service_registered_and_materializes( hass, make_entry ) -> None: @@ -474,6 +565,40 @@ async def test_regenerate_service_registered_and_materializes( assert len(records) == DEFAULT_HORIZON_DAYS + 1 +async def test_regenerate_service_uses_configured_horizon_days( + hass, make_entry +) -> None: + """``nestquest.regenerate`` regenerates only the configured horizon.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire( + make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + + database = entry.runtime_data.database + child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) + + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + + today = _local_today(hass) + end = (today + datetime.timedelta(days=5)).isoformat() + records = await QuestInstancesDao(database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == 6 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await QuestInstancesDao(database).list_by_date_range( + child_id, beyond, beyond_end + ) == [] + + async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None: """Unload removes the ``regenerate`` service so no stale entry leaks.""" entry = _wire(make_entry(), hass.registry) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 390b3ea..2135559 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -7,6 +7,7 @@ import datetime import pytest from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import ( CompletionEventsDao, QuestInstancesDao, @@ -1113,6 +1114,158 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None: _with_db(tmp_path, "regenerate-child-scoped.db")(_body) +def test_regenerate_for_definition_respects_horizon_days(tmp_path) -> None: + """A non-default horizon_days sizes the definition's rebuild window.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + + # ``create`` does not materialize; regeneration over a 5-day horizon + # must build exactly today..today+5 (six daily rows). + await regenerate_for_definition( + database, created.definition.id, horizon_days=5 + ) + + dao = QuestInstancesDao(database) + horizon_end = today + datetime.timedelta(days=5) + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == 6 + + # The window is BOUNDED: nothing must exist at day 6 or later (a + # hard-coded 14-day default would still emit rows there). + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await dao.list_by_date_range(child.id, beyond, beyond_end) == [] + return None + + _with_db(tmp_path, "regenerate-def-horizon.db")(_body) + + +def test_regenerate_for_child_respects_horizon_days(tmp_path) -> None: + """A non-default horizon_days sizes the child's rebuild window.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + + await regenerate_for_child(database, child.id, horizon_days=5) + + dao = QuestInstancesDao(database) + horizon_end = today + datetime.timedelta(days=5) + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == 6 + + # Bounded window: nothing must exist at day 6 or later. + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await dao.list_by_date_range(child.id, beyond, beyond_end) == [] + return None + + _with_db(tmp_path, "regenerate-child-horizon.db")(_body) + + +@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5]) +def test_regenerate_for_definition_rejects_invalid_horizon_before_delete( + tmp_path, bad +) -> None: + """A malformed horizon fails BEFORE any future-instance delete.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + dao = QuestInstancesDao(database) + await materialize(database, start_iso, horizon_end.isoformat()) + + with pytest.raises(ValueError, match="horizon_days"): + await regenerate_for_definition( + database, created.definition.id, horizon_days=bad + ) + + # The refused call deleted nothing: the seeded horizon survives. + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + return None + + _with_db(tmp_path, "regenerate-def-horizon-invalid.db")(_body) + + +@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5]) +def test_regenerate_for_child_rejects_invalid_horizon_before_delete( + tmp_path, bad +) -> None: + """A malformed child horizon fails BEFORE any future-instance delete.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + dao = QuestInstancesDao(database) + await materialize(database, start_iso, horizon_end.isoformat()) + + with pytest.raises(ValueError, match="horizon_days"): + await regenerate_for_child(database, child.id, horizon_days=bad) + + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + return None + + _with_db(tmp_path, "regenerate-child-horizon-invalid.db")(_body) + + #: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month, #: so the two-week custody cycle is week-aligned and the yearly rule's #: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 2770113..a755670 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -8,6 +8,7 @@ import re import pytest from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import ( CompletionEventsDao, QuestInstancesDao, @@ -1938,3 +1939,71 @@ def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None: return reactivated _with_db(tmp_path, "full-lifecycle.db")(_body) + + +@pytest.mark.parametrize( + "mode", + ["edit", "assign", "unassign", "set_active"], +) +def test_wrappers_forward_horizon_days(tmp_path, mode) -> None: + """The four change wrappers forward a non-default horizon_days. + + Each wrapper threads ``horizon_days`` into the regeneration it triggers; + passing 5 here must bound the regenerated window to today..today+5 — + proving the forwarding argument is actually honored, not dropped. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=_today_iso()), + [child.id], + ["morning"], + ) + definition_id = created.definition.id + instances = QuestInstancesDao(database) + + if mode == "edit": + await edit_quest_definition( + database, + definition_id, + windows=[("morning", "10:30")], + horizon_days=5, + ) + tracked = child + elif mode == "assign": + bo = await children.create("Bo", NOW) + await assign_child(database, definition_id, bo.id, horizon_days=5) + tracked = bo + elif mode == "unassign": + # The remaining assignee (``child``) must be regenerated over + # the 5-day window after ``bo`` is removed. + bo = await children.create("Bo", NOW) + await assign_child(database, definition_id, bo.id) + await unassign_child( + database, definition_id, bo.id, horizon_days=5 + ) + tracked = child + else: # set_active + await set_quest_definition_active( + database, definition_id, True, horizon_days=5 + ) + tracked = child + + today = datetime.date.today() + end = (today + datetime.timedelta(days=5)).isoformat() + records = await instances.list_by_date_range( + tracked.id, today.isoformat(), end + ) + assert len(records) == 6 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await instances.list_by_date_range( + tracked.id, beyond, beyond_end + ) == [] + return None + + _with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body) -- 2.55.0 From 6e32761b0c6504e0dd2fc4dadefb4d1ae1a852fd Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 17:17:57 -0400 Subject: [PATCH 20/28] Guard against stale due_time overwrite (Feature 07 fix) (#87) --- custom_components/nestquest/dao_instances.py | 25 +++++---- tests/test_materialize.py | 57 +++++++++++++++++--- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 94f05b9..e483d6c 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -344,14 +344,20 @@ class QuestInstancesDao: - the (definition, child) assignment link still exists (a removed assignment is skipped); - the window is still declared on the definition (a removed window - is skipped). + is skipped); + - the window's live ``due_time`` still EQUALS the caller-supplied + ``due_time`` (NULL-safe: NULL == NULL counts as equal; a window + with no due time matches a snapshot with no due time). A + window-time edit landed after the input snapshot makes the + caller's ``due_time`` stale, and writing it would overwrite the + newer due_time a concurrent regeneration already upserted — so + the tuple is skipped instead of written. - The live checks above ONLY decide whether the tuple is still - valid; they never read the window's ``due_time``. The instance is - written with the caller-supplied ``due_time`` — the value snapshotted - from the coherent input read — so a window-time edit landing - mid-walk cannot leak a newer time into a batch that was already - snapshotted against older rule/presence/assignment decisions. + The due_time check closes the one check-then-insert gap this + method previously left open: it re-validated every OTHER + precondition but then wrote the snapshot's ``due_time`` + unconditionally, so a stale materialization could clobber a + newer, concurrently-regenerated due_time. Returns the stored instance, or ``None`` when a precondition failed — the tuple no longer materializable, which the walk SKIPS @@ -384,8 +390,9 @@ class QuestInstancesDao: "JOIN quest_definition_windows w " "ON w.definition_id = d.id " "WHERE d.id = ? AND a.child_id = ? AND w.window = ? " - "AND d.is_active = 1 AND c.is_active = 1 LIMIT 1", - (definition_id, child_id, window), + "AND d.is_active = 1 AND c.is_active = 1 " + "AND w.due_time IS ? LIMIT 1", + (definition_id, child_id, window, due_time), ) if still_valid is None: # A config change invalidated the tuple since the diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 2135559..3b1ea2f 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -545,7 +545,7 @@ def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None: _with_db(tmp_path, "materialize-presence-snapshot.db")(_body) -def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None: +def test_materialize_skips_stale_due_time_mid_walk(tmp_path) -> None: async def _body(database): children = ChildrenDao(database) child = await children.create("Ada", NOW) @@ -566,21 +566,64 @@ def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None: ) # The window's due_time is edited to 10:15 between the input - # snapshot and the insert. The walk must store the SNAPSHOT's - # 09:00, not the live 10:15 — the batch is governed entirely by - # the coherent snapshot it read. + # snapshot and the insert. The walk's snapshotted 09:00 no + # longer matches the live 10:15, so the tuple is SKIPPED rather + # than writing a due_time that has already been superseded. count = await _materialize_with_change( database, start_iso, end_iso, _edit_due_time ) - assert count == 3 + assert count == 0 + assert await QuestInstancesDao(database).list_by_date_range( + child.id, start_iso, end_iso + ) == [] + return count + + _with_db(tmp_path, "materialize-stale-due-time.db")(_body) + + +def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None: + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + end_iso = (today + datetime.timedelta(days=2)).isoformat() + + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + + async def _edit_and_regenerate(): + # A window-time edit + regeneration land after the stale walk + # read its input snapshot: the window moves to 10:15 and the + # regeneration upserts the newer due_time across the horizon. + await QuestDefinitionsDao(database).upsert_window( + created.definition.id, "morning", due_time="10:15" + ) + await regenerate_for_definition(database, created.definition.id) + + # The stale walk (snapshot due_time 09:00) then resumes. It must + # SKIP these tuples — its snapshotted 09:00 no longer matches the + # live 10:15 — so the regeneration's newer due_time survives and + # is NOT overwritten by the stale value. + count = await _materialize_with_change( + database, start_iso, end_iso, _edit_and_regenerate + ) + assert count == 0 + records = await QuestInstancesDao(database).list_by_date_range( child.id, start_iso, end_iso ) assert len(records) == 3 - assert {r.due_time for r in records} == {"09:00"} + assert {r.due_time for r in records} == {"10:15"} return records - _with_db(tmp_path, "materialize-snapshot-due-time.db")(_body) + _with_db(tmp_path, "materialize-regenerated-due-time.db")(_body) def test_materialize_honours_overrides(tmp_path) -> None: -- 2.55.0 From 22797cbc7eea5238a2ed58a10c3504b7875a89f2 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 06:31:01 -0400 Subject: [PATCH 21/28] Completion business layer wrapping append-only DAO (Feature 08, task 1) (#88) --- custom_components/nestquest/completion.py | 165 +++++++++++ tests/test_completion.py | 339 ++++++++++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 custom_components/nestquest/completion.py create mode 100644 tests/test_completion.py diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py new file mode 100644 index 0000000..4b512b4 --- /dev/null +++ b/custom_components/nestquest/completion.py @@ -0,0 +1,165 @@ +"""Business layer for the completion event log (Feature 08). + +Sits between the Feature 09 service gate and the typed +:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape +validation and the UTC occurred-at stamp here, and never talk to the +DAO or SQL directly. This module is the only Feature-08 caller of +:meth:`~.dao_instances.CompletionEventsDao.append`. Completing and +un-completing an instance (no-op-if-already-done, derived state) arrive +in later tasks; this module is the append wrapper only. + +Actor policy matches the schema CHECKs (D-008): ``actor_source`` is +``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` +requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` +requires ``actor_child_id`` (the tapped profile) and forbids +``actor_user_id``. Inverted shapes raise a field-naming ValueError +before any write. + +``occurred_at`` is stamped UTC ISO-8601 at second precision with an +explicit ``+00:00`` offset, matching the DAO's one accepted shape so +lexicographic range queries stay exact. ``was_on_time`` is required +(True/False) for completed events; un-completions may pass None. There +is no permission check here (that is Feature 09). +""" +from __future__ import annotations + +import datetime + +from .dao_instances import ( + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + CompletionEventRecord, + CompletionEventsDao, +) +from .db import NestQuestDatabase + +#: Timestamp policy for completion_events.occurred_at: strict UTC +#: ISO-8601 with explicit '+00:00' offset (module timestamp policy). +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + """Return the module's strict UTC second-precision stamp.""" + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +def _stamp_occurred_at(now: datetime.datetime | None) -> str: + """Return a DAO-accepted UTC stamp for ``now``, or the current time.""" + if now is None: + return _now_stamp() + if not isinstance(now, datetime.datetime): + raise ValueError(f"now must be a datetime, got {now!r}") + if now.tzinfo is None: + raise ValueError("now must be timezone-aware") + return now.astimezone(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +def _validate_int_id(value: object, field: str) -> int: + """Reject non-int ids (bools included) before any lookup.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{field} must be an integer, got {value!r}") + return value + + +def _validate_event_type(value: object) -> str: + """Return a schema-legal event type, or raise naming the field.""" + if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED): + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {value!r}" + ) + return value + + +def _validate_actor( + actor_source: object, + actor_user_id: object, + actor_child_id: object, +) -> tuple[str, str | None, int | None]: + """Return a schema-legal actor triple, or raise naming the field. + + ``'user'`` requires a non-empty ``actor_user_id`` string and forbids + ``actor_child_id``. ``'panel'`` requires an integer + ``actor_child_id`` and forbids ``actor_user_id``. Any other + ``actor_source`` (including ``'service'``) is rejected. + """ + if actor_source not in ("user", "panel"): + raise ValueError( + f"actor_source must be 'user' or 'panel', got {actor_source!r}" + ) + if actor_source == "user": + if not isinstance(actor_user_id, str) or not actor_user_id.strip(): + raise ValueError("actor_source 'user' requires actor_user_id") + if actor_child_id is not None: + raise ValueError( + "actor_source 'user' must not carry actor_child_id" + ) + return "user", actor_user_id.strip(), None + if actor_user_id is not None: + raise ValueError("actor_source 'panel' must not carry actor_user_id") + if actor_child_id is None: + raise ValueError( + "actor_source 'panel' requires actor_child_id" + ) + return ( + "panel", + None, + _validate_int_id(actor_child_id, "actor_child_id"), + ) + + +def _validate_was_on_time( + event_type: str, was_on_time: object +) -> bool | None: + """Return a real bool or None; completed events require True/False.""" + if was_on_time is not None and type(was_on_time) is not bool: + raise ValueError( + f"was_on_time must be True, False or None, got {was_on_time!r}" + ) + if event_type == EVENT_COMPLETED and was_on_time is None: + raise ValueError( + "a completed event requires was_on_time (True/False)" + ) + return was_on_time + + +async def append_event( + database: NestQuestDatabase, + instance_id: int, + child_id: int, + event_type: str, + *, + actor_source: str, + actor_user_id: str | None = None, + actor_child_id: int | None = None, + was_on_time: bool | None = None, + now: datetime.datetime | None = None, +) -> CompletionEventRecord: + """Append one completion or un-completion event and return the row. + + Validates actor shapes and the completed-event on-time requirement + before calling :meth:`CompletionEventsDao.append`. ``occurred_at`` + is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock. + """ + instance_id = _validate_int_id(instance_id, "instance_id") + child_id = _validate_int_id(child_id, "child_id") + event_type = _validate_event_type(event_type) + actor_source, actor_user_id, actor_child_id = _validate_actor( + actor_source, actor_user_id, actor_child_id + ) + was_on_time = _validate_was_on_time(event_type, was_on_time) + occurred_at = _stamp_occurred_at(now) + return await CompletionEventsDao(database).append( + instance_id, + child_id, + event_type, + actor_source, + occurred_at, + was_on_time, + actor_user_id=actor_user_id, + actor_child_id=actor_child_id, + ) diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..5b4dc19 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,339 @@ +"""Tests for completion.py: the Feature 08 append-only business layer.""" +from __future__ import annotations + +import asyncio +import datetime +import inspect +import re + +import pytest + +from custom_components.nestquest.completion import append_event +from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.dao_instances import ( + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + CompletionEventRecord, + QuestInstancesDao, +) +from custom_components.nestquest.dao_rules import ( + QuestDefinitionsDao, + ScheduleRulesDao, +) +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + from unittest.mock import AsyncMock, MagicMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +def _today_iso() -> str: + return datetime.date.today().isoformat() + + +def _now_stamp() -> str: + return f"{_today_iso()}T08:00:00+00:00" + + +def _future_day(offset: int) -> str: + return ( + datetime.date.today() + datetime.timedelta(days=offset) + ).isoformat() + + +D1 = _future_day(1) + + +async def _prepare(path) -> tuple: + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + await apply_migrations(database) + children = ChildrenDao(database) + rules = ScheduleRulesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) + child = await children.create("Ada", _now_stamp()) + rule = await rules.create("daily", D1) + definition = await definitions.create( + "Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id] + ) + instance = await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="morning" + ) + return database, child, instance + + +def _with_db(tmp_path, name): + def _run_test(body): + async def _main(): + prepared = await _prepare(tmp_path / name) + try: + return await body(*prepared) + finally: + await prepared[0].close() + + return _run(_main()) + + return _run_test + + +# --------------------------------------------------------------------------- +# inverted actor shapes: field-naming ValueErrors, no write +# --------------------------------------------------------------------------- + + +def test_user_without_actor_user_id_rejected() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + was_on_time=True, + ) + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id=None, + was_on_time=True, + ) + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="", + was_on_time=True, + ) + + _run(_body()) + + +def test_user_must_not_carry_actor_child_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_child_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + actor_child_id=1, + was_on_time=True, + ) + + _run(_body()) + + +def test_panel_must_not_carry_actor_user_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="panel", + actor_user_id="user-1", + actor_child_id=1, + was_on_time=True, + ) + + _run(_body()) + + +def test_panel_requires_actor_child_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_child_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="panel", + was_on_time=True, + ) + + _run(_body()) + + +def test_actor_source_service_rejected() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_source"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="service", + actor_user_id="user-1", + was_on_time=True, + ) + + _run(_body()) + + +def test_completed_requires_was_on_time() -> None: + async def _body(): + with pytest.raises(ValueError, match="was_on_time"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + ) + + _run(_body()) + + +# --------------------------------------------------------------------------- +# happy paths +# --------------------------------------------------------------------------- + + +def test_user_append_round_trips(tmp_path) -> None: + async def _body(database, child, instance): + event = await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + assert isinstance(event, CompletionEventRecord) + assert event.event_type == EVENT_COMPLETED + assert event.actor_source == "user" + assert event.actor_user_id == "user-1" + assert event.actor_child_id is None + assert event.was_on_time is True + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", event.occurred_at + ) + return event + + _with_db(tmp_path, "user-append.db")(_body) + + +def test_panel_append_round_trips(tmp_path) -> None: + async def _body(database, child, instance): + event = await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="panel", + actor_child_id=child.id, + was_on_time=False, + ) + assert event.actor_source == "panel" + assert event.actor_user_id is None + assert event.actor_child_id == child.id + assert event.was_on_time is False + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", event.occurred_at + ) + return event + + _with_db(tmp_path, "panel-append.db")(_body) + + +def test_uncompleted_allows_null_was_on_time(tmp_path) -> None: + async def _body(database, child, instance): + event = await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=None, + ) + assert event.event_type == EVENT_UNCOMPLETED + assert event.was_on_time is None + return event + + _with_db(tmp_path, "uncompleted-null.db")(_body) + + +def test_now_pins_occurred_at_utc(tmp_path) -> None: + async def _body(database, child, instance): + pinned = datetime.datetime( + 2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc + ) + event = await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + now=pinned, + ) + assert event.occurred_at == "2026-09-18T08:00:00+00:00" + return event + + _with_db(tmp_path, "pinned-now.db")(_body) + + +# --------------------------------------------------------------------------- +# append-only: the module never exposes update/delete +# --------------------------------------------------------------------------- + + +def test_module_never_exposes_update_or_delete() -> None: + import custom_components.nestquest.completion as completion + + public = { + name + for name, _ in inspect.getmembers(completion, inspect.isfunction) + if not name.startswith("_") + } + forbidden = { + name + for name in public + if name.startswith( + ( + "update", + "delete", + "set", + "clear", + "remove", + "modify", + "edit", + "upsert", + ) + ) + } + assert forbidden == set(), ( + f"completion.py exposed mutation methods: {forbidden}" + ) + assert "append_event" in public + assert "complete_instance" not in public + assert "uncomplete_instance" not in public + source = inspect.getsource(completion) + assert "UPDATE" not in source + assert "DELETE" not in source + assert "INSERT" not in source -- 2.55.0 From 293efe9705848a7da7225a14f4bb275edb4d65a5 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 06:35:50 -0400 Subject: [PATCH 22/28] Derive instance state from latest event (Feature 08, task 2) (#89) --- custom_components/nestquest/completion.py | 39 +++++++- tests/test_completion.py | 109 +++++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 4b512b4..94bbb46 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -5,8 +5,10 @@ Sits between the Feature 09 service gate and the typed validation and the UTC occurred-at stamp here, and never talk to the DAO or SQL directly. This module is the only Feature-08 caller of :meth:`~.dao_instances.CompletionEventsDao.append`. Completing and -un-completing an instance (no-op-if-already-done, derived state) arrive -in later tasks; this module is the append wrapper only. +un-completing an instance (no-op-if-already-done) arrive in later +tasks. Instance current state is derived from the latest event via +:func:`instance_state` (``open`` / ``done``); there is no status +column. Actor policy matches the schema CHECKs (D-008): ``actor_source`` is ``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` @@ -25,11 +27,13 @@ from __future__ import annotations import datetime +from .dao_children import _connection_lock from .dao_instances import ( EVENT_COMPLETED, EVENT_UNCOMPLETED, CompletionEventRecord, CompletionEventsDao, + QuestInstancesDao, ) from .db import NestQuestDatabase @@ -163,3 +167,34 @@ async def append_event( actor_user_id=actor_user_id, actor_child_id=actor_child_id, ) + + +async def instance_state( + database: NestQuestDatabase, + instance_id: int, +) -> str: + """Return ``open`` or ``done`` from the instance's latest event. + + No events, or a latest event of uncompleted, is ``open``. A latest + event of completed is ``done``. Unknown ``instance_id`` raises + ValueError naming the field. Existence and the latest event are + read inside the connection lock so the answer is one snapshot. + """ + instance_id = _validate_int_id(instance_id, "instance_id") + async with _connection_lock(database): + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + latest = await CompletionEventsDao(database).get_latest_for_instance( + instance_id + ) + if latest is None or latest.event_type == EVENT_UNCOMPLETED: + return "open" + if latest.event_type == EVENT_COMPLETED: + return "done" + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" + ) diff --git a/tests/test_completion.py b/tests/test_completion.py index 5b4dc19..5412db5 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,12 +8,13 @@ import re import pytest -from custom_components.nestquest.completion import append_event +from custom_components.nestquest.completion import append_event, instance_state from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_instances import ( EVENT_COMPLETED, EVENT_UNCOMPLETED, CompletionEventRecord, + CompletionEventsDao, QuestInstancesDao, ) from custom_components.nestquest.dao_rules import ( @@ -331,9 +332,115 @@ def test_module_never_exposes_update_or_delete() -> None: f"completion.py exposed mutation methods: {forbidden}" ) assert "append_event" in public + assert "instance_state" in public assert "complete_instance" not in public assert "uncomplete_instance" not in public source = inspect.getsource(completion) assert "UPDATE" not in source assert "DELETE" not in source assert "INSERT" not in source + + +# --------------------------------------------------------------------------- +# instance_state: derived from the latest event +# --------------------------------------------------------------------------- + + +def test_instance_state_open_when_no_events(tmp_path) -> None: + async def _body(database, child, instance): + assert await instance_state(database, instance.id) == "open" + + _with_db(tmp_path, "state-none.db")(_body) + + +def test_instance_state_open_when_latest_uncompleted(tmp_path) -> None: + async def _body(database, child, instance): + await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + ) + assert await instance_state(database, instance.id) == "open" + + _with_db(tmp_path, "state-uncompleted.db")(_body) + + +def test_instance_state_unknown_instance_id_raises(tmp_path) -> None: + async def _body(database, child, instance): + with pytest.raises(ValueError, match="instance_id"): + await instance_state(database, instance.id + 999) + + _with_db(tmp_path, "state-unknown.db")(_body) + + +def test_instance_state_rejects_non_integer_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="instance_id"): + await instance_state(object(), "1") + with pytest.raises(ValueError, match="instance_id"): + await instance_state(object(), True) + + _run(_body()) + + +def test_instance_state_three_events_returns_done_and_preserves_rows( + tmp_path, +) -> None: + async def _body(database, child, instance): + base = datetime.datetime( + 2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc + ) + await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + now=base, + ) + await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + now=base + datetime.timedelta(seconds=1), + ) + await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=False, + now=base + datetime.timedelta(seconds=2), + ) + events = CompletionEventsDao(database) + before = await events.list_by_instance(instance.id) + assert [e.event_type for e in before] == [ + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + EVENT_COMPLETED, + ] + assert await instance_state(database, instance.id) == "done" + after = await events.list_by_instance(instance.id) + assert after == before + assert len(after) == 3 + + _with_db(tmp_path, "state-three.db")(_body) -- 2.55.0 From 775a9906e7ae7a7a6fb4081d8e8c0332f7d9ed8b Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 06:47:45 -0400 Subject: [PATCH 23/28] complete_instance (Feature 08, task 3) (#90) --- custom_components/nestquest/completion.py | 128 +++++++++++- tests/test_completion.py | 244 +++++++++++++++++++++- 2 files changed, 364 insertions(+), 8 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 94bbb46..d87bb31 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -4,11 +4,11 @@ Sits between the Feature 09 service gate and the typed :class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape validation and the UTC occurred-at stamp here, and never talk to the DAO or SQL directly. This module is the only Feature-08 caller of -:meth:`~.dao_instances.CompletionEventsDao.append`. Completing and -un-completing an instance (no-op-if-already-done) arrive in later -tasks. Instance current state is derived from the latest event via -:func:`instance_state` (``open`` / ``done``); there is no status -column. +:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an +instance (no-op if already done) is :func:`complete_instance`; +un-completing arrives in a later task. Instance current state is +derived from the latest event via :func:`instance_state` +(``open`` / ``done``); there is no status column. Actor policy matches the schema CHECKs (D-008): ``actor_source`` is ``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` @@ -25,9 +25,10 @@ is no permission check here (that is Feature 09). """ from __future__ import annotations +import asyncio import datetime -from .dao_children import _connection_lock +from .dao_children import _CONNECTION_LOCKS, _connection_lock from .dao_instances import ( EVENT_COMPLETED, EVENT_UNCOMPLETED, @@ -62,6 +63,63 @@ def _stamp_occurred_at(now: datetime.datetime | None) -> str: ) +class _TaskReentrantLock: + """Shared connection lock the owning task may re-enter.""" + + def __init__(self, inner: asyncio.Lock) -> None: + self._inner = inner + self._task: asyncio.Task | None = None + self._depth = 0 + + def locked(self) -> bool: + return self._inner.locked() + + async def acquire(self) -> bool: + task = asyncio.current_task() + if self._task is task: + self._depth += 1 + return True + await self._inner.acquire() + self._task = task + self._depth = 1 + return True + + def release(self) -> None: + if self._task is not asyncio.current_task(): + raise RuntimeError("lock released by a non-owner") + self._depth -= 1 + if self._depth == 0: + self._task = None + self._inner.release() + + async def __aenter__(self) -> _TaskReentrantLock: + await self.acquire() + return self + + async def __aexit__(self, *exc: object) -> None: + self.release() + + +def _reentrant_connection_lock(database) -> _TaskReentrantLock: + """Return the shared connection lock, made task-reentrant. + + ``CompletionEventsDao.append`` also acquires this lock, so + check+append in :func:`complete_instance` must re-enter on the + same task. The wrapper is stored in the shared dict so the DAO + sees the same object. + """ + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _CONNECTION_LOCKS.get(key) + if isinstance(lock, _TaskReentrantLock): + return lock + wrapped = _TaskReentrantLock( + lock if lock is not None else asyncio.Lock() + ) + _CONNECTION_LOCKS[key] = wrapped + return wrapped + + def _validate_int_id(value: object, field: str) -> int: """Reject non-int ids (bools included) before any lookup.""" if isinstance(value, bool) or not isinstance(value, int): @@ -198,3 +256,61 @@ async def instance_state( f"event_type must be '{EVENT_COMPLETED}' or " f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" ) + + +async def complete_instance( + database: NestQuestDatabase, + instance_id: int, + *, + actor_source: str, + actor_user_id: str | None = None, + actor_child_id: int | None = None, + now: datetime.datetime | None = None, + today: datetime.date | None = None, +) -> str: + """Append a completed event unless the instance is already done. + + Returns the derived state ``done``. An already-done instance is a + no-op: nothing is appended. Unknown ``instance_id`` raises + ValueError naming the field. Existence, latest-event, and the + append run under one connection lock so a concurrent complete + cannot double-append. + + ``was_on_time`` is recorded as True — a placeholder until the + on-time task computes it from ``now`` / ``today`` against the + instance due_date and optional due_time. ``today`` is accepted so + callers can thread the HA-local date through without a signature + change; it is unused here. + """ + instance_id = _validate_int_id(instance_id, "instance_id") + actor_source, actor_user_id, actor_child_id = _validate_actor( + actor_source, actor_user_id, actor_child_id + ) + async with _reentrant_connection_lock(database): + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + latest = await CompletionEventsDao(database).get_latest_for_instance( + instance_id + ) + if latest is not None and latest.event_type == EVENT_COMPLETED: + return "done" + if latest is not None and latest.event_type != EVENT_UNCOMPLETED: + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" + ) + await append_event( + database, + instance.id, + instance.child_id, + EVENT_COMPLETED, + actor_source=actor_source, + actor_user_id=actor_user_id, + actor_child_id=actor_child_id, + was_on_time=True, + now=now, + ) + return "done" diff --git a/tests/test_completion.py b/tests/test_completion.py index 5412db5..e9e2ff4 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,7 +8,11 @@ import re import pytest -from custom_components.nestquest.completion import append_event, instance_state +from custom_components.nestquest.completion import ( + append_event, + complete_instance, + instance_state, +) from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_instances import ( EVENT_COMPLETED, @@ -333,7 +337,7 @@ def test_module_never_exposes_update_or_delete() -> None: ) assert "append_event" in public assert "instance_state" in public - assert "complete_instance" not in public + assert "complete_instance" in public assert "uncomplete_instance" not in public source = inspect.getsource(completion) assert "UPDATE" not in source @@ -444,3 +448,239 @@ def test_instance_state_three_events_returns_done_and_preserves_rows( assert len(after) == 3 _with_db(tmp_path, "state-three.db")(_body) + + +# --------------------------------------------------------------------------- +# complete_instance: append completed, no-op if already done +# --------------------------------------------------------------------------- + + +def test_complete_instance_appends_and_returns_done(tmp_path) -> None: + async def _body(database, child, instance): + events = CompletionEventsDao(database) + assert await events.list_by_instance(instance.id) == [] + state = await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + assert state == "done" + assert await instance_state(database, instance.id) == "done" + rows = await events.list_by_instance(instance.id) + assert len(rows) == 1 + assert rows[0].event_type == EVENT_COMPLETED + assert rows[0].actor_source == "user" + assert rows[0].actor_user_id == "user-1" + assert rows[0].was_on_time is True + + _with_db(tmp_path, "complete-open.db")(_body) + + +def test_complete_instance_already_done_is_noop(tmp_path) -> None: + async def _body(database, child, instance): + first = await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + events = CompletionEventsDao(database) + before = await events.list_by_instance(instance.id) + assert first == "done" + assert len(before) == 1 + second = await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-2", + ) + after = await events.list_by_instance(instance.id) + assert second == "done" + assert after == before + assert after[0].actor_user_id == "user-1" + + _with_db(tmp_path, "complete-noop.db")(_body) + + +def test_complete_instance_unknown_instance_id_raises(tmp_path) -> None: + async def _body(database, child, instance): + with pytest.raises(ValueError, match="instance_id"): + await complete_instance( + database, + instance.id + 999, + actor_source="user", + actor_user_id="user-1", + ) + + _with_db(tmp_path, "complete-unknown.db")(_body) + + +def test_complete_instance_rejects_non_integer_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="instance_id"): + await complete_instance( + object(), + "1", + actor_source="user", + actor_user_id="user-1", + ) + with pytest.raises(ValueError, match="instance_id"): + await complete_instance( + object(), + True, + actor_source="user", + actor_user_id="user-1", + ) + + _run(_body()) + + +def test_complete_instance_after_uncompleted_appends(tmp_path) -> None: + async def _body(database, child, instance): + await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + ) + events = CompletionEventsDao(database) + before = await events.list_by_instance(instance.id) + assert await instance_state(database, instance.id) == "open" + state = await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + ) + assert state == "done" + after = await events.list_by_instance(instance.id) + assert len(after) == len(before) + 1 + assert after[-1].event_type == EVENT_COMPLETED + assert after[-1].actor_source == "panel" + assert after[-1].was_on_time is True + + _with_db(tmp_path, "complete-reopen.db")(_body) + + +def test_complete_instance_now_pins_occurred_at(tmp_path) -> None: + async def _body(database, child, instance): + pinned = datetime.datetime( + 2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=pinned, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].occurred_at == "2026-09-18T08:00:00+00:00" + + _with_db(tmp_path, "complete-pinned.db")(_body) + + +def _gated_hass(armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event): + """A hass mock whose executor gates the FIRST ``_execute`` job after + arming — the INSERT of whichever ``complete_instance`` starts first + — proving that caller holds the connection lock and the second + caller must queue behind it.""" + from unittest.mock import MagicMock + + hass = MagicMock() + + async def _gated_executor(fn, *args): + if armed["active"]: + if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor + return hass + + +def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None: + """Two racing completes cannot both append. + + Deterministic gate: task A is paused at its INSERT (it holds the + connection lock); task B is then started and must queue. A + finishes, B sees done and appends nothing. + """ + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "complete-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + rules = ScheduleRulesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) + child = await children.create("Ada", _now_stamp()) + rule = await rules.create("daily", D1) + definition = await definitions.create( + "Brush teeth", + rule.id, + _now_stamp(), + assignee_child_ids=[child.id], + ) + instance = await instances.upsert( + definition.id, + child.id, + D1, + _now_stamp(), + window="morning", + ) + + armed["active"] = True + task_a = asyncio.ensure_future( + complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-2", + ) + ) + await asyncio.sleep(0) + gate_open.set() + state_a, state_b = await asyncio.gather(task_a, task_b) + assert state_a == "done" + assert state_b == "done" + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert len(rows) == 1 + assert rows[0].actor_user_id == "user-1" + finally: + await database.close() + + _run(_main()) -- 2.55.0 From c3495bb277eabb94d17f541186038a85ddc73e7e Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 06:52:42 -0400 Subject: [PATCH 24/28] uncomplete_instance reversal event (Feature 08, task 4) (#91) --- custom_components/nestquest/completion.py | 65 +++++- tests/test_completion.py | 240 +++++++++++++++++++++- 2 files changed, 298 insertions(+), 7 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index d87bb31..3a4108e 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -6,9 +6,10 @@ validation and the UTC occurred-at stamp here, and never talk to the DAO or SQL directly. This module is the only Feature-08 caller of :meth:`~.dao_instances.CompletionEventsDao.append`. Completing an instance (no-op if already done) is :func:`complete_instance`; -un-completing arrives in a later task. Instance current state is -derived from the latest event via :func:`instance_state` -(``open`` / ``done``); there is no status column. +un-completing (no-op if already open) is :func:`uncomplete_instance`. +Instance current state is derived from the latest event via +:func:`instance_state` (``open`` / ``done``); there is no status +column. Actor policy matches the schema CHECKs (D-008): ``actor_source`` is ``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` @@ -104,9 +105,10 @@ def _reentrant_connection_lock(database) -> _TaskReentrantLock: """Return the shared connection lock, made task-reentrant. ``CompletionEventsDao.append`` also acquires this lock, so - check+append in :func:`complete_instance` must re-enter on the - same task. The wrapper is stored in the shared dict so the DAO - sees the same object. + check+append in :func:`complete_instance` and + :func:`uncomplete_instance` must re-enter on the same task. The + wrapper is stored in the shared dict so the DAO sees the same + object. """ loop = asyncio.get_running_loop() key = (id(database), id(loop)) @@ -314,3 +316,54 @@ async def complete_instance( now=now, ) return "done" + + +async def uncomplete_instance( + database: NestQuestDatabase, + instance_id: int, + *, + actor_source: str, + actor_user_id: str | None = None, + actor_child_id: int | None = None, + now: datetime.datetime | None = None, +) -> str: + """Append an uncompleted event unless the instance is already open. + + Returns the derived state ``open``. An already-open instance is a + no-op: nothing is appended. Unknown ``instance_id`` raises + ValueError naming the field. Existence, latest-event, and the + append run under one connection lock so a concurrent uncomplete + cannot double-append. The original completed row is not modified. + """ + instance_id = _validate_int_id(instance_id, "instance_id") + actor_source, actor_user_id, actor_child_id = _validate_actor( + actor_source, actor_user_id, actor_child_id + ) + async with _reentrant_connection_lock(database): + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + latest = await CompletionEventsDao(database).get_latest_for_instance( + instance_id + ) + if latest is None or latest.event_type == EVENT_UNCOMPLETED: + return "open" + if latest.event_type != EVENT_COMPLETED: + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" + ) + await append_event( + database, + instance.id, + instance.child_id, + EVENT_UNCOMPLETED, + actor_source=actor_source, + actor_user_id=actor_user_id, + actor_child_id=actor_child_id, + was_on_time=None, + now=now, + ) + return "open" diff --git a/tests/test_completion.py b/tests/test_completion.py index e9e2ff4..ac54dbd 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -12,6 +12,7 @@ from custom_components.nestquest.completion import ( append_event, complete_instance, instance_state, + uncomplete_instance, ) from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_instances import ( @@ -338,7 +339,7 @@ def test_module_never_exposes_update_or_delete() -> None: assert "append_event" in public assert "instance_state" in public assert "complete_instance" in public - assert "uncomplete_instance" not in public + assert "uncomplete_instance" in public source = inspect.getsource(completion) assert "UPDATE" not in source assert "DELETE" not in source @@ -684,3 +685,240 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None: await database.close() _run(_main()) + + +# --------------------------------------------------------------------------- +# uncomplete_instance: append uncompleted, no-op if already open +# --------------------------------------------------------------------------- + + +def test_uncomplete_instance_appends_and_leaves_original_untouched( + tmp_path, +) -> None: + async def _body(database, child, instance): + completed_at = datetime.datetime( + 2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=completed_at, + ) + events = CompletionEventsDao(database) + before = await events.list_by_instance(instance.id) + assert len(before) == 1 + original = before[0] + assert original.event_type == EVENT_COMPLETED + assert original.occurred_at == "2026-09-18T08:00:00+00:00" + assert original.actor_source == "user" + assert original.actor_user_id == "user-1" + assert original.actor_child_id is None + uncompleted_at = completed_at + datetime.timedelta(seconds=1) + state = await uncomplete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + now=uncompleted_at, + ) + assert state == "open" + assert await instance_state(database, instance.id) == "open" + after = await events.list_by_instance(instance.id) + assert len(after) == 2 + assert after[0].id == original.id + assert after[0].event_type == EVENT_COMPLETED + assert after[0].occurred_at == original.occurred_at + assert after[0].actor_source == original.actor_source + assert after[0].actor_user_id == original.actor_user_id + assert after[0].actor_child_id == original.actor_child_id + assert after[1].event_type == EVENT_UNCOMPLETED + assert after[1].actor_source == "panel" + assert after[1].actor_child_id == child.id + assert after[1].was_on_time is None + + _with_db(tmp_path, "uncomplete-append.db")(_body) + + +def test_uncomplete_instance_already_open_is_noop(tmp_path) -> None: + async def _body(database, child, instance): + events = CompletionEventsDao(database) + assert await events.list_by_instance(instance.id) == [] + first = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + after_first = await events.list_by_instance(instance.id) + assert first == "open" + assert after_first == [] + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + before = await events.list_by_instance(instance.id) + assert len(before) == 2 + second = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-2", + ) + after = await events.list_by_instance(instance.id) + assert second == "open" + assert after == before + assert after[0].actor_user_id == "user-1" + assert after[1].actor_user_id == "user-1" + + _with_db(tmp_path, "uncomplete-noop.db")(_body) + + +def test_uncomplete_instance_unknown_instance_id_raises(tmp_path) -> None: + async def _body(database, child, instance): + with pytest.raises(ValueError, match="instance_id"): + await uncomplete_instance( + database, + instance.id + 999, + actor_source="user", + actor_user_id="user-1", + ) + + _with_db(tmp_path, "uncomplete-unknown.db")(_body) + + +def test_uncomplete_instance_rejects_non_integer_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="instance_id"): + await uncomplete_instance( + object(), + "1", + actor_source="user", + actor_user_id="user-1", + ) + with pytest.raises(ValueError, match="instance_id"): + await uncomplete_instance( + object(), + True, + actor_source="user", + actor_user_id="user-1", + ) + + _run(_body()) + + +def test_uncomplete_instance_now_pins_occurred_at(tmp_path) -> None: + async def _body(database, child, instance): + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=datetime.datetime( + 2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc + ), + ) + pinned = datetime.datetime( + 2026, 9, 18, 8, 0, 1, tzinfo=datetime.timezone.utc + ) + await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=pinned, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[1].occurred_at == "2026-09-18T08:00:01+00:00" + + _with_db(tmp_path, "uncomplete-pinned.db")(_body) + + +def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None: + """Two racing uncompletes cannot both append. + + Deterministic gate: task A is paused at its INSERT (it holds the + connection lock); task B is then started and must queue. A + finishes, B sees open and appends nothing. + """ + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "uncomplete-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + rules = ScheduleRulesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) + child = await children.create("Ada", _now_stamp()) + rule = await rules.create("daily", D1) + definition = await definitions.create( + "Brush teeth", + rule.id, + _now_stamp(), + assignee_child_ids=[child.id], + ) + instance = await instances.upsert( + definition.id, + child.id, + D1, + _now_stamp(), + window="morning", + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + + armed["active"] = True + task_a = asyncio.ensure_future( + uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + ) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-2", + ) + ) + await asyncio.sleep(0) + gate_open.set() + state_a, state_b = await asyncio.gather(task_a, task_b) + assert state_a == "open" + assert state_b == "open" + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert len(rows) == 2 + assert rows[0].event_type == EVENT_COMPLETED + assert rows[1].event_type == EVENT_UNCOMPLETED + assert rows[1].actor_user_id == "user-1" + finally: + await database.close() + + _run(_main()) -- 2.55.0 From 2ec016ebfe3efb01a932991113ae4b308c191330 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 07:02:29 -0400 Subject: [PATCH 25/28] Compute was_on_time at completion (Feature 08, task 5) (#92) --- custom_components/nestquest/completion.py | 71 +++++++- tests/test_completion.py | 201 ++++++++++++++++++++++ 2 files changed, 266 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 3a4108e..3f22089 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -35,6 +35,7 @@ from .dao_instances import ( EVENT_UNCOMPLETED, CompletionEventRecord, CompletionEventsDao, + QuestInstanceRecord, QuestInstancesDao, ) from .db import NestQuestDatabase @@ -64,6 +65,64 @@ def _stamp_occurred_at(now: datetime.datetime | None) -> str: ) +def _completion_moment( + now: datetime.datetime | None, + today: datetime.date | None, +) -> tuple[datetime.date, datetime.time | None]: + """Return the HA-local completion date and optional wall-clock time. + + ``today`` is the HA-local calendar date when threaded (Feature 07 + style). ``now`` is a timezone-aware HA-local datetime; its wall + clock is used as-is and is never converted to UTC. When ``today`` + is omitted the date comes from ``now``. When both are omitted the + host calendar date is the Feature 07 fallback — never the UTC + clock. Time-of-day is None unless ``now`` is supplied; comparing + against ``due_time`` requires ``now``. + """ + if now is not None: + if not isinstance(now, datetime.datetime): + raise ValueError(f"now must be a datetime, got {now!r}") + if now.tzinfo is None: + raise ValueError("now must be timezone-aware") + if today is not None: + completion_date = today + elif now is not None: + completion_date = now.date() + else: + completion_date = datetime.date.today() + if now is None: + return completion_date, None + return completion_date, now.time() + + +def _was_on_time( + instance: QuestInstanceRecord, + now: datetime.datetime | None, + today: datetime.date | None, +) -> bool: + """Return whether the completion moment is on time for ``instance``. + + On ``due_date`` before ``due_time`` is on time; on ``due_date`` + after ``due_time``, or any later date, is late. With no + ``due_time``, on time if the completion date equals ``due_date`` + (or is earlier); late if after ``due_date``. Same-day + ``due_time`` comparison requires ``now``; UTC time-of-day is never + substituted. + """ + completion_date, completion_time = _completion_moment(now, today) + due_date = datetime.date.fromisoformat(instance.due_date) + if completion_date < due_date: + return True + if completion_date > due_date: + return False + if instance.due_time is None: + return True + if completion_time is None: + raise ValueError("now is required to compare due_time") + due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time() + return completion_time <= due_time + + class _TaskReentrantLock: """Shared connection lock the owning task may re-enter.""" @@ -278,11 +337,11 @@ async def complete_instance( append run under one connection lock so a concurrent complete cannot double-append. - ``was_on_time`` is recorded as True — a placeholder until the - on-time task computes it from ``now`` / ``today`` against the - instance due_date and optional due_time. ``today`` is accepted so - callers can thread the HA-local date through without a signature - change; it is unused here. + ``was_on_time`` is computed from the HA-local completion moment + (threaded ``now`` / ``today``) against the instance ``due_date`` + and optional ``due_time``. ``now`` is the timezone-aware HA-local + datetime; its wall clock is never converted to UTC. Same-day + ``due_time`` comparison requires ``now``. """ instance_id = _validate_int_id(instance_id, "instance_id") actor_source, actor_user_id, actor_child_id = _validate_actor( @@ -312,7 +371,7 @@ async def complete_instance( actor_source=actor_source, actor_user_id=actor_user_id, actor_child_id=actor_child_id, - was_on_time=True, + was_on_time=_was_on_time(instance, now, today), now=now, ) return "done" diff --git a/tests/test_completion.py b/tests/test_completion.py index ac54dbd..da2493e 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -687,6 +687,207 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None: _run(_main()) +_HA_TZ = datetime.timezone(datetime.timedelta(hours=-8)) + + +def _ha_local(day: datetime.date, hour: int, minute: int = 0) -> datetime.datetime: + return datetime.datetime( + day.year, day.month, day.day, hour, minute, 0, + tzinfo=_HA_TZ, + ) + + +def test_complete_instance_on_due_date_before_due_time_is_on_time( + tmp_path, +) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + instance = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + instance.due_date, + _now_stamp(), + window=instance.window, + due_time="12:00", + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(due_date, 11, 0), + today=due_date, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].was_on_time is True + + _with_db(tmp_path, "ontime-before-due-time.db")(_body) + + +def test_complete_instance_on_due_date_after_due_time_is_late( + tmp_path, +) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + instance = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + instance.due_date, + _now_stamp(), + window=instance.window, + due_time="12:00", + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(due_date, 12, 1), + today=due_date, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].was_on_time is False + + _with_db(tmp_path, "late-after-due-time.db")(_body) + + +def test_complete_instance_microsecond_after_due_time_is_late(tmp_path) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + instance = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + instance.due_date, + _now_stamp(), + window=instance.window, + due_time="12:00", + ) + now = datetime.datetime( + due_date.year, due_date.month, due_date.day, 12, 0, 0, 500, + tzinfo=_HA_TZ, + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=now, + today=due_date, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].was_on_time is False + + _with_db(tmp_path, "late-microsecond-after-due-time.db")(_body) + + +def test_complete_instance_no_due_time_on_due_date_is_on_time( + tmp_path, +) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + assert instance.due_time is None + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(due_date, 23, 59), + today=due_date, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].was_on_time is True + + _with_db(tmp_path, "ontime-no-due-time.db")(_body) + + +def test_complete_instance_no_due_time_after_due_date_is_late( + tmp_path, +) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + later = due_date + datetime.timedelta(days=1) + assert instance.due_time is None + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(later, 8, 0), + today=later, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].was_on_time is False + + _with_db(tmp_path, "late-after-due-date.db")(_body) + + +def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None: + """HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00. + + The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting + to UTC before comparing would mark this late. + """ + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + instance = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + instance.due_date, + _now_stamp(), + window=instance.window, + due_time="12:00", + ) + now = _ha_local(due_date, 11, 0) + assert now.astimezone(datetime.timezone.utc).hour == 19 + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=now, + today=due_date, + ) + rows = await CompletionEventsDao(database).list_by_instance( + instance.id + ) + assert rows[0].was_on_time is True + + _with_db(tmp_path, "ontime-ha-local-not-utc.db")(_body) + + +def test_complete_instance_due_time_requires_now_on_due_date(tmp_path) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + instance = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + instance.due_date, + _now_stamp(), + window=instance.window, + due_time="12:00", + ) + with pytest.raises(ValueError, match="now is required to compare due_time"): + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due_date, + ) + + _with_db(tmp_path, "due-time-requires-now.db")(_body) + + # --------------------------------------------------------------------------- # uncomplete_instance: append uncompleted, no-op if already open # --------------------------------------------------------------------------- -- 2.55.0 From 318d77f443f1839e48755cdcedd62f622ac13325 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 07:09:21 -0400 Subject: [PATCH 26/28] Derive missed for past-due open instances (Feature 08, task 6) (#93) --- custom_components/nestquest/completion.py | 113 +++++++--- tests/test_completion.py | 238 ++++++++++++++++++++++ 2 files changed, 328 insertions(+), 23 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 3f22089..15aa11c 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -8,8 +8,10 @@ DAO or SQL directly. This module is the only Feature-08 caller of instance (no-op if already done) is :func:`complete_instance`; un-completing (no-op if already open) is :func:`uncomplete_instance`. Instance current state is derived from the latest event via -:func:`instance_state` (``open`` / ``done``); there is no status -column. +:func:`instance_state` (``open`` / ``done`` / ``missed``); there is +no status column. ``missed`` is derived for past-due open instances +(``due_date`` before HA-local ``today``) and is never stored as an +event_type. Actor policy matches the schema CHECKs (D-008): ``actor_source`` is ``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` @@ -95,6 +97,37 @@ def _completion_moment( return completion_date, now.time() +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the threaded HA-local date, or the host calendar date.""" + if today is None: + return datetime.date.today() + return today + + +def _derived_state( + instance: QuestInstanceRecord, + latest: CompletionEventRecord | None, + today: datetime.date, +) -> str: + """Return ``open``, ``done``, or ``missed`` from event plus due date. + + A latest completed event is ``done`` even when past due. No events + or a latest uncompleted event is ``open``, or ``missed`` when + ``due_date`` is before ``today``. ``missed`` is never an event_type. + """ + if latest is None or latest.event_type == EVENT_UNCOMPLETED: + due_date = datetime.date.fromisoformat(instance.due_date) + if due_date < today: + return "missed" + return "open" + if latest.event_type == EVENT_COMPLETED: + return "done" + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" + ) + + def _was_on_time( instance: QuestInstanceRecord, now: datetime.datetime | None, @@ -291,15 +324,21 @@ async def append_event( async def instance_state( database: NestQuestDatabase, instance_id: int, + *, + today: datetime.date | None = None, ) -> str: - """Return ``open`` or ``done`` from the instance's latest event. + """Return ``open``, ``done``, or ``missed`` from the latest event. - No events, or a latest event of uncompleted, is ``open``. A latest - event of completed is ``done``. Unknown ``instance_id`` raises - ValueError naming the field. Existence and the latest event are - read inside the connection lock so the answer is one snapshot. + No events, or a latest event of uncompleted, is ``open`` unless + ``due_date`` is before ``today`` (HA-local, threaded), in which + case it is ``missed``. A latest event of completed is ``done`` + even when past due. ``missed`` is never stored as an event_type. + Unknown ``instance_id`` raises ValueError naming the field. + Existence and the latest event are read inside the connection lock + so the answer is one snapshot. """ instance_id = _validate_int_id(instance_id, "instance_id") + today_date = _resolve_today(today) async with _connection_lock(database): instance = await QuestInstancesDao(database).get_by_id(instance_id) if instance is None: @@ -309,14 +348,7 @@ async def instance_state( latest = await CompletionEventsDao(database).get_latest_for_instance( instance_id ) - if latest is None or latest.event_type == EVENT_UNCOMPLETED: - return "open" - if latest.event_type == EVENT_COMPLETED: - return "done" - raise ValueError( - f"event_type must be '{EVENT_COMPLETED}' or " - f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" - ) + return _derived_state(instance, latest, today_date) async def complete_instance( @@ -385,19 +417,23 @@ async def uncomplete_instance( actor_user_id: str | None = None, actor_child_id: int | None = None, now: datetime.datetime | None = None, + today: datetime.date | None = None, ) -> str: """Append an uncompleted event unless the instance is already open. - Returns the derived state ``open``. An already-open instance is a - no-op: nothing is appended. Unknown ``instance_id`` raises - ValueError naming the field. Existence, latest-event, and the - append run under one connection lock so a concurrent uncomplete - cannot double-append. The original completed row is not modified. + Returns the derived state ``open``, or ``missed`` when the instance + is past due (``due_date`` before HA-local ``today``). An + already-open instance is a no-op: nothing is appended. Unknown + ``instance_id`` raises ValueError naming the field. Existence, + latest-event, and the append run under one connection lock so a + concurrent uncomplete cannot double-append. The original completed + row is not modified. """ instance_id = _validate_int_id(instance_id, "instance_id") actor_source, actor_user_id, actor_child_id = _validate_actor( actor_source, actor_user_id, actor_child_id ) + today_date = _resolve_today(today) async with _reentrant_connection_lock(database): instance = await QuestInstancesDao(database).get_by_id(instance_id) if instance is None: @@ -408,13 +444,13 @@ async def uncomplete_instance( instance_id ) if latest is None or latest.event_type == EVENT_UNCOMPLETED: - return "open" + return _derived_state(instance, latest, today_date) if latest.event_type != EVENT_COMPLETED: raise ValueError( f"event_type must be '{EVENT_COMPLETED}' or " f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" ) - await append_event( + latest = await append_event( database, instance.id, instance.child_id, @@ -425,4 +461,35 @@ async def uncomplete_instance( was_on_time=None, now=now, ) - return "open" + return _derived_state(instance, latest, today_date) + + +async def list_missed_for_child( + database: NestQuestDatabase, + child_id: int, + range_start: str, + range_end: str, + *, + today: datetime.date | None = None, +) -> list[QuestInstanceRecord]: + """Return the child's missed instances due in the closed date range. + + Missed means the latest event state is open and ``due_date`` is + before ``today`` (HA-local, threaded). Uses + :meth:`QuestInstancesDao.list_by_date_range` plus derived state. + Never appends a missed event. Instances and latest events are + read inside the connection lock so the list is one snapshot. + """ + child_id = _validate_int_id(child_id, "child_id") + today_date = _resolve_today(today) + async with _connection_lock(database): + instances = await QuestInstancesDao(database).list_by_date_range( + child_id, range_start, range_end + ) + events = CompletionEventsDao(database) + missed: list[QuestInstanceRecord] = [] + for instance in instances: + latest = await events.get_latest_for_instance(instance.id) + if _derived_state(instance, latest, today_date) == "missed": + missed.append(instance) + return missed diff --git a/tests/test_completion.py b/tests/test_completion.py index da2493e..a5641f0 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -12,6 +12,7 @@ from custom_components.nestquest.completion import ( append_event, complete_instance, instance_state, + list_missed_for_child, uncomplete_instance, ) from custom_components.nestquest.dao_children import ChildrenDao @@ -340,6 +341,7 @@ def test_module_never_exposes_update_or_delete() -> None: assert "instance_state" in public assert "complete_instance" in public assert "uncomplete_instance" in public + assert "list_missed_for_child" in public source = inspect.getsource(completion) assert "UPDATE" not in source assert "DELETE" not in source @@ -1123,3 +1125,239 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None: await database.close() _run(_main()) + + +# --------------------------------------------------------------------------- +# missed: derived for past-due open instances, never stored +# --------------------------------------------------------------------------- + + +def test_append_event_rejects_missed_event_type() -> None: + async def _body(): + with pytest.raises(ValueError, match="event_type"): + await append_event( + object(), + 1, + 1, + "missed", + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + + _run(_body()) + + +def test_instance_state_missed_when_past_due_and_open(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "missed" + rows = await CompletionEventsDao(database).list_by_instance(instance.id) + assert rows == [] + + _with_db(tmp_path, "state-missed-open.db")(_body) + + +def test_instance_state_open_when_due_today(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + assert await instance_state(database, instance.id, today=due) == "open" + + _with_db(tmp_path, "state-open-due-today.db")(_body) + + +def test_instance_state_done_when_past_due_and_completed(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due, + ) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "done" + + _with_db(tmp_path, "state-done-past-due.db")(_body) + + +def test_instance_state_missed_after_uncompleted_when_past_due(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + ) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "missed" + + _with_db(tmp_path, "state-missed-uncompleted.db")(_body) + + +def test_instance_state_missed_uses_host_today_when_unthreaded(tmp_path) -> None: + async def _body(database, child, instance): + yesterday = datetime.date.today() - datetime.timedelta(days=1) + past = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + yesterday.isoformat(), + _now_stamp(), + window="afternoon", + today=yesterday, + ) + assert await instance_state(database, past.id) == "missed" + rows = await CompletionEventsDao(database).list_by_instance(past.id) + assert rows == [] + assert all(row.event_type != "missed" for row in rows) + + _with_db(tmp_path, "state-missed-host-today.db")(_body) + + +def test_uncomplete_past_due_done_returns_missed_not_done(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due, + ) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "done" + state = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=later, + ) + assert state == "missed" + assert await instance_state(database, instance.id, today=later) == "missed" + rows = await CompletionEventsDao(database).list_by_instance(instance.id) + assert [row.event_type for row in rows] == [ + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + ] + assert all(row.event_type != "missed" for row in rows) + + _with_db(tmp_path, "uncomplete-past-due-missed.db")(_body) + + +def test_uncomplete_past_due_already_open_is_noop_missed(tmp_path) -> None: + async def _body(database, child, instance): + later = datetime.date.fromisoformat(instance.due_date) + datetime.timedelta( + days=1 + ) + events = CompletionEventsDao(database) + assert await events.list_by_instance(instance.id) == [] + state = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=later, + ) + assert state == "missed" + assert await events.list_by_instance(instance.id) == [] + + _with_db(tmp_path, "uncomplete-past-due-noop.db")(_body) + + +def test_complete_instance_of_missed_returns_done(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "missed" + state = await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(later, 8, 0), + today=later, + ) + assert state == "done" + assert await instance_state(database, instance.id, today=later) == "done" + rows = await CompletionEventsDao(database).list_by_instance(instance.id) + assert len(rows) == 1 + assert rows[0].event_type == EVENT_COMPLETED + assert rows[0].was_on_time is False + + _with_db(tmp_path, "complete-missed.db")(_body) + + +def test_list_missed_for_child_over_date_range(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + later_due = due + datetime.timedelta(days=1) + other = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + later_due.isoformat(), + _now_stamp(), + window="morning", + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due, + ) + today = later_due + datetime.timedelta(days=1) + found = await list_missed_for_child( + database, + child.id, + instance.due_date, + later_due.isoformat(), + today=today, + ) + assert [row.id for row in found] == [other.id] + events = CompletionEventsDao(database) + assert await events.list_by_instance(other.id) == [] + done_rows = await events.list_by_instance(instance.id) + assert [row.event_type for row in done_rows] == [EVENT_COMPLETED] + assert all(row.event_type != "missed" for row in done_rows) + + _with_db(tmp_path, "list-missed-range.db")(_body) + + +def test_list_missed_for_child_empty_when_due_today(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + found = await list_missed_for_child( + database, + child.id, + instance.due_date, + instance.due_date, + today=due, + ) + assert found == [] + + _with_db(tmp_path, "list-missed-due-today.db")(_body) + + +def test_list_missed_for_child_rejects_non_integer_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="child_id"): + await list_missed_for_child(object(), "1", D1, D1) + with pytest.raises(ValueError, match="child_id"): + await list_missed_for_child(object(), True, D1, D1) + + _run(_body()) -- 2.55.0 From 0820544fce11eb2655f84e9e78d98e6764d0ff3f Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 07:12:47 -0400 Subject: [PATCH 27/28] Completion lifecycle tests (Feature 08, task 7) (#94) --- tests/test_completion.py | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_completion.py b/tests/test_completion.py index a5641f0..808096f 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -1361,3 +1361,90 @@ def test_list_missed_for_child_rejects_non_integer_id() -> None: await list_missed_for_child(object(), True, D1, D1) _run(_body()) + + +# --------------------------------------------------------------------------- +# lifecycle: complete → uncomplete → complete +# --------------------------------------------------------------------------- + + +def test_complete_uncomplete_complete_three_events_final_done( + tmp_path, +) -> None: + async def _body(database, child, instance): + due_date = datetime.date.fromisoformat(instance.due_date) + later = due_date + datetime.timedelta(days=1) + instance = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + instance.due_date, + _now_stamp(), + window=instance.window, + due_time="12:00", + ) + events = CompletionEventsDao(database) + + first = await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + now=_ha_local(due_date, 11, 0), + today=due_date, + ) + assert first == "done" + before_uncomplete = await events.list_by_instance(instance.id) + assert len(before_uncomplete) == 1 + original = before_uncomplete[0] + assert original.event_type == EVENT_COMPLETED + assert original.was_on_time is True + assert original.actor_source == "panel" + assert original.actor_user_id is None + assert original.actor_child_id == child.id + assert await instance_state(database, instance.id, today=later) == "done" + + reversed_state = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(later, 8, 0), + today=later, + ) + assert reversed_state == "missed" + assert await instance_state(database, instance.id, today=later) == "missed" + + second = await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + now=_ha_local(later, 9, 0), + today=later, + ) + assert second == "done" + assert await instance_state(database, instance.id, today=later) == "done" + + after = await events.list_by_instance(instance.id) + assert [row.event_type for row in after] == [ + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + EVENT_COMPLETED, + ] + assert after[0].id == original.id + assert after[0].occurred_at == original.occurred_at + assert after[0].actor_source == original.actor_source + assert after[0].actor_user_id == original.actor_user_id + assert after[0].actor_child_id == original.actor_child_id + assert after[0].was_on_time is True + assert after[1].actor_source == "user" + assert after[1].actor_user_id == "user-1" + assert after[1].actor_child_id is None + assert after[1].was_on_time is None + assert after[2].actor_source == "panel" + assert after[2].actor_user_id is None + assert after[2].actor_child_id == child.id + assert after[2].was_on_time is False + assert all(row.event_type != "missed" for row in after) + + _with_db(tmp_path, "lifecycle-three.db")(_body) -- 2.55.0 From 52cbf33f337496e3fd4dfe1dd63c263044102c53 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 14:14:36 -0400 Subject: [PATCH 28/28] release: advance version to 0.5.0 and add Features 06/07/08 release notes --- RELEASE_NOTES.md | 84 ++++++++++++++++++++++- custom_components/nestquest/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6de8b0c..c90e54e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,87 @@ # NestQuest Release Notes +## Version 0.5.0 — 2026-09-18 + +### Scope + +Fifth release of NestQuest, promoting Features 06, 07, and 08 from `dev` onto 0.4.0 (quest model, children registry, presence engine): + +- **Feature 06 — quest definitions and assignment:** business layer `quest_definitions.py` over the multi-assignee / multi-window model (D-008). Create/edit/deactivate definitions; assign/unassign children; query helpers for materialization. `ScheduleRule` ⇄ `schedule_rules` storage mapping in the DAO layer. +- **Feature 07 — instance materialization:** `materialize.py` fans out one instance per (definition, child, window, firing date) when the recurrence rule fires and the child is present. Idempotent on `(definition_id, child_id, due_date, window)`; skips completed instances; never generates past dates. HA-local `today` and configured `horizon_days` threaded end-to-end. Regeneration on definition/assignee/presence changes; daily rollover listener; startup backfill; `nestquest.regenerate` service. +- **Feature 08 — completion and event log:** business layer `completion.py` wrapping the existing append-only `CompletionEventsDao`. `complete_instance` / `uncomplete_instance` (reversal event, original row untouched); state derived from the latest event; HA-local `was_on_time`; derived `missed` (never stored). Actor shapes `'user'` / `'panel'` with `actor_child_id` (D-008). Permission gating of un-complete is Feature 09. + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. + +### Breaking changes + +- None at the schema layer. Schema remains **version 6** (advanced in 0.4.0). This release adds application code only. + +### Known Limitations + +- No permission decorator yet (Feature 09): `complete_instance` / `uncomplete_instance` are Python APIs, not gated HA services. The only HA service in this release is `nestquest.regenerate`. +- No entity or event surface (Feature 10): no sensors, no `nestquest_quest_completed` bus events. +- No panel or admin Lovelace cards (Features 12/13). +- Un-complete is implemented but not admin-gated until Feature 09. +- Single-instance only. + +### Rollback + +Schema version is unchanged from 0.4.0 (still 6). A code downgrade to 0.4.0 does not require a database restore for schema compatibility, but **back up `nestquest.db` (plus `-wal`/`-shm`) before upgrading** — generated instances and completion events written by 0.5.0 remain in the file after downgrade; 0.4.0 will not generate or complete them. + +**HACS installs:** + +1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.4.0). +2. Restart Home Assistant. + +**Manual installs:** + +1. Replace `custom_components/nestquest/` with the 0.4.0 tree. +2. Restart Home Assistant. + +**Repository operators:** git-revert the 0.5.0 release merge on `main`. + +## Version 0.4.0 — 2026-09-15 + +### Scope + +Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine: + +- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**. +- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users. +- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary. + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. + +### Breaking changes + +- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback. +- Dev installs from before 2026-09-15 migrate automatically on first start. + +### Known Limitations + +- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface). +- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07). +- Single-instance only. + +### Rollback + +**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated. + +**Rollback procedure (after a migrated database):** + +1. Stop Home Assistant. +2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading. +3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually. +4. Restart Home Assistant. + +Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`). + +**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database. + ## Version 0.3.0 — 2026-09-14 ### Scope @@ -126,4 +208,4 @@ v0.1.0 is the first release — there is no previous NestQuest version to downgr **Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal. -**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above. \ No newline at end of file +**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above. diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index ffef4b2..b55fe8a 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/talon2king/NestQuest/issues", "requirements": [], - "version": "0.3.0" + "version": "0.5.0" } diff --git a/pyproject.toml b/pyproject.toml index cd7c2c2..ce505c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.3.0" +version = "0.5.0" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 13bd8ec..044c8d0 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.3.0" +version = "0.5.0" source = { virtual = "." } [package.dev-dependencies] -- 2.55.0