Address PR #143 review (CHANGES_REQUIRED)

P2.1: Point test_presence.py ISO-week scan at core.presence (the real
model since task 510c1f78); the integration's presence.py is now a
re-export shim, so scanning it was vacuous.

P2.2: Add direct core-builder unit tests to test_events.py — exact
payload key sets for completed/uncompleted/day-complete events,
was_on_time threading (True/False/None), event ordering (completed
fires before day-complete on the bus), and the four day-complete
outcomes (zero-quest day, non-today instance, partial day,
fully-cleared today). Add a shim timezone test that sets
hass.config.time_zone to a zone whose local date differs from UTC and
asserts the day-complete rule uses that local date.

P3.1: Split build_quest_completed_events into
build_quest_completed_event (single event, no day-complete state) and
build_child_day_complete_event (separate, returns the day-complete
event or None). The shim fires nestquest_quest_completed BEFORE it
reads the HA-local today and evaluates the day-complete rule,
restoring the pre-extraction ordering so a failure in the day-complete
DB reads or the ZoneInfo lookup cannot suppress the completed event.
Payloads and the day-complete rule are identical.

P3.2: test_startup_db.py glob -> rglob so core/ is scanned by the
'never unlinks or truncates DB files' guardrail (non-recursive glob
skipped the extracted core package).

P3.3: RELEASE_NOTES.md notes the children and admin_allowlist loggers
moved to .core.children / .core.admin_allowlist (parent logger still
catches them).

P3.4: Refresh the stale test_core_isolation.py docstring to cover the
whole core package (storage-layer task d0b691d8 + engines/business-
layer task 510c1f78).

uv run pytest -q = 1507 passed (baseline 1497 + 10 new).
This commit is contained in:
2026-09-21 15:04:05 -04:00
parent 08cb31ce4c
commit 77fdd2531c
7 changed files with 551 additions and 61 deletions
+70 -35
View File
@@ -16,6 +16,19 @@ remain — a zero-quest day can never fire it, matching the all-done
binary sensor's rule). ``nestquest_quest_missed`` is the documented
contract Feature 11's nightly sweep fires; nothing here builds it.
The completed-event and day-complete-event builders are split so the
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
day-complete rule (the pre-extraction implementation fired the
completed event on the bus first, then ran the day-complete DB reads;
a failure in those reads or the ZoneInfo lookup must not suppress the
completed event). :func:`build_quest_completed_event` builds the
completed event from the instance and its child/definition only;
:func:`build_child_day_complete_event` runs the day-complete DB reads
and returns the day-complete event (or ``None``). The shim fires the
completed event, then calls the day-complete builder and fires its
result. :func:`build_quest_uncompleted_events` builds the single
uncompleted event.
Payload policy: one dict carrying ``child_id``, ``child_name``,
``instance_id``, ``quest_title``, the instance's ``window``/
``due_date``/``due_time`` so automation authors can filter without a
@@ -80,14 +93,14 @@ async def _instance_payload(
}
async def build_quest_completed_events(
async def build_quest_completed_event(
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
today: datetime.date,
) -> list[tuple[str, dict]]:
"""Build the transition events for an actual completion.
) -> tuple[str, dict]:
"""Build the ``nestquest_quest_completed`` event for an actual
completion.
Called by the complete_quest service handler AFTER an actual
completion transition, with the completion's ``was_on_time`` —
@@ -95,30 +108,54 @@ async def build_quest_completed_events(
the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here.
Returns a list of ``(event_type, payload)`` tuples to fire, in
order: always ``nestquest_quest_completed`` first, and — when the
completion cleared the child's whole day —
``nestquest_child_day_complete`` after it. The caller fires them
on the bus.
Returns the ``(event_type, payload)`` tuple to fire. This builder
touches NO day-complete state: it only reads the instance, its
child, and its definition, so a failure in the day-complete
evaluation (DB reads, timezone lookup in the shim) cannot suppress
the completed event. The caller fires this BEFORE asking
:func:`build_child_day_complete_event` for the day-complete event.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
return (EVENT_QUEST_COMPLETED, payload)
async def build_child_day_complete_event(
database: NestQuestDatabase,
completed_payload: dict,
*,
today: datetime.date,
) -> tuple[str, dict] | None:
"""Build the ``nestquest_child_day_complete`` event for a
completion that cleared the child's whole day, or ``None`` when no
day-complete event should fire.
Called by the complete_quest service handler AFTER the
``nestquest_quest_completed`` event has already been fired, with
that event's payload (so this builder reuses the same
``occurred_at`` stamp and the same ``child_id`` / ``child_name``
the completed event carried — one transition, one shared
timestamp, matching the pre-extraction implementation).
Day-complete rule (identical to the pre-extraction implementation
and to the all-done binary sensor): quests were owed today and
none remain — a zero-quest day can never fire it. Only a
completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is
evaluated against; the integration reads it from
``hass.config.time_zone`` and passes it in, so this module never
touches HA config.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
events: list[tuple[str, dict]] = [(EVENT_QUEST_COMPLETED, payload)]
# Day-complete: quests were owed today and none remain (the same
# rule as the all-done binary sensor; a zero-quest day never fires).
# Only a completion of TODAY'S instance evaluates it: completing a
# back-dated or future instance must not announce a cleared day.
if payload["due_date"] != today.isoformat():
return events
# Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not
# announce a cleared day.
if completed_payload["due_date"] != today.isoformat():
return None
instances = await QuestInstancesDao(database).list_by_date_range(
payload["child_id"], today.isoformat(), today.isoformat()
completed_payload["child_id"], today.isoformat(), today.isoformat()
)
completion_events = CompletionEventsDao(database)
remaining = 0
@@ -128,20 +165,18 @@ async def build_quest_completed_events(
)
if derive_state(instance, latest_for_instance, today) != "done":
remaining += 1
if instances and remaining == 0:
events.append(
(
EVENT_CHILD_DAY_COMPLETE,
{
"child_id": payload["child_id"],
"child_name": payload["child_name"],
"quests_due": len(instances),
"quests_completed": len(instances),
"occurred_at": now_stamp,
},
)
)
return events
if not instances or remaining:
return None
return (
EVENT_CHILD_DAY_COMPLETE,
{
"child_id": completed_payload["child_id"],
"child_name": completed_payload["child_name"],
"quests_due": len(instances),
"quests_completed": len(instances),
"occurred_at": completed_payload["occurred_at"],
},
)
async def build_quest_uncompleted_events(