From 5d2357ee30a7707ee6177a57af99178cdbc44e06 Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 21 Sep 2026 17:40:51 -0400 Subject: [PATCH] Tie coordinator wrapper to snapshot builder; single clock read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_snapshot now takes now ONLY and derives today = now.date() inside (P3-1): a single source of truth so the calendar date and wall-clock time can never disagree across two reads. Coordinator call site updated. The expected completed_at in tests/test_core_snapshot.py is now built from pinned_now.astimezone(datetime.timezone.utc).isoformat() instead of a literal '+00:00' UTC string (P3-2), so the assertion is timezone-independent rather than relying on the hass fixture's UTC timezone. Pin the coordinator's clock to pinned_now, run async_refresh, and assert coordinator.data == built (the frozen dataclasses make == a full deep compare) — the REQUIRED tie-in that proves the coordinator wrapper actually delegates to build_snapshot, which the literal assertions alone could not (P2). --- custom_components/nestquest/coordinator.py | 3 +-- custom_components/nestquest/core/snapshot.py | 19 ++++++++-------- tests/test_core_snapshot.py | 23 ++++++++++++++++---- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 647bbaf..5a26876 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -117,7 +117,6 @@ class NestQuestCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> NestQuestSnapshot: now = self._local_now() - today = now.date() return await build_snapshot( - self.database, self.settings, today, now + self.database, self.settings, now ) diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py index f54fe61..0236960 100644 --- a/custom_components/nestquest/core/snapshot.py +++ b/custom_components/nestquest/core/snapshot.py @@ -18,9 +18,10 @@ the panel payload shaper (:func:`instance_payload`), and the async service's panel snapshot route share ONE implementation of both the snapshot assembly and the entity-facing attribute payload shape. Nothing here imports :mod:`homeassistant`; the builder takes the -database, the settings object, the target date, and the HA-local -``now`` (a single clock read the caller already resolved) and returns -the snapshot, mirroring exactly what the coordinator used to do inline. +database, the settings object, and the HA-local ``now`` (a single +clock read the caller already resolved, from which it derives +``today``) and returns the snapshot, mirroring exactly what the +coordinator used to do inline. ``settings`` is accepted because the API route hands in the entry's :class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the @@ -158,7 +159,6 @@ def instance_payload( async def build_snapshot( database: NestQuestDatabase, settings: NestQuestSettings, - today: datetime.date, now: datetime.datetime, ) -> NestQuestSnapshot: """Build the household panel snapshot for ``today`` at the ``now`` instant. @@ -180,12 +180,13 @@ async def build_snapshot( ``now.time()`` against the instance's ``due_time`` for today's open instances. The builder performs NO internal clock read — it never calls :func:`datetime.datetime.now` — so the instant is pinned in - tests and date and time can never disagree across two reads. - ``today`` is the calendar date the caller resolved from the same - clock (``now.date()``); it anchors the due-date and cycle-day - arithmetic. ``settings`` is accepted but currently unused (see the - module docstring). + tests. ``today`` is derived INSIDE the builder as ``now.date()``, + so the calendar date that anchors the due-date and cycle-day + arithmetic is always the SAME clock read as the wall-clock time; + the two can never disagree. ``settings`` is accepted but currently + unused (see the module docstring). """ + today = now.date() today_iso = today.isoformat() children = await ChildrenDao(database).list_active() diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index 6bccf3e..e795989 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -105,7 +105,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None: ) -async def test_build_snapshot_explicit_values(hass, make_entry) -> None: +async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None: """The builder produces the documented shape as literals, pinned in time. Ada is AWAY today (a 2-week schedule whose absent week 0 covers today, @@ -189,7 +189,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry) -> None: today=today, ) - built = await build_snapshot(database, settings, today, pinned_now) + built = await build_snapshot(database, settings, pinned_now) assert isinstance(built, NestQuestSnapshot) assert built.today_iso == today_iso @@ -231,7 +231,9 @@ async def test_build_snapshot_explicit_values(hass, make_entry) -> None: assert bo_view.state == "done" assert bo_view.due_time == "17:00" assert bo_view.overdue is False, "overdue is only computed for open state" - assert bo_view.completed_at == f"{today_iso}T12:00:00+00:00" + assert bo_view.completed_at == pinned_now.astimezone( + datetime.timezone.utc + ).isoformat() assert bo_view.was_on_time is True # Panel payload shape: Ada open+overdue stays open; Bo done -> completed. @@ -263,7 +265,9 @@ async def test_build_snapshot_explicit_values(hass, make_entry) -> None: "due_time": "17:00", "state": "completed", "overdue": False, - "completed_at": f"{today_iso}T12:00:00+00:00", + "completed_at": pinned_now.astimezone( + datetime.timezone.utc + ).isoformat(), "on_time": True, } ] @@ -289,3 +293,14 @@ async def test_build_snapshot_explicit_values(hass, make_entry) -> None: instance_payload([missed_view], include_missed=True)[0]["state"] == "missed" ) + + # REQUIRED wrapper tie-in: the coordinator's _async_update_data is a + # thin WRAPPER over build_snapshot, so pinning its clock to the SAME + # pinned_now the builder was called with must reproduce the builder's + # snapshot byte-for-byte. The dataclasses are frozen, so == is a full + # deep compare — a wrapper that mis-passed today vs now, or stopped + # calling the builder, would diverge here even though the literal + # assertions above still pass. + monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now) + await coordinator.async_refresh() + assert coordinator.data == built