Tie coordinator wrapper to snapshot builder; single clock read
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).
This commit is contained in:
@@ -117,7 +117,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||||
now = self._local_now()
|
now = self._local_now()
|
||||||
today = now.date()
|
|
||||||
return await build_snapshot(
|
return await build_snapshot(
|
||||||
self.database, self.settings, today, now
|
self.database, self.settings, now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
service's panel snapshot route share ONE implementation of both the
|
||||||
snapshot assembly and the entity-facing attribute payload shape.
|
snapshot assembly and the entity-facing attribute payload shape.
|
||||||
Nothing here imports :mod:`homeassistant`; the builder takes the
|
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||||
database, the settings object, the target date, and the HA-local
|
database, the settings object, and the HA-local ``now`` (a single
|
||||||
``now`` (a single clock read the caller already resolved) and returns
|
clock read the caller already resolved, from which it derives
|
||||||
the snapshot, mirroring exactly what the coordinator used to do inline.
|
``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
|
``settings`` is accepted because the API route hands in the entry's
|
||||||
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
||||||
@@ -158,7 +159,6 @@ def instance_payload(
|
|||||||
async def build_snapshot(
|
async def build_snapshot(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
settings: NestQuestSettings,
|
settings: NestQuestSettings,
|
||||||
today: datetime.date,
|
|
||||||
now: datetime.datetime,
|
now: datetime.datetime,
|
||||||
) -> NestQuestSnapshot:
|
) -> NestQuestSnapshot:
|
||||||
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
|
"""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
|
``now.time()`` against the instance's ``due_time`` for today's open
|
||||||
instances. The builder performs NO internal clock read — it never
|
instances. The builder performs NO internal clock read — it never
|
||||||
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
||||||
tests and date and time can never disagree across two reads.
|
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
|
||||||
``today`` is the calendar date the caller resolved from the same
|
so the calendar date that anchors the due-date and cycle-day
|
||||||
clock (``now.date()``); it anchors the due-date and cycle-day
|
arithmetic is always the SAME clock read as the wall-clock time;
|
||||||
arithmetic. ``settings`` is accepted but currently unused (see the
|
the two can never disagree. ``settings`` is accepted but currently
|
||||||
module docstring).
|
unused (see the module docstring).
|
||||||
"""
|
"""
|
||||||
|
today = now.date()
|
||||||
today_iso = today.isoformat()
|
today_iso = today.isoformat()
|
||||||
|
|
||||||
children = await ChildrenDao(database).list_active()
|
children = await ChildrenDao(database).list_active()
|
||||||
|
|||||||
@@ -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.
|
"""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,
|
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,
|
today=today,
|
||||||
)
|
)
|
||||||
|
|
||||||
built = await build_snapshot(database, settings, today, pinned_now)
|
built = await build_snapshot(database, settings, pinned_now)
|
||||||
|
|
||||||
assert isinstance(built, NestQuestSnapshot)
|
assert isinstance(built, NestQuestSnapshot)
|
||||||
assert built.today_iso == today_iso
|
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.state == "done"
|
||||||
assert bo_view.due_time == "17:00"
|
assert bo_view.due_time == "17:00"
|
||||||
assert bo_view.overdue is False, "overdue is only computed for open state"
|
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
|
assert bo_view.was_on_time is True
|
||||||
|
|
||||||
# Panel payload shape: Ada open+overdue stays open; Bo done -> completed.
|
# 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",
|
"due_time": "17:00",
|
||||||
"state": "completed",
|
"state": "completed",
|
||||||
"overdue": False,
|
"overdue": False,
|
||||||
"completed_at": f"{today_iso}T12:00:00+00:00",
|
"completed_at": pinned_now.astimezone(
|
||||||
|
datetime.timezone.utc
|
||||||
|
).isoformat(),
|
||||||
"on_time": True,
|
"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"]
|
instance_payload([missed_view], include_missed=True)[0]["state"]
|
||||||
== "missed"
|
== "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
|
||||||
|
|||||||
Reference in New Issue
Block a user