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>
This commit is contained in:
+41
-13
@@ -867,7 +867,7 @@ class AdminSettingsResponse(BaseModel):
|
|||||||
"""The effective settings as the admin plane serializes them.
|
"""The effective settings as the admin plane serializes them.
|
||||||
|
|
||||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
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
|
already fell back to its default in the core). The same shape both
|
||||||
the GET and the PATCH route answer with.
|
the GET and the PATCH route answer with.
|
||||||
"""
|
"""
|
||||||
@@ -882,6 +882,9 @@ class AdminSettingsResponse(BaseModel):
|
|||||||
afternoon_reminder_enabled: bool
|
afternoon_reminder_enabled: bool
|
||||||
end_of_day_report_enabled: bool
|
end_of_day_report_enabled: bool
|
||||||
celebration_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):
|
class AdminMissedSweepResponse(BaseModel):
|
||||||
@@ -1178,6 +1181,26 @@ def _local_now() -> datetime.datetime:
|
|||||||
return datetime.datetime.now().astimezone()
|
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:
|
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
|
||||||
"""Build the core ScheduleRule from the request's ``rule`` object.
|
"""Build the core ScheduleRule from the request's ``rule`` object.
|
||||||
|
|
||||||
@@ -1496,7 +1519,7 @@ async def admin_create_quest_definition(
|
|||||||
response_model=AdminOccurrencesPreviewResponse,
|
response_model=AdminOccurrencesPreviewResponse,
|
||||||
)
|
)
|
||||||
async def admin_preview_occurrences(
|
async def admin_preview_occurrences(
|
||||||
body: AdminOccurrencesPreviewRequest,
|
body: AdminOccurrencesPreviewRequest, request: Request
|
||||||
) -> AdminOccurrencesPreviewResponse:
|
) -> AdminOccurrencesPreviewResponse:
|
||||||
"""Return the first ``count`` dates the supplied rule fires on.
|
"""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
|
call over the bounded window ``[start_date, start_date + 366
|
||||||
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
||||||
read — truncated to ``count``. Every core ``ValueError`` is 422
|
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 = (
|
start_date = (
|
||||||
body.start_date
|
body.start_date
|
||||||
if body.start_date is not None
|
if body.start_date is not None
|
||||||
else _local_now().date().isoformat()
|
else (
|
||||||
|
await _household_today(request.app.state.db.database)
|
||||||
|
).isoformat()
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
rule = _rule_from_request(body.rule)
|
rule = _rule_from_request(body.rule)
|
||||||
@@ -1839,7 +1865,7 @@ async def admin_presence_override_consequence(
|
|||||||
body.start_date,
|
body.start_date,
|
||||||
body.end_date,
|
body.end_date,
|
||||||
body.is_present,
|
body.is_present,
|
||||||
today=_local_now().date(),
|
today=await _household_today(state.database),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
@@ -1926,7 +1952,7 @@ async def admin_uncomplete_instance(
|
|||||||
"""
|
"""
|
||||||
state: DatabaseState = request.app.state.db
|
state: DatabaseState = request.app.state.db
|
||||||
database = state.database
|
database = state.database
|
||||||
now = _local_now()
|
_settings, now = await _household_clock(database)
|
||||||
sub = claims.get("sub")
|
sub = claims.get("sub")
|
||||||
try:
|
try:
|
||||||
result = await core_completion.uncomplete_instance(
|
result = await core_completion.uncomplete_instance(
|
||||||
@@ -1992,7 +2018,7 @@ async def admin_regenerate(
|
|||||||
"""
|
"""
|
||||||
state: DatabaseState = request.app.state.db
|
state: DatabaseState = request.app.state.db
|
||||||
database = state.database
|
database = state.database
|
||||||
today = _local_now().date()
|
today = await _household_today(state.database)
|
||||||
|
|
||||||
if body.scope == "household":
|
if body.scope == "household":
|
||||||
end_date = today + datetime.timedelta(
|
end_date = today + datetime.timedelta(
|
||||||
@@ -2077,7 +2103,7 @@ def _settings_response(
|
|||||||
"""Serialize one core NestQuestSettings into the documented payload.
|
"""Serialize one core NestQuestSettings into the documented payload.
|
||||||
|
|
||||||
Explicit field-by-field (not ``dataclasses.asdict``) so the 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.
|
dataclass later grows another one.
|
||||||
"""
|
"""
|
||||||
return AdminSettingsResponse(
|
return AdminSettingsResponse(
|
||||||
@@ -2091,6 +2117,7 @@ def _settings_response(
|
|||||||
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
|
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
|
||||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||||
celebration_enabled=settings.celebration_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`).
|
``ValueError`` is 422 (:func:`_raise_history_error`).
|
||||||
"""
|
"""
|
||||||
state: DatabaseState = request.app.state.db
|
state: DatabaseState = request.app.state.db
|
||||||
today = _local_now().date()
|
today = await _household_today(state.database)
|
||||||
try:
|
try:
|
||||||
rows = await core_history.query_history(
|
rows = await core_history.query_history(
|
||||||
state.database, filter, start, end, today=today
|
state.database, filter, start, end, today=today
|
||||||
@@ -2165,7 +2192,7 @@ async def admin_history_csv(
|
|||||||
JSON route's (:func:`_raise_history_error`).
|
JSON route's (:func:`_raise_history_error`).
|
||||||
"""
|
"""
|
||||||
state: DatabaseState = request.app.state.db
|
state: DatabaseState = request.app.state.db
|
||||||
today = _local_now().date()
|
today = await _household_today(state.database)
|
||||||
try:
|
try:
|
||||||
csv_text = await core_history.export_history_csv(
|
csv_text = await core_history.export_history_csv(
|
||||||
state.database, filter, start, end, today=today
|
state.database, filter, start, end, today=today
|
||||||
@@ -2188,7 +2215,7 @@ async def admin_history_csv(
|
|||||||
response_model=AdminSettingsResponse,
|
response_model=AdminSettingsResponse,
|
||||||
)
|
)
|
||||||
async def admin_get_settings(request: Request) -> 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
|
Requires a valid admin JWT (the router's shared
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
: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
|
state: DatabaseState = request.app.state.db
|
||||||
database = state.database
|
database = state.database
|
||||||
today = _local_now().date()
|
today = await _household_today(state.database)
|
||||||
try:
|
try:
|
||||||
events = await core_sweep.run_missed_sweep(database, today=today)
|
events = await core_sweep.run_missed_sweep(database, today=today)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
@@ -2309,8 +2336,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
|||||||
:class:`AdminSnapshotResponse`.
|
:class:`AdminSnapshotResponse`.
|
||||||
"""
|
"""
|
||||||
state: DatabaseState = request.app.state.db
|
state: DatabaseState = request.app.state.db
|
||||||
|
settings, now = await _household_clock(state.database)
|
||||||
snapshot = await core_snapshot.build_snapshot(
|
snapshot = await core_snapshot.build_snapshot(
|
||||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
state.database, settings, now
|
||||||
)
|
)
|
||||||
return AdminSnapshotResponse(
|
return AdminSnapshotResponse(
|
||||||
today_iso=snapshot.today_iso,
|
today_iso=snapshot.today_iso,
|
||||||
|
|||||||
+21
-6
@@ -8,9 +8,9 @@ this router inherits the check).
|
|||||||
|
|
||||||
The snapshot route is a thin adapter over the bundled core: it performs
|
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``,
|
NO business logic. It reads the live database off ``app.state.db``,
|
||||||
resolves ONE timezone-aware ``now`` (the API host's local time — the
|
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
|
||||||
API is the household's "local" host now that the domain no longer lives
|
stored household ``timezone`` setting — the API host may run UTC while
|
||||||
inside Home Assistant), calls the pure
|
the household does not), calls the pure
|
||||||
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
||||||
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
||||||
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
``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 (
|
from api.nestquest_core import (
|
||||||
core_completion,
|
core_completion,
|
||||||
core_events,
|
core_events,
|
||||||
core_settings,
|
core_settings_store,
|
||||||
core_snapshot,
|
core_snapshot,
|
||||||
)
|
)
|
||||||
from api import transitions as api_transitions
|
from api import transitions as api_transitions
|
||||||
@@ -178,6 +178,20 @@ def _local_now() -> datetime.datetime:
|
|||||||
return datetime.datetime.now().astimezone()
|
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(
|
@router.get(
|
||||||
"/snapshot",
|
"/snapshot",
|
||||||
summary="Today's household snapshot",
|
summary="Today's household snapshot",
|
||||||
@@ -196,8 +210,9 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
|||||||
:class:`PanelSnapshotResponse`.
|
:class:`PanelSnapshotResponse`.
|
||||||
"""
|
"""
|
||||||
state: DatabaseState = request.app.state.db
|
state: DatabaseState = request.app.state.db
|
||||||
|
settings, now = await _household_clock(state.database)
|
||||||
snapshot = await core_snapshot.build_snapshot(
|
snapshot = await core_snapshot.build_snapshot(
|
||||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
state.database, settings, now
|
||||||
)
|
)
|
||||||
return PanelSnapshotResponse(
|
return PanelSnapshotResponse(
|
||||||
today_iso=snapshot.today_iso,
|
today_iso=snapshot.today_iso,
|
||||||
@@ -282,7 +297,7 @@ async def panel_complete_instance(
|
|||||||
detail="Quest instance not found for this child",
|
detail="Quest instance not found for this child",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = _local_now()
|
_settings, now = await _household_clock(database)
|
||||||
try:
|
try:
|
||||||
result = await core_completion.complete_instance(
|
result = await core_completion.complete_instance(
|
||||||
database,
|
database,
|
||||||
|
|||||||
+10
-3
@@ -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
|
at the top of EVERY cycle, so an admin's settings change applies on
|
||||||
the next scheduling decision without a restart — then runs the
|
the next scheduling decision without a restart — then runs the
|
||||||
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for 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
|
``nestquest_quest_missed`` event on the app's ONE transition publisher
|
||||||
(the same SSE stream every route publishes through).
|
(the same SSE stream every route publishes through).
|
||||||
|
|
||||||
@@ -130,7 +131,11 @@ class MissedSweepScheduler:
|
|||||||
)
|
)
|
||||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||||
continue
|
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(
|
LOGGER.info(
|
||||||
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
||||||
settings.day_rollover_time,
|
settings.day_rollover_time,
|
||||||
@@ -138,7 +143,9 @@ class MissedSweepScheduler:
|
|||||||
)
|
)
|
||||||
await self._sleep(delay)
|
await self._sleep(delay)
|
||||||
try:
|
try:
|
||||||
await self._run_once(self._clock().date())
|
await self._run_once(
|
||||||
|
settings.household_now(self._clock()).date()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Not silent either: the sweep error is logged and the
|
# Not silent either: the sweep error is logged and the
|
||||||
# loop backs off before the next cycle, whose watermark
|
# 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_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||||
DEFAULT_AUTOMATION_ENABLED = True
|
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
|
# Panel-plane API client (Feature 18): the integration reaches the
|
||||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
# 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 dataclasses import dataclass
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||||
@@ -47,12 +48,14 @@ from .const import (
|
|||||||
CONF_MORNING_SUMMARY_ENABLED,
|
CONF_MORNING_SUMMARY_ENABLED,
|
||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
CONF_NOTIFY_TARGET,
|
CONF_NOTIFY_TARGET,
|
||||||
|
CONF_TIMEZONE,
|
||||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
DEFAULT_AUTOMATION_ENABLED,
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_MORNING_SUMMARY_TIME,
|
DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
|
DEFAULT_TIMEZONE,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
TIME_PATTERN,
|
TIME_PATTERN,
|
||||||
)
|
)
|
||||||
@@ -153,6 +156,29 @@ def _resolve_optional_str(value: Any) -> str:
|
|||||||
return value.strip()
|
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
|
#: The single field table the two builders share. Each row is
|
||||||
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
||||||
#: applies every validator (raising on the first invalid value) while
|
#: 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,
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
_resolve_bool,
|
_resolve_bool,
|
||||||
),
|
),
|
||||||
|
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +270,7 @@ class NestQuestSettings:
|
|||||||
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
|
timezone: str = DEFAULT_TIMEZONE
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
# Validate STRICTLY at construction so a hand-built instance
|
# Validate STRICTLY at construction so a hand-built instance
|
||||||
@@ -281,6 +309,21 @@ class NestQuestSettings:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{name} must be a boolean, got {value!r}"
|
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
|
@property
|
||||||
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
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.
|
side reads the other's values.
|
||||||
|
|
||||||
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
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
|
(which equals the ``CONF_*`` option key for every field), complete
|
||||||
after the first update:
|
after the first update:
|
||||||
|
|
||||||
@@ -30,7 +30,10 @@ after the first update:
|
|||||||
core's normalizer),
|
core's normalizer),
|
||||||
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
|
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
|
||||||
``end_of_day_report_enabled``, ``celebration_enabled`` — real
|
``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
|
Only values whose core validators ACCEPTED are ever written, so a
|
||||||
stored document always round-trips through the core's option builders.
|
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.
|
update can never leave a half-applied document behind.
|
||||||
|
|
||||||
The validated changes are merged onto the current effective
|
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
|
in one atomic upsert; the updated settings are returned. The whole
|
||||||
read → merge → write span runs inside the per-database settings
|
read → merge → write span runs inside the per-database settings
|
||||||
lock, so concurrent updates queue instead of interleaving.
|
lock, so concurrent updates queue instead of interleaving.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,10 @@
|
|||||||
* receives one JSON object {location, headline, countdown, html, probe} —
|
* receives one JSON object {location, headline, countdown, html, probe} —
|
||||||
* the final window pathname, the rendered title, the complete-screen
|
* the final window pathname, the rendered title, the complete-screen
|
||||||
* countdown text, the rendered shadow DOM, and the probe snapshot when
|
* 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.
|
* assertions run against the exact bundle HACS ships.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
@@ -43,12 +46,34 @@ globalThis.Event = dom.window.Event;
|
|||||||
|
|
||||||
await import(pathToFileURL(spec.bundle).href);
|
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");
|
const element = document.createElement("nestquest-quest-log-card");
|
||||||
element.setConfig(spec.config);
|
element.setConfig(spec.config);
|
||||||
element.hass = spec.hass;
|
element.hass = hass;
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
await element.updateComplete;
|
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 = () => ({
|
const snapshot = () => ({
|
||||||
location: window.location.pathname,
|
location: window.location.pathname,
|
||||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
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));
|
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()));
|
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": false,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
|
|||||||
@@ -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:
|
Proven per the done-condition, everything read back through the API:
|
||||||
|
|
||||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
- ``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
|
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||||
NEXT read reflects them; a later partial update merges onto the
|
NEXT read reflects them; a later partial update merges onto the
|
||||||
current effective settings (omitted fields keep their stored value),
|
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"
|
SETTINGS_META_KEY = "settings"
|
||||||
|
|
||||||
#: The all-defaults effective settings a fresh database reports — the
|
#: 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.
|
#: pinned here, independent of the core constants.
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"horizon_days": 14,
|
"horizon_days": 14,
|
||||||
@@ -73,6 +73,7 @@ DEFAULT_SETTINGS = {
|
|||||||
"afternoon_reminder_enabled": True,
|
"afternoon_reminder_enabled": True,
|
||||||
"end_of_day_report_enabled": True,
|
"end_of_day_report_enabled": True,
|
||||||
"celebration_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(
|
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||||
settings_client: SimpleNamespace,
|
settings_client: SimpleNamespace,
|
||||||
) -> None:
|
) -> 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(
|
response = await settings_client.client.get(
|
||||||
"/api/v1/admin/settings", headers=_admin_headers()
|
"/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.status_code == 200
|
||||||
assert reread.json() == expected
|
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.
|
# fields, keyed by field name, under the well-known meta_state key.
|
||||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||||
SETTINGS_META_KEY
|
SETTINGS_META_KEY
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -828,6 +828,7 @@ def _render_quest_log(
|
|||||||
url: str,
|
url: str,
|
||||||
idle_ms: int | None = None,
|
idle_ms: int | None = None,
|
||||||
probe_ms: int | None = None,
|
probe_ms: int | None = None,
|
||||||
|
tap_complete: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Render the built bundle's quest log and report its location.
|
"""Render the built bundle's quest log and report its location.
|
||||||
|
|
||||||
@@ -851,6 +852,8 @@ def _render_quest_log(
|
|||||||
spec["idle_ms"] = idle_ms
|
spec["idle_ms"] = idle_ms
|
||||||
if probe_ms is not None:
|
if probe_ms is not None:
|
||||||
spec["probe_ms"] = probe_ms
|
spec["probe_ms"] = probe_ms
|
||||||
|
if tap_complete is not None:
|
||||||
|
spec["tap_complete"] = tap_complete
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
["node", str(QUEST_LOG_HARNESS)],
|
["node", str(QUEST_LOG_HARNESS)],
|
||||||
input=json.dumps(spec),
|
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"
|
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:
|
def test_quest_log_crest_keeps_its_shield_classes() -> None:
|
||||||
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
|
"""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
|
styled shield, not a bare span. Lit removes an attribute outright when
|
||||||
|
|||||||
Reference in New Issue
Block a user