Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64aa8e99f6 |
+41
-13
@@ -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
@@ -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
@@ -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
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"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:
|
||||
|
||||
- ``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
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user