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
+9
View File
@@ -13,6 +13,15 @@
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
update the entry to keep that level, as the old name no longer update the entry to keep that level, as the old name no longer
receives migration log output. receives migration log output.
- **Additional logger namespace moves:** the children registry and the
admin allowlist business layers now log under
`custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist` respectively
(moved with the engines/business-layer extraction, task 510c1f78).
The parent `custom_components.nestquest` logger still catches both,
so users who tune the parent rather than the leaf names are
unaffected; only users who pinned a leaf logger name by its old
spelling need to update.
## Version 0.5.0 — 2026-09-18 ## Version 0.5.0 — 2026-09-18
+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 binary sensor's rule). ``nestquest_quest_missed`` is the documented
contract Feature 11's nightly sweep fires; nothing here builds it. 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``, Payload policy: one dict carrying ``child_id``, ``child_name``,
``instance_id``, ``quest_title``, the instance's ``window``/ ``instance_id``, ``quest_title``, the instance's ``window``/
``due_date``/``due_time`` so automation authors can filter without a ``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, database: NestQuestDatabase,
instance_id: int, instance_id: int,
*, *,
was_on_time: bool | None, was_on_time: bool | None,
today: datetime.date, ) -> tuple[str, dict]:
) -> list[tuple[str, dict]]: """Build the ``nestquest_quest_completed`` event for an actual
"""Build the transition events for an actual completion. completion.
Called by the complete_quest service handler AFTER an actual Called by the complete_quest service handler AFTER an actual
completion transition, with the completion's ``was_on_time`` — 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 the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here. could have replaced. A no-op re-complete never reaches here.
Returns a list of ``(event_type, payload)`` tuples to fire, in Returns the ``(event_type, payload)`` tuple to fire. This builder
order: always ``nestquest_quest_completed`` first, and — when the touches NO day-complete state: it only reads the instance, its
completion cleared the child's whole day — child, and its definition, so a failure in the day-complete
``nestquest_child_day_complete`` after it. The caller fires them evaluation (DB reads, timezone lookup in the shim) cannot suppress
on the bus. 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 ``today`` is the HA-local calendar date the day-complete rule is
evaluated against; the integration reads it from evaluated against; the integration reads it from
``hass.config.time_zone`` and passes it in, so this module never ``hass.config.time_zone`` and passes it in, so this module never
touches HA config. touches HA config.
""" """
now_stamp = _now_stamp() # Only a completion of TODAY'S instance evaluates the day-complete
payload = await _instance_payload(database, instance_id, now_stamp) # rule: completing a back-dated or future instance must not
payload["was_on_time"] = was_on_time # announce a cleared day.
events: list[tuple[str, dict]] = [(EVENT_QUEST_COMPLETED, payload)] if completed_payload["due_date"] != today.isoformat():
return None
# 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
instances = await QuestInstancesDao(database).list_by_date_range( 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) completion_events = CompletionEventsDao(database)
remaining = 0 remaining = 0
@@ -128,20 +165,18 @@ async def build_quest_completed_events(
) )
if derive_state(instance, latest_for_instance, today) != "done": if derive_state(instance, latest_for_instance, today) != "done":
remaining += 1 remaining += 1
if instances and remaining == 0: if not instances or remaining:
events.append( return None
( return (
EVENT_CHILD_DAY_COMPLETE, EVENT_CHILD_DAY_COMPLETE,
{ {
"child_id": payload["child_id"], "child_id": completed_payload["child_id"],
"child_name": payload["child_name"], "child_name": completed_payload["child_name"],
"quests_due": len(instances), "quests_due": len(instances),
"quests_completed": len(instances), "quests_completed": len(instances),
"occurred_at": now_stamp, "occurred_at": completed_payload["occurred_at"],
}, },
) )
)
return events
async def build_quest_uncompleted_events( async def build_quest_uncompleted_events(
+29 -11
View File
@@ -9,6 +9,15 @@ handlers call them with ``hass``), reads the HA-local timezone from
``hass.config.time_zone`` to derive ``today``, asks the core builder ``hass.config.time_zone`` to derive ``today``, asks the core builder
for the events, and fires each on ``hass.bus`` with the documented for the events, and fires each on ``hass.bus`` with the documented
payloads. The four blueprints keep working with identical payloads. payloads. The four blueprints keep working with identical payloads.
The completed event is fired BEFORE the day-complete rule evaluates:
``fire_quest_completed`` asks
:func:`build_quest_completed_event` for the completed event and fires
it immediately, THEN reads the HA-local ``today``, asks
:func:`build_child_day_complete_event` for the day-complete event, and
fires it (if any) afterwards. This restores the pre-extraction
ordering — a failure in the day-complete DB reads or the ZoneInfo
lookup cannot suppress the completed event.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,7 +26,8 @@ from zoneinfo import ZoneInfo
from .core.db import NestQuestDatabase from .core.db import NestQuestDatabase
from .core.events import ( from .core.events import (
build_quest_completed_events, build_child_day_complete_event,
build_quest_completed_event,
build_quest_uncompleted_events, build_quest_uncompleted_events,
) )
@@ -35,21 +45,29 @@ async def fire_quest_completed(
cleared the child's whole day, ``nestquest_child_day_complete``. cleared the child's whole day, ``nestquest_child_day_complete``.
Thin HA wiring over the HA-free Thin HA wiring over the HA-free
:func:`custom_components.nestquest.core.events.build_quest_completed_events`: :func:`custom_components.nestquest.core.events.build_quest_completed_event`
the HA-local ``today`` (the day-complete rule's input) is read and
here from ``hass.config.time_zone``; the core builder does every :func:`build_child_day_complete_event`: the completed event is
payload-shape and day-complete decision and hands back the events built and fired FIRST (so a failure in the day-complete DB reads
to fire. Identical payloads to the pre-extraction implementation. or the HA-local ``today`` lookup cannot suppress it), then the
HA-local ``today`` (the day-complete rule's input) is read from
``hass.config.time_zone`` and the day-complete builder is asked
whether the child's whole day cleared. Identical payloads and
day-complete rule to the pre-extraction implementation.
""" """
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() completed = await build_quest_completed_event(
events = await build_quest_completed_events(
database, database,
instance_id, instance_id,
was_on_time=was_on_time, was_on_time=was_on_time,
today=today,
) )
for event_type, payload in events: hass.bus.async_fire(*completed)
hass.bus.async_fire(event_type, payload)
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
day_complete = await build_child_day_complete_event(
database, completed[1], today=today
)
if day_complete is not None:
hass.bus.async_fire(*day_complete)
async def fire_quest_uncompleted( async def fire_quest_uncompleted(
+4 -3
View File
@@ -4,9 +4,10 @@ Every module under ``custom_components/nestquest/core/`` must be free of
Home Assistant and must not climb out of the core package: no Home Assistant and must not climb out of the core package: no
``homeassistant`` import, no relative import with level > 1, no absolute ``homeassistant`` import, no relative import with level > 1, no absolute
import of ``custom_components.nestquest.<something other than core>``, import of ``custom_components.nestquest.<something other than core>``,
and no bare ``import core`` / ``from core``. This guards the and no bare ``import core`` / ``from core``. This guards the whole
storage-layer extraction (task d0b691d8) from regressing into a core package — the storage-layer extraction (task d0b691d8) and the
HA-coupled or integration-coupled bundle. engines/business-layer extraction (task 510c1f78) — from regressing
into a HA-coupled or integration-coupled bundle.
""" """
from __future__ import annotations from __future__ import annotations
+423 -9
View File
@@ -31,7 +31,19 @@ ADMIN_CTX = {"user_id": "admin-1"}
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=None):
"""Seed a household and materialize today's instances.
``seed_date`` pins the date instances are materialized for (and the
daily rule's ``start_date``); it defaults to the host's
``datetime.date.today()``. Callers that need to test the day-complete
rule against a non-UTC HA-local timezone pass the local zone's
``today`` here so the seeded instance's ``due_date`` matches it.
"""
import datetime
if seed_date is None:
seed_date = datetime.date.today()
entry = wire_entry_to_registry( entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
) )
@@ -43,7 +55,6 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
{"action": "create", "display_name": "Ada"}, {"action": "create", "display_name": "Ada"},
context=ADMIN_CTX, context=ADMIN_CTX,
) )
child = None
from custom_components.nestquest.children import list_children from custom_components.nestquest.children import list_children
child = (await list_children(database))[0] child = (await list_children(database))[0]
@@ -51,18 +62,16 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
database, database,
"Brush teeth", "Brush teeth",
ScheduleRule.from_dict( ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()} {"rule_type": "daily", "start_date": seed_date.isoformat()}
), ),
[child.id], [child.id],
list(windows), list(windows),
) )
import datetime today_iso = seed_date.isoformat()
end_iso = (seed_date + datetime.timedelta(days=3)).isoformat()
today = datetime.date.today().isoformat() await materialize(database, today_iso, end_iso, today=seed_date)
end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
await materialize(database, today, end, today=datetime.date.today())
instances = await QuestInstancesDao(database).list_by_date_range( instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today, today child.id, today_iso, today_iso
) )
return entry, child, instances return entry, child, instances
@@ -377,3 +386,408 @@ async def test_forced_refreshes_serialize_no_stale_publish(
assert order == ["start", "end", "start", "end"], ( assert order == ["start", "end", "start", "end"], (
"overlapping refreshes must serialize: " + str(order) "overlapping refreshes must serialize: " + str(order)
) )
# ---------------------------------------------------------------------------
# Direct core-builder unit tests (P2.2a): exact payload key sets, event
# order, and was_on_time threading. These target
# custom_components.nestquest.core.events directly, bypassing the shim,
# so a payload-shape regression is caught even if the shim masks it.
# ---------------------------------------------------------------------------
_COMPLETED_KEYS = {
"child_id",
"child_name",
"instance_id",
"quest_title",
"window",
"due_date",
"due_time",
"occurred_at",
"was_on_time",
}
_UNCOMPLETED_KEYS = {
"child_id",
"child_name",
"instance_id",
"quest_title",
"window",
"due_date",
"due_time",
"occurred_at",
}
_DAY_COMPLETE_KEYS = {
"child_id",
"child_name",
"quests_due",
"quests_completed",
"occurred_at",
}
async def test_build_quest_completed_event_payload_key_set(hass, make_entry) -> None:
"""The completed-event payload carries exactly the documented keys
(no extra fields, no missing field) and threads ``was_on_time``."""
from custom_components.nestquest.core.events import build_quest_completed_event
entry, child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
event_type, payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
assert event_type == EVENT_QUEST_COMPLETED
assert set(payload) == _COMPLETED_KEYS, (
f"completed payload keys drift: got {set(payload)} expected {_COMPLETED_KEYS}"
)
assert payload["was_on_time"] is True
assert payload["child_id"] == child.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == instances[0].id
assert payload["quest_title"] == "Brush teeth"
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
async def test_build_quest_completed_event_was_on_time_threads_all_shapes(
hass, make_entry
) -> None:
"""``was_on_time`` is carried verbatim — True, False, and None
(the unknown-timing shape) all round-trip into the payload."""
from custom_components.nestquest.core.events import build_quest_completed_event
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
for wanted in (True, False, None):
_etype, payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=wanted
)
assert payload["was_on_time"] is wanted, (
f"was_on_time={wanted!r} did not thread through"
)
async def test_build_quest_uncompleted_events_payload_key_set(
hass, make_entry
) -> None:
"""The uncompleted-event payload carries exactly the documented keys
(no ``was_on_time`` — that field is completion-only)."""
from custom_components.nestquest.core.events import (
build_quest_uncompleted_events,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
events = await build_quest_uncompleted_events(database, instances[0].id)
assert len(events) == 1
event_type, payload = events[0]
assert event_type == EVENT_QUEST_UNCOMPLETED
assert set(payload) == _UNCOMPLETED_KEYS, (
f"uncompleted payload keys drift: got {set(payload)} "
f"expected {_UNCOMPLETED_KEYS}"
)
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
async def test_build_child_day_complete_event_payload_key_set(
hass, make_entry
) -> None:
"""The day-complete payload carries exactly the documented keys
when the child's whole day clears, and reuses the completed
event's ``occurred_at`` stamp (one transition, one timestamp)."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
completed_type, completed_payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
# Mark every today-instance done so the day clears.
from custom_components.nestquest.completion import complete_instance
for instance in instances:
await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=_child.id,
today=today,
)
day_event = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert day_event is not None
event_type, day_payload = day_event
assert event_type == EVENT_CHILD_DAY_COMPLETE
assert set(day_payload) == _DAY_COMPLETE_KEYS, (
f"day-complete payload keys drift: got {set(day_payload)} "
f"expected {_DAY_COMPLETE_KEYS}"
)
assert day_payload["occurred_at"] == completed_payload["occurred_at"], (
"day-complete must reuse the completed event's occurred_at stamp"
)
assert day_payload["quests_due"] == len(instances)
assert day_payload["quests_completed"] == len(instances)
async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> None:
"""The shim fires ``nestquest_quest_completed`` BEFORE
``nestquest_child_day_complete`` (the pre-extraction ordering), so
a failure in the day-complete DB reads cannot suppress the
completed event. Asserted by bus event ORDER, not just counts."""
entry, child, instances = await _setup_and_seed(hass, make_entry)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
ordered = [
etype for etype, _payload in hass.bus.events
if etype in (EVENT_QUEST_COMPLETED, EVENT_CHILD_DAY_COMPLETE)
]
# The completed event must precede the day-complete event.
first_completed = ordered.index(EVENT_QUEST_COMPLETED)
first_day = ordered.index(EVENT_CHILD_DAY_COMPLETE)
assert first_completed < first_day, (
f"completed must fire before day-complete; order was {ordered}"
)
# ---------------------------------------------------------------------------
# Day-complete rule coverage (P2.2b): an explicit ``today`` proves the
# four outcomes — zero-quest day, non-today instance, partial day, and
# fully-cleared today.
# ---------------------------------------------------------------------------
async def test_day_complete_zero_quest_day_yields_no_event(
hass, make_entry
) -> None:
"""A zero-quest day never fires day-complete: when the completed
payload's ``due_date`` matches ``today`` but the child has NO
instances due that date, the builder returns ``None`` (the
all-done binary sensor's rule — a day with zero quests owed can
never be "cleared")."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
# A date the daily rule does NOT cover (well past the materialized
# horizon) — zero instances due this date.
zero_quest_day = datetime.date.today() + datetime.timedelta(days=365)
_etype, completed_payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
# Override due_date to the zero-quest day so the builder evaluates
# it (otherwise the due_date != today short-circuit fires first).
crafted = dict(completed_payload, due_date=zero_quest_day.isoformat())
result = await build_child_day_complete_event(
database, crafted, today=zero_quest_day
)
assert result is None, (
"a zero-quest day must not yield a day-complete event"
)
async def test_day_complete_non_today_instance_yields_no_event(
hass, make_entry
) -> None:
"""Completing an instance NOT due today never evaluates the
child's day-complete for today: only the completed event fires."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
# Complete a FUTURE instance (tomorrow), pass today = today.
from custom_components.nestquest.dao_instances import QuestInstancesDao
today = datetime.date.today()
end = (today + datetime.timedelta(days=3)).isoformat()
all_instances = await QuestInstancesDao(database).list_by_date_range(
_child.id, today.isoformat(), end
)
today_ids = {i.id for i in instances}
tomorrow_instance = next(i for i in all_instances if i.id not in today_ids)
_etype, completed_payload = await build_quest_completed_event(
database, tomorrow_instance.id, was_on_time=True
)
assert completed_payload["due_date"] != today.isoformat()
result = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert result is None, (
"a non-today instance must not yield a day-complete event for today"
)
async def test_day_complete_partial_day_yields_no_event(
hass, make_entry
) -> None:
"""A partial day (some quests still open) yields only the
completed event — day-complete does not fire until the WHOLE day
clears."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
from custom_components.nestquest.completion import complete_instance
entry, child, instances = await _setup_and_seed(
hass, make_entry, windows=("morning", "evening")
)
assert len(instances) == 2
database = entry.runtime_data.database
today = datetime.date.today()
# Complete ONLY the morning instance — the evening one stays open.
await complete_instance(
database,
instances[0].id,
actor_source="panel",
actor_child_id=child.id,
today=today,
)
_etype, completed_payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
result = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert result is None, (
"a partial day (evening quest still open) must not fire day-complete"
)
async def test_day_complete_fully_cleared_today_yields_event(
hass, make_entry
) -> None:
"""A fully-cleared today (all owed quests done) yields the
completed event AND the child_day_complete event."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
from custom_components.nestquest.completion import complete_instance
entry, child, instances = await _setup_and_seed(
hass, make_entry, windows=("morning", "evening")
)
assert len(instances) == 2
database = entry.runtime_data.database
today = datetime.date.today()
for instance in instances:
await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=child.id,
today=today,
)
# The last completion's payload drives the day-complete evaluation.
_etype, completed_payload = await build_quest_completed_event(
database, instances[-1].id, was_on_time=True
)
result = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert result is not None, (
"a fully-cleared today must yield the day-complete event"
)
assert result[0] == EVENT_CHILD_DAY_COMPLETE
assert result[1]["quests_due"] == len(instances)
assert result[1]["quests_completed"] == len(instances)
# ---------------------------------------------------------------------------
# Shim timezone wiring (P2.2c): the conftest fake hass defaults
# hass.config.time_zone to UTC, so the day-complete rule's HA-local
# ``today`` wiring is otherwise untested. This test sets the zone to
# one whose current local date differs from UTC and asserts the
# day-complete rule uses THAT local date, not UTC.
# ---------------------------------------------------------------------------
async def test_shim_day_complete_uses_ha_local_timezone(
hass, make_entry
) -> None:
"""The day-complete rule reads ``hass.config.time_zone`` and
derives ``today`` in HA-local time, not UTC.
Picks a zone whose current local date differs from UTC, seeds an
instance for THAT zone's today (not UTC's today), completes it
through the service path, and asserts
``nestquest_child_day_complete`` fires — proving the local date
reached the day-complete rule. If no candidate zone has a
different date right now (only possible inside the ~1h window
around 12:00 UTC when all zones share one date), the test skips
rather than run vacuously.
"""
import datetime as _dt
from zoneinfo import ZoneInfo
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
far_zone = None
far_today = None
for tz_name in (
"Pacific/Kiritimati", # +14 (UTC+14)
"Pacific/Auckland", # +12/+13
"Pacific/Chatham", # +12:45/+13:45
"Pacific/Honolulu", # -10
"America/Los_Angeles", # -8/-7
"Etc/GMT+12", # -12
"Etc/GMT-13", # +13
):
local_today = _dt.datetime.now(ZoneInfo(tz_name)).date()
if local_today != utc_today:
far_zone = tz_name
far_today = local_today
break
if far_zone is None:
import pytest
pytest.skip(
"no candidate zone has a date different from UTC right now"
)
hass.config.time_zone = far_zone
entry, child, instances = await _setup_and_seed(
hass, make_entry, seed_date=far_today
)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
assert len(day_complete) == 1, (
f"day-complete must fire under {far_zone} (local today "
f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); "
f"got {len(day_complete)} events"
)
+10 -2
View File
@@ -364,11 +364,19 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
def test_module_uses_no_iso_week_functions() -> None: def test_module_uses_no_iso_week_functions() -> None:
"""D-004 guardrail: cycle math never reads ISO week numbers.""" """D-004 guardrail: cycle math never reads ISO week numbers.
Scanned against ``custom_components.nestquest.core.presence`` (the
real presence model since task 510c1f78); the integration's
``presence.py`` is now a re-export shim, so scanning the shim is
vacuous. Mirrors ``test_recurrence_model.py``'s scan of
``core.recurrence``.
"""
from pathlib import Path from pathlib import Path
source = Path( source = Path(
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__ __import__("custom_components.nestquest.core.presence",
fromlist=["__file__"]).__file__
).read_text() ).read_text()
assert "isocalendar" not in source assert "isocalendar" not in source
assert "isoweek" not in source assert "isoweek" not in source
+6 -1
View File
@@ -442,6 +442,11 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
"""Guardrail: no code path may delete or overwrite an existing """Guardrail: no code path may delete or overwrite an existing
database file. Scans the package for unlink/rmtree/write on the database file. Scans the package for unlink/rmtree/write on the
DB filename constant or *.db patterns. DB filename constant or *.db patterns.
Recursive (``rglob``) so the bundled ``core/`` package (extracted
in task d0b691d8 / 510c1f78) is scanned alongside the integration's
own modules — a non-recursive ``glob`` skips ``core/`` and leaves
the guardrail vacuous against it.
""" """
import re import re
from pathlib import Path from pathlib import Path
@@ -459,7 +464,7 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
re.IGNORECASE, re.IGNORECASE,
) )
offenders: list[str] = [] offenders: list[str] = []
for py in sorted(package.glob("*.py")): for py in sorted(package.rglob("*.py")):
if pattern.search(py.read_text()): if pattern.search(py.read_text()):
offenders.append(py.name) offenders.append(py.name)
assert offenders == [], ( assert offenders == [], (