From 77fdd2531c01005f6da88fee8de0c3c05c21875b Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 21 Sep 2026 15:04:05 -0400 Subject: [PATCH] Address PR #143 review (CHANGES_REQUIRED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- RELEASE_NOTES.md | 9 + custom_components/nestquest/core/events.py | 105 +++-- custom_components/nestquest/events.py | 40 +- tests/test_core_isolation.py | 7 +- tests/test_events.py | 432 ++++++++++++++++++++- tests/test_presence.py | 12 +- tests/test_startup_db.py | 7 +- 7 files changed, 551 insertions(+), 61 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4823457..5bd20da 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,6 +13,15 @@ logger name (e.g. in `logger.yaml` or `configuration.yaml`) must update the entry to keep that level, as the old name no longer 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 diff --git a/custom_components/nestquest/core/events.py b/custom_components/nestquest/core/events.py index ce4652e..01e7015 100644 --- a/custom_components/nestquest/core/events.py +++ b/custom_components/nestquest/core/events.py @@ -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( diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py index 702e3c5..8384333 100644 --- a/custom_components/nestquest/events.py +++ b/custom_components/nestquest/events.py @@ -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 for the events, and fires each on ``hass.bus`` with the documented 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 @@ -17,7 +26,8 @@ from zoneinfo import ZoneInfo from .core.db import NestQuestDatabase from .core.events import ( - build_quest_completed_events, + build_child_day_complete_event, + build_quest_completed_event, build_quest_uncompleted_events, ) @@ -35,21 +45,29 @@ async def fire_quest_completed( cleared the child's whole day, ``nestquest_child_day_complete``. Thin HA wiring over the HA-free - :func:`custom_components.nestquest.core.events.build_quest_completed_events`: - the HA-local ``today`` (the day-complete rule's input) is read - here from ``hass.config.time_zone``; the core builder does every - payload-shape and day-complete decision and hands back the events - to fire. Identical payloads to the pre-extraction implementation. + :func:`custom_components.nestquest.core.events.build_quest_completed_event` + and + :func:`build_child_day_complete_event`: the completed event is + built and fired FIRST (so a failure in the day-complete DB reads + 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() - events = await build_quest_completed_events( + completed = await build_quest_completed_event( database, instance_id, was_on_time=was_on_time, - today=today, ) - for event_type, payload in events: - hass.bus.async_fire(event_type, payload) + hass.bus.async_fire(*completed) + + 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( diff --git a/tests/test_core_isolation.py b/tests/test_core_isolation.py index 3ff816d..125bed9 100644 --- a/tests/test_core_isolation.py +++ b/tests/test_core_isolation.py @@ -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 ``homeassistant`` import, no relative import with level > 1, no absolute import of ``custom_components.nestquest.``, -and no bare ``import core`` / ``from core``. This guards the -storage-layer extraction (task d0b691d8) from regressing into a -HA-coupled or integration-coupled bundle. +and no bare ``import core`` / ``from core``. This guards the whole +core package — the storage-layer extraction (task d0b691d8) and the +engines/business-layer extraction (task 510c1f78) — from regressing +into a HA-coupled or integration-coupled bundle. """ from __future__ import annotations diff --git a/tests/test_events.py b/tests/test_events.py index 829f4ea..8d520f6 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -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$") -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( 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"}, context=ADMIN_CTX, ) - child = None from custom_components.nestquest.children import list_children child = (await list_children(database))[0] @@ -51,18 +62,16 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): database, "Brush teeth", ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()} + {"rule_type": "daily", "start_date": seed_date.isoformat()} ), [child.id], list(windows), ) - import datetime - - today = datetime.date.today().isoformat() - end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat() - await materialize(database, today, end, today=datetime.date.today()) + today_iso = seed_date.isoformat() + end_iso = (seed_date + datetime.timedelta(days=3)).isoformat() + await materialize(database, today_iso, end_iso, today=seed_date) instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today, today + child.id, today_iso, today_iso ) return entry, child, instances @@ -377,3 +386,408 @@ async def test_forced_refreshes_serialize_no_stale_publish( assert order == ["start", "end", "start", "end"], ( "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" + ) diff --git a/tests/test_presence.py b/tests/test_presence.py index 14db444..dd33184 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -364,11 +364,19 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> 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 source = Path( - __import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__ + __import__("custom_components.nestquest.core.presence", + fromlist=["__file__"]).__file__ ).read_text() assert "isocalendar" not in source assert "isoweek" not in source diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index d0f1ea8..323c7c3 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -442,6 +442,11 @@ def test_package_never_unlinks_or_truncates_db_files() -> None: """Guardrail: no code path may delete or overwrite an existing database file. Scans the package for unlink/rmtree/write on the 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 from pathlib import Path @@ -459,7 +464,7 @@ def test_package_never_unlinks_or_truncates_db_files() -> None: re.IGNORECASE, ) offenders: list[str] = [] - for py in sorted(package.glob("*.py")): + for py in sorted(package.rglob("*.py")): if pattern.search(py.read_text()): offenders.append(py.name) assert offenders == [], (