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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.<something other than core>``,
|
||||
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
|
||||
|
||||
|
||||
+423
-9
@@ -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"
|
||||
)
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
@@ -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 == [], (
|
||||
|
||||
Reference in New Issue
Block a user