Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 64aa8e99f6 Fix premature OVERDUE on the API and the Complete tap doing nothing
Overdue: the API read the host clock (the deployed container runs UTC,
the household is US Eastern), so every morning quest turned overdue
from ~04:00 local, and the snapshot routes built a default settings
object instead of the stored one. Add an additive `timezone` setting
(IANA name; "" keeps the host-local behaviour), validated through the
shared settings field table and exposed on the admin settings GET/PATCH.
Every API clock read (panel/admin routes, the missed-sweep scheduler)
now re-expresses the one host read in that zone, and the snapshot
routes load the persisted settings.

Complete: with target ES2022, TypeScript's useDefineForClassFields
default emitted the cards' reactive fields as native class fields that
shadow Lit's accessors, so setting `_confirm` never re-rendered and the
confirm dialog never opened. Set useDefineForClassFields to false (Lit's
documented TypeScript setting) and rebuild the bundle.

Tests pin the clock at the due-time boundary for both snapshot shapes,
cover the scheduler and on-time stamp, and drive the built bundle's
Complete tap through to the complete_quest service call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:29:24 -04:00
12 changed files with 792 additions and 353 deletions
+41 -13
View File
@@ -867,7 +867,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all TEN documented fields, resolved (every absent stored field
— all ELEVEN documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with.
"""
@@ -882,6 +882,9 @@ class AdminSettingsResponse(BaseModel):
afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool
celebration_enabled: bool
#: The household IANA time zone the API reads its clock in; ``""``
#: means the API host's local time.
timezone: str
class AdminMissedSweepResponse(BaseModel):
@@ -1178,6 +1181,26 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
async def _household_today(database: object) -> datetime.date:
"""Return the household-local date of ONE :func:`_household_clock` read."""
_settings, now = await _household_clock(database)
return now.date()
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
"""Build the core ScheduleRule from the request's ``rule`` object.
@@ -1496,7 +1519,7 @@ async def admin_create_quest_definition(
response_model=AdminOccurrencesPreviewResponse,
)
async def admin_preview_occurrences(
body: AdminOccurrencesPreviewRequest,
body: AdminOccurrencesPreviewRequest, request: Request
) -> AdminOccurrencesPreviewResponse:
"""Return the first ``count`` dates the supplied rule fires on.
@@ -1509,12 +1532,15 @@ async def admin_preview_occurrences(
call over the bounded window ``[start_date, start_date + 366
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
read — truncated to ``count``. Every core ``ValueError`` is 422
(:func:`_raise_preview_error`). No database access.
(:func:`_raise_preview_error`). The only database access is the
settings read behind the household clock.
"""
start_date = (
body.start_date
if body.start_date is not None
else _local_now().date().isoformat()
else (
await _household_today(request.app.state.db.database)
).isoformat()
)
try:
rule = _rule_from_request(body.rule)
@@ -1839,7 +1865,7 @@ async def admin_presence_override_consequence(
body.start_date,
body.end_date,
body.is_present,
today=_local_now().date(),
today=await _household_today(state.database),
)
)
except ValueError as error:
@@ -1926,7 +1952,7 @@ async def admin_uncomplete_instance(
"""
state: DatabaseState = request.app.state.db
database = state.database
now = _local_now()
_settings, now = await _household_clock(database)
sub = claims.get("sub")
try:
result = await core_completion.uncomplete_instance(
@@ -1992,7 +2018,7 @@ async def admin_regenerate(
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
today = await _household_today(state.database)
if body.scope == "household":
end_date = today + datetime.timedelta(
@@ -2077,7 +2103,7 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the ten documented fields even if the core
stays pinned to the eleven documented fields even if the core
dataclass later grows another one.
"""
return AdminSettingsResponse(
@@ -2091,6 +2117,7 @@ def _settings_response(
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone,
)
@@ -2122,7 +2149,7 @@ async def admin_history_query(
``ValueError`` is 422 (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
today = await _household_today(state.database)
try:
rows = await core_history.query_history(
state.database, filter, start, end, today=today
@@ -2165,7 +2192,7 @@ async def admin_history_csv(
JSON route's (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
today = await _household_today(state.database)
try:
csv_text = await core_history.export_history_csv(
state.database, filter, start, end, today=today
@@ -2188,7 +2215,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse,
)
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all ten documented fields).
"""Return the current effective settings (all eleven documented fields).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
@@ -2280,7 +2307,7 @@ async def admin_run_missed_sweep(
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
today = await _household_today(state.database)
try:
events = await core_sweep.run_missed_sweep(database, today=today)
except ValueError as error:
@@ -2309,8 +2336,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
+21 -6
View File
@@ -8,9 +8,9 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the API host's local time — the
API is the household's "local" host now that the domain no longer lives
inside Home Assistant), calls the pure
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
stored household ``timezone`` setting — the API host may run UTC while
the household does not), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -63,7 +63,7 @@ from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_settings_store,
core_snapshot,
)
from api import transitions as api_transitions
@@ -178,6 +178,20 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
@router.get(
"/snapshot",
summary="Today's household snapshot",
@@ -196,8 +210,9 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return PanelSnapshotResponse(
today_iso=snapshot.today_iso,
@@ -282,7 +297,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child",
)
now = _local_now()
_settings, now = await _household_clock(database)
try:
result = await core_completion.complete_instance(
database,
+10 -3
View File
@@ -8,7 +8,8 @@ configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
household-local date (the stored ``timezone`` setting; the API host's
local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
@@ -130,7 +131,11 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
# The host may run UTC: the rollover is a household wall-clock
# time, so the clock read is re-expressed in the stored zone.
delay = _delay_seconds(
rollover, settings.household_now(self._clock())
)
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
@@ -138,7 +143,9 @@ class MissedSweepScheduler:
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
@@ -112,6 +112,13 @@ CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string keeps the historical
# behaviour — the API host's own local time — so an existing install is
# unchanged until the owner sets it. The integration itself always uses
# Home Assistant's configured ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = ""
# Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database.
@@ -35,6 +35,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .const import (
CONF_AFTERNOON_REMINDER_ENABLED,
@@ -47,12 +48,14 @@ from .const import (
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_TIMEZONE,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_TIMEZONE,
LOGGER,
TIME_PATTERN,
)
@@ -153,6 +156,29 @@ def _resolve_optional_str(value: Any) -> str:
return value.strip()
def _resolve_timezone(value: Any) -> str:
"""Validate a household time zone and return it.
The empty string means "the API host's local time" (the historical
behaviour); anything else must be an IANA key :class:`ZoneInfo`
resolves (e.g. ``America/New_York``). A non-str (including
``None``) is rejected; the ``None``-as-absent mapping lives ONLY in
:meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
"""
if not isinstance(value, str):
raise ValueError(f"timezone must be a string, got {value!r}")
if value == "":
return value
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as error:
raise ValueError(
f"timezone must be an IANA time zone name such as "
f"'America/New_York', got {value!r}"
) from error
return value
#: The single field table the two builders share. Each row is
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
#: applies every validator (raising on the first invalid value) while
@@ -214,6 +240,7 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
)
@@ -243,6 +270,7 @@ class NestQuestSettings:
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
timezone: str = DEFAULT_TIMEZONE
def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance
@@ -281,6 +309,21 @@ class NestQuestSettings:
raise ValueError(
f"{name} must be a boolean, got {value!r}"
)
_resolve_timezone(self.timezone)
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone.
The API service has no Home Assistant ``time_zone``, so its one
clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set the SAME instant is re-expressed in that
zone, so ``.date()`` and ``.time()`` are the household's wall
clock even when the host runs UTC. With :attr:`timezone` empty
the instant is returned unchanged (the host's local time).
"""
if not self.timezone:
return instant
return instant.astimezone(ZoneInfo(self.timezone))
@property
def day_rollover_hour_minute(self) -> tuple[int, int]:
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TEN settings fields, keyed by field name
object holding exactly the ELEVEN settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
@@ -30,7 +30,10 @@ after the first update:
core's normalizer),
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
``end_of_day_report_enabled``, ``celebration_enabled`` — real
booleans.
booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
@@ -172,7 +175,7 @@ async def update_settings(
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, ten-field) document is persisted
settings and the merged (complete, eleven-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -10,7 +10,10 @@
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* probe_ms was given. With tap_complete: <instance id> the harness taps that
* card's Complete button, records whether the confirm dialog opened, taps
* the dialog's Complete, and reports every hass.callService call as
* {dialog_opened, service_calls}. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
@@ -43,12 +46,34 @@ globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const serviceCalls = [];
const hass = {
...spec.hass,
callService(domain, service, data) {
serviceCalls.push({ domain, service, data });
return Promise.resolve();
},
};
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
element.hass = hass;
document.body.appendChild(element);
await element.updateComplete;
let tap = null;
if (spec.tap_complete !== undefined) {
element.shadowRoot
.querySelector(`.quest[data-instance-id="${spec.tap_complete}"] button.complete`)
.click();
await element.updateComplete;
const confirm = element.shadowRoot.querySelector("button.do-complete");
confirm?.click();
await element.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 0));
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
}
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
@@ -68,6 +93,6 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({ ...snapshot(), probe });
const payload = JSON.stringify({ ...snapshot(), probe, tap });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
+5 -4
View File
@@ -13,7 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings
— the ten documented fields at their defaults on a fresh database.
— the eleven documented fields at their defaults on a fresh database.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value),
@@ -60,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
SETTINGS_META_KEY = "settings"
#: The all-defaults effective settings a fresh database reports — the
#: documented ten fields, asserted literally so the payload contract is
#: documented eleven fields, asserted literally so the payload contract is
#: pinned here, independent of the core constants.
DEFAULT_SETTINGS = {
"horizon_days": 14,
@@ -73,6 +73,7 @@ DEFAULT_SETTINGS = {
"afternoon_reminder_enabled": True,
"end_of_day_report_enabled": True,
"celebration_enabled": True,
"timezone": "",
}
@@ -103,7 +104,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace,
) -> None:
"""GET returns the ten documented fields at their defaults."""
"""GET returns the eleven documented fields at their defaults."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
@@ -139,7 +140,7 @@ async def test_update_persists_and_the_next_read_reflects_it(
assert reread.status_code == 200
assert reread.json() == expected
# ...and the DATABASE holds the merged document: the complete ten
# ...and the DATABASE holds the merged document: the complete eleven
# fields, keyed by field name, under the well-known meta_state key.
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
+272
View File
@@ -0,0 +1,272 @@
"""Household-local clock tests for the API service (task fa7475ba).
The deployed API container runs UTC while the household is US Eastern,
so reading the HOST clock marked every morning quest overdue from the
small hours. The API now re-expresses its ONE host clock read in the
stored ``timezone`` setting (``NestQuestSettings.household_now``).
Every test pins the host clock to a UTC instant (the deployed
container's zone) and sets the household zone through the real admin
``PATCH /api/v1/admin/settings`` route, then reads BOTH snapshot shapes:
- one second before the 10:00 due time: NOT overdue (previously
flagged, because 13:59:59 UTC > 10:00);
- exactly at 10:00:00: NOT overdue (the builder's strict
``now.time() > due`` convention);
- one second after: overdue;
- 23:30 household time, already the next day in UTC: ``today_iso`` is
still the household's day.
An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time.
"""
from __future__ import annotations
import asyncio
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.database import _executor
from api.scheduler import MissedSweepScheduler
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
_settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations")
#: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
UTC = ZoneInfo("UTC")
HOUSEHOLD_ZONE = "America/New_York"
#: A fixed household day on Eastern Daylight Time (UTC-4).
TODAY = datetime.date(2026, 9, 26)
def _utc(hour: int, minute: int, second: int = 0, *, day: int = 26):
"""An aware UTC instant on September ``day`` 2026."""
return datetime.datetime(2026, 9, day, hour, minute, second, tzinfo=UTC)
def _admin_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""The seeded household on the admin-plane app, host clock pinnable.
Ada's open "Pack bag" quest is due 10:00 on :data:`TODAY`. Both
routes' host clock read returns ``clock["now"]`` (a UTC instant).
"""
seed = await _seed_household(temp_db_path, TODAY, _utc(16, 0))
clock = {"now": _utc(12, 0)}
monkeypatch.setattr(routes_admin, "_local_now", lambda: clock["now"])
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(client=client, seed=seed, clock=clock)
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone"] == zone
async def _pack_bag(household: SimpleNamespace, plane: str) -> tuple[str, dict]:
"""Return ``(today_iso, Ada's Pack bag instance)`` from one snapshot."""
headers = _admin_headers() if plane == "admin" else _panel_headers()
response = await household.client.get(
f"/api/v1/{plane}/snapshot", headers=headers
)
assert response.status_code == 200
body = response.json()
ada = next(
child
for child in body["children"]
if child["child_id"] == household.seed.ada.id
)
(instance,) = ada["instances"]
assert instance["title"] == "Pack bag"
assert instance["due_time"] == "10:00"
return body["today_iso"], instance
@pytest.mark.parametrize("plane", ["panel", "admin"])
@pytest.mark.parametrize(
("host_now", "overdue"),
[
# 09:59:59 EDT — the pre-fix code compared 13:59:59 (UTC) > 10:00.
(_utc(13, 59, 59), False),
# 10:00:00 EDT exactly — strict ``>``: not yet overdue.
(_utc(14, 0, 0), False),
# 10:00:01 EDT — overdue.
(_utc(14, 0, 1), True),
],
ids=["before-due", "at-due", "after-due"],
)
async def test_overdue_boundary_uses_household_zone(
household: SimpleNamespace,
plane: str,
host_now: datetime.datetime,
overdue: bool,
) -> None:
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = host_now
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["state"] == "open"
assert instance["overdue"] is overdue
@pytest.mark.parametrize("plane", ["panel", "admin"])
async def test_household_day_survives_utc_midnight(
household: SimpleNamespace, plane: str
) -> None:
"""23:30 EDT is already tomorrow in UTC; the household day is today."""
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = _utc(3, 30, day=27)
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is True
@pytest.mark.parametrize("plane", ["panel", "admin"])
async def test_unset_zone_keeps_host_local_time(
household: SimpleNamespace, plane: str
) -> None:
"""Backward compatible: no stored zone reads the host clock as-is."""
household.clock["now"] = _utc(13, 59, 59)
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is True
async def test_panel_completion_on_time_uses_household_zone(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap on a 10:00 quest is on time (13:30 UTC is not)."""
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = _utc(13, 30)
seed = household.seed
response = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_panel_headers(),
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["state"] == "completed"
assert instance["on_time"] is True
async def test_invalid_zone_is_rejected(household: SimpleNamespace) -> None:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "Mars/Olympus_Mons"},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone" in str(response.json()["detail"])
reread = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json()["timezone"] == ""
# --- the settings object ---------------------------------------------------
def test_settings_timezone_defaults_to_host_local() -> None:
settings = _settings.NestQuestSettings()
assert settings.timezone == ""
instant = _utc(13, 0)
assert settings.household_now(instant) is instant
def test_settings_household_now_converts_the_same_instant() -> None:
settings = _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
local = settings.household_now(_utc(13, 0))
assert local == _utc(13, 0)
assert (local.date(), local.time()) == (TODAY, datetime.time(9, 0))
@pytest.mark.parametrize("value", ["Mars/Olympus_Mons", "../etc/passwd", 5, None])
def test_settings_rejects_invalid_timezone(value: object) -> None:
with pytest.raises(ValueError, match="timezone"):
_settings.NestQuestSettings(timezone=value)
def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
assert (
_settings.NestQuestSettings.from_options(
{"timezone": HOUSEHOLD_ZONE}
).timezone
== HOUSEHOLD_ZONE
)
assert _settings.NestQuestSettings.from_options({}).timezone == ""
assert (
_settings.NestQuestSettings.from_options_resilient(
{"timezone": "Nowhere/Land", "horizon_days": 9}
).timezone
== ""
)
# --- the missed-sweep scheduler ----------------------------------------------
async def test_scheduler_waits_for_rollover_in_household_time(
temp_db_path: str,
) -> None:
"""09:00 EDT (13:00 UTC) to a 10:05 rollover is 65 minutes away."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
await _settings_store.update_settings(
database,
{"day_rollover_time": "10:05", "timezone": HOUSEHOLD_ZONE},
)
delays: list[float] = []
first_sleep = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
first_sleep.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(first_sleep.wait(), timeout=5)
finally:
await scheduler.stop()
assert delays == [pytest.approx(65 * 60)]
finally:
await database.close()
+63
View File
@@ -828,6 +828,7 @@ def _render_quest_log(
url: str,
idle_ms: int | None = None,
probe_ms: int | None = None,
tap_complete: int | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
@@ -851,6 +852,8 @@ def _render_quest_log(
spec["idle_ms"] = idle_ms
if probe_ms is not None:
spec["probe_ms"] = probe_ms
if tap_complete is not None:
spec["tap_complete"] = tap_complete
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
@@ -952,6 +955,66 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
assert result["location"] == "/nestquest"
def test_tapping_complete_confirms_and_calls_the_service() -> None:
"""Tapping an open quest's Complete opens the confirm dialog, and the
dialog's Complete calls nestquest.complete_quest with the panel actor.
With ``useDefineForClassFields`` on (the ES2022 default) the card's
reactive fields were emitted as native class fields that shadowed
Lit's accessors: ``_confirm`` changed but no re-render was scheduled,
so the dialog never opened and the tap did nothing."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
open_quest = {
"id": 42,
"definition_id": 7,
"child_id": 1,
"title": "Brush Teeth",
"icon": None,
"window": "morning",
"due_time": "08:15",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"1",
child_name="Ada",
child_id=1,
present=True,
instances=[open_quest],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
tap_complete=open_quest["id"],
)
assert result["tap"]["dialog_opened"] is True
assert result["tap"]["service_calls"] == [
{
"domain": "nestquest",
"service": "complete_quest",
"data": {
"instance_id": open_quest["id"],
"actor": "panel",
"actor_child_id": open_quest["child_id"],
},
}
]
def test_card_build_keeps_lit_reactive_accessors() -> None:
"""The frontend build must not emit class fields that shadow Lit's
reactive property accessors (Lit's documented TypeScript setting)."""
tsconfig = json.loads((FRONTEND_DIR / "tsconfig.json").read_text())
assert tsconfig["compilerOptions"]["useDefineForClassFields"] is False
def test_quest_log_crest_keeps_its_shield_classes() -> None:
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
styled shield, not a bare span. Lit removes an attribute outright when