Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c4617459 | ||
|
|
4172b286ff | ||
|
|
88b4733a2d |
@@ -1,41 +1,5 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.8.0 — 2026-09-25
|
||||
|
||||
### Scope
|
||||
|
||||
Minor release carrying the multi-pattern presence model (schema 9), the
|
||||
Sunday-first calendar week, and the create-materializes fix, plus the
|
||||
definition start-date field, onto `main`.
|
||||
|
||||
- **Multiple presence patterns per child (schema 9):** a child now has zero or
|
||||
more presence patterns instead of at most one. Each pattern has a name, a kind
|
||||
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
|
||||
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
|
||||
then home, then absent when the child has at least one home pattern, else
|
||||
present. Migration 9 copies each existing schedule into a `home` pattern named
|
||||
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
|
||||
- **Sunday-first calendar week:** the admin Schedule month grid and the
|
||||
Definitions weekday picker now render weeks Sunday-first; the stored/API
|
||||
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
|
||||
- **Fix — newly created tasks materialize immediately:** creating a quest
|
||||
definition now regenerates its future instances over the rolling horizon, so a
|
||||
new task reaches the panel without waiting for an unrelated write. (PRs #235,
|
||||
#236.)
|
||||
- **Definition start date:** the definition edit sheet gains a Start date field
|
||||
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
|
||||
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
|
||||
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
|
||||
|
||||
### Upgrade note
|
||||
|
||||
This release runs schema version **9**. Version 0.7.1 refuses a version-9
|
||||
database, so take a copy of the SQLite file (or note the Litestream generation)
|
||||
before the first start on 0.8.0 if you need a rollback path.
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
## Version 0.7.1 — 2026-09-24
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -1118,95 +1118,3 @@ describe("DefinitionsTab icon picker", () => {
|
||||
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Start date ─────────────────────────────────────────────────────── */
|
||||
|
||||
function startDateInput(s: HTMLElement): HTMLInputElement {
|
||||
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("DefinitionsTab start date", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a new task defaults to today and POSTs the chosen start date", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||
await renderReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
const input = startDateInput(s);
|
||||
expect(input.type).toBe("date");
|
||||
expect(input.value).toBe("2026-09-23");
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Feed the cat" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||
fireEvent.change(input, { target: { value: "2026-10-15" } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
|
||||
});
|
||||
|
||||
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
const input = startDateInput(s);
|
||||
expect(input.value).toBe("2026-01-01");
|
||||
|
||||
fireEvent.change(input, { target: { value: "2026-11-02" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toEqual([
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
path: "/api/v1/admin/quest-definitions/10",
|
||||
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("the occurrence preview requests the chosen start date", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(1));
|
||||
|
||||
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
|
||||
await settleDebounce();
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(2));
|
||||
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
|
||||
"2027-03-01",
|
||||
);
|
||||
});
|
||||
|
||||
it("a cleared start date is not submitted", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
fireEvent.change(startDateInput(s), { target: { value: "" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,7 +177,6 @@ function parseWhole(value: string, min: number, max: number): number | null {
|
||||
function buildRule(draft: Draft): DefinitionRule | string {
|
||||
const interval = parseWhole(draft.interval, 1, 999);
|
||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
||||
if (draft.startDate === "") return "Pick a start date.";
|
||||
const rule: DefinitionRule = {
|
||||
rule_type: "daily",
|
||||
interval,
|
||||
@@ -741,16 +740,6 @@ function EditSheet({
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Start date" id="defs-label-start">
|
||||
<input
|
||||
className="defs-input"
|
||||
type="date"
|
||||
aria-labelledby="defs-label-start"
|
||||
value={draft.startDate}
|
||||
onChange={(e) => update({ startDate: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
{orderedWindows.length === 0 ? (
|
||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
||||
|
||||
+13
-41
@@ -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 ELEVEN documented fields, resolved (every absent stored field
|
||||
— all TEN 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,9 +882,6 @@ 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):
|
||||
@@ -1181,26 +1178,6 @@ 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.
|
||||
|
||||
@@ -1519,7 +1496,7 @@ async def admin_create_quest_definition(
|
||||
response_model=AdminOccurrencesPreviewResponse,
|
||||
)
|
||||
async def admin_preview_occurrences(
|
||||
body: AdminOccurrencesPreviewRequest, request: Request
|
||||
body: AdminOccurrencesPreviewRequest,
|
||||
) -> AdminOccurrencesPreviewResponse:
|
||||
"""Return the first ``count`` dates the supplied rule fires on.
|
||||
|
||||
@@ -1532,15 +1509,12 @@ 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`). The only database access is the
|
||||
settings read behind the household clock.
|
||||
(:func:`_raise_preview_error`). No database access.
|
||||
"""
|
||||
start_date = (
|
||||
body.start_date
|
||||
if body.start_date is not None
|
||||
else (
|
||||
await _household_today(request.app.state.db.database)
|
||||
).isoformat()
|
||||
else _local_now().date().isoformat()
|
||||
)
|
||||
try:
|
||||
rule = _rule_from_request(body.rule)
|
||||
@@ -1865,7 +1839,7 @@ async def admin_presence_override_consequence(
|
||||
body.start_date,
|
||||
body.end_date,
|
||||
body.is_present,
|
||||
today=await _household_today(state.database),
|
||||
today=_local_now().date(),
|
||||
)
|
||||
)
|
||||
except ValueError as error:
|
||||
@@ -1952,7 +1926,7 @@ async def admin_uncomplete_instance(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
_settings, now = await _household_clock(database)
|
||||
now = _local_now()
|
||||
sub = claims.get("sub")
|
||||
try:
|
||||
result = await core_completion.uncomplete_instance(
|
||||
@@ -2018,7 +1992,7 @@ async def admin_regenerate(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = await _household_today(state.database)
|
||||
today = _local_now().date()
|
||||
|
||||
if body.scope == "household":
|
||||
end_date = today + datetime.timedelta(
|
||||
@@ -2103,7 +2077,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 eleven documented fields even if the core
|
||||
stays pinned to the ten documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
@@ -2117,7 +2091,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -2149,7 +2122,7 @@ async def admin_history_query(
|
||||
``ValueError`` is 422 (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = await _household_today(state.database)
|
||||
today = _local_now().date()
|
||||
try:
|
||||
rows = await core_history.query_history(
|
||||
state.database, filter, start, end, today=today
|
||||
@@ -2192,7 +2165,7 @@ async def admin_history_csv(
|
||||
JSON route's (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = await _household_today(state.database)
|
||||
today = _local_now().date()
|
||||
try:
|
||||
csv_text = await core_history.export_history_csv(
|
||||
state.database, filter, start, end, today=today
|
||||
@@ -2215,7 +2188,7 @@ async def admin_history_csv(
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all eleven documented fields).
|
||||
"""Return the current effective settings (all ten documented fields).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
@@ -2307,7 +2280,7 @@ async def admin_run_missed_sweep(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = await _household_today(state.database)
|
||||
today = _local_now().date()
|
||||
try:
|
||||
events = await core_sweep.run_missed_sweep(database, today=today)
|
||||
except ValueError as error:
|
||||
@@ -2336,9 +2309,8 @@ 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, settings, now
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return AdminSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
|
||||
+6
-21
@@ -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 host clock re-expressed in the
|
||||
stored household ``timezone`` setting — the API host may run UTC while
|
||||
the household does not), calls the pure
|
||||
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
|
||||
: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_store,
|
||||
core_settings,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import transitions as api_transitions
|
||||
@@ -178,20 +178,6 @@ 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",
|
||||
@@ -210,9 +196,8 @@ 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, settings, now
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
@@ -297,7 +282,7 @@ async def panel_complete_instance(
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
_settings, now = await _household_clock(database)
|
||||
now = _local_now()
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
|
||||
+3
-10
@@ -8,8 +8,7 @@ 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
|
||||
household-local date (the stored ``timezone`` setting; the API host's
|
||||
local date when unset) and publishes each returned
|
||||
API host's local date and publishes each returned
|
||||
``nestquest_quest_missed`` event on the app's ONE transition publisher
|
||||
(the same SSE stream every route publishes through).
|
||||
|
||||
@@ -131,11 +130,7 @@ class MissedSweepScheduler:
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
continue
|
||||
# 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())
|
||||
)
|
||||
delay = _delay_seconds(rollover, self._clock())
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
||||
settings.day_rollover_time,
|
||||
@@ -143,9 +138,7 @@ class MissedSweepScheduler:
|
||||
)
|
||||
await self._sleep(delay)
|
||||
try:
|
||||
await self._run_once(
|
||||
settings.household_now(self._clock()).date()
|
||||
)
|
||||
await self._run_once(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,13 +112,6 @@ 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.
|
||||
|
||||
@@ -235,11 +235,6 @@ async def create_quest_definition(
|
||||
transaction, so the returned bundle is a consistent creation
|
||||
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||
failed create persists nothing.
|
||||
|
||||
After the write, :func:`~.materialize.regenerate_for_definition`
|
||||
materializes the new definition's instances over the rolling horizon
|
||||
(host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the
|
||||
board without waiting for an unrelated write.
|
||||
"""
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
@@ -274,9 +269,6 @@ async def create_quest_definition(
|
||||
) from error
|
||||
raise
|
||||
decoded_rule = schedule_rule_from_storage(storage)
|
||||
|
||||
await regenerate_for_definition(database, snapshot.definition.id)
|
||||
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
|
||||
@@ -35,7 +35,6 @@ 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,
|
||||
@@ -48,14 +47,12 @@ 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,
|
||||
)
|
||||
@@ -156,29 +153,6 @@ 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
|
||||
@@ -240,7 +214,6 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
||||
)
|
||||
|
||||
|
||||
@@ -270,7 +243,6 @@ 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
|
||||
@@ -309,21 +281,6 @@ 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 ELEVEN settings fields, keyed by field name
|
||||
object holding exactly the TEN settings fields, keyed by field name
|
||||
(which equals the ``CONF_*`` option key for every field), complete
|
||||
after the first update:
|
||||
|
||||
@@ -30,10 +30,7 @@ after the first update:
|
||||
core's normalizer),
|
||||
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
|
||||
``end_of_day_report_enabled``, ``celebration_enabled`` — real
|
||||
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 ``""``.
|
||||
booleans.
|
||||
|
||||
Only values whose core validators ACCEPTED are ever written, so a
|
||||
stored document always round-trips through the core's option builders.
|
||||
@@ -175,7 +172,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, eleven-field) document is persisted
|
||||
settings and the merged (complete, ten-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.
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.8.0"
|
||||
"version": "0.7.1"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,10 @@ Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
|
||||
|
||||
Default view on the wall. Layout top to bottom:
|
||||
|
||||
1. **Wordmark row** at y=64. Rule · monogram · `NestQuest` (66px/900 Cinzel Decorative
|
||||
display, brand gradient via `background-clip: text`) · rule — the horizontal lockup of
|
||||
`design/logos/NestQuest_Logo.png`. The monogram is an 80px inline SVG (no raster, no
|
||||
remote asset): a compass rose — four long cardinal points, four short diagonal points,
|
||||
a ring broken by the points — around a d20 hexagon with facet lines and a knocked-out
|
||||
Cinzel Decorative "N", filled with the brand gradient (`--nq-brand-blue` →
|
||||
`--nq-brand-purple`, 135°). Gaps are SVG masks, so the mark stays transparent over the
|
||||
parchment.
|
||||
1. **Wordmark row** at y=64. Rule · d20 hexagon · `NestQuest` (66px/900 display, brand
|
||||
gradient via `background-clip: text`) · d20 · rule. d20 marks are 34px hexagons
|
||||
(`clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)`) filled with
|
||||
the brand gradient, numeral 15px/700 Cinzel.
|
||||
2. **Kicker** `The Party · <weekday>, <Month D>` — 21px/600 Cinzel, tracking .30em, uppercase.
|
||||
3. **Adventurer plates**, 3-up grid, 56px gap, page inset 110px, top y=262. One plate per
|
||||
child returned by the party sensor, in configured order.
|
||||
|
||||
@@ -231,76 +231,8 @@ function conditionPhrase(condition: string, label: string): string {
|
||||
}
|
||||
|
||||
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
|
||||
// The brand monogram (design/logos/NestQuest_Logo.png): a compass rose —
|
||||
// four long cardinal points, four short diagonal points, a ring broken by
|
||||
// the points — around a d20 hexagon carrying a knocked-out "N". Drawn on
|
||||
// a 100-unit grid and filled with the brand gradient; the masks cut the
|
||||
// parchment gaps so the mark stays transparent on any backdrop.
|
||||
const MONOGRAM_POINTS =
|
||||
"M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z " +
|
||||
"M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z " +
|
||||
"M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z " +
|
||||
"M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z";
|
||||
const MONOGRAM_HEX = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z";
|
||||
const MONOGRAM_FACETS =
|
||||
"M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2" +
|
||||
"M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8" +
|
||||
"M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8" +
|
||||
"M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8";
|
||||
const MONOGRAM = html`<svg
|
||||
class="monogram"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="nq-monogram-fill"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="100"
|
||||
y2="100"
|
||||
>
|
||||
<stop offset="0" style="stop-color: var(--nq-brand-blue)" />
|
||||
<stop offset="1" style="stop-color: var(--nq-brand-purple)" />
|
||||
</linearGradient>
|
||||
<mask id="nq-monogram-points-gap" maskUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#fff" />
|
||||
<path d=${MONOGRAM_POINTS} stroke="#000" stroke-width="3.5" />
|
||||
</mask>
|
||||
<mask id="nq-monogram-facets" maskUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#fff" />
|
||||
<path d=${MONOGRAM_FACETS} stroke="#000" stroke-width="1.6" />
|
||||
<text
|
||||
x="50"
|
||||
y="59.4"
|
||||
text-anchor="middle"
|
||||
font-family="'Cinzel Decorative', Cinzel, serif"
|
||||
font-weight="900"
|
||||
font-size="25"
|
||||
fill="#fff"
|
||||
stroke="#000"
|
||||
stroke-width="2"
|
||||
paint-order="stroke"
|
||||
>N</text>
|
||||
</mask>
|
||||
</defs>
|
||||
<g fill="url(#nq-monogram-fill)">
|
||||
<path d=${MONOGRAM_POINTS} />
|
||||
<g mask="url(#nq-monogram-points-gap)">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="34.25"
|
||||
fill="none"
|
||||
stroke="url(#nq-monogram-fill)"
|
||||
stroke-width="6.5"
|
||||
/>
|
||||
<path mask="url(#nq-monogram-facets)" d=${MONOGRAM_HEX} />
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
const HEXAGON_CLIP =
|
||||
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
|
||||
|
||||
const boardStyles = css`
|
||||
* {
|
||||
@@ -367,11 +299,23 @@ const boardStyles = css`
|
||||
background: rgba(92, 62, 26, 0.35);
|
||||
}
|
||||
|
||||
.monogram {
|
||||
.d20 {
|
||||
flex: none;
|
||||
display: block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.d20-numeral {
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
@@ -553,12 +497,11 @@ const boardStyles = css`
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 896px;
|
||||
top: 930px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
font-family: var(--nq-p-font-body);
|
||||
font-size: 27px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
@@ -918,8 +861,13 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
<div class="frame frame-inner"></div>
|
||||
<div class="wordmark-row">
|
||||
<span class="rule"></span>
|
||||
${MONOGRAM}
|
||||
<span class="d20" aria-hidden="true">
|
||||
<span class="d20-numeral">20</span>
|
||||
</span>
|
||||
<span class="wordmark">NestQuest</span>
|
||||
<span class="d20" aria-hidden="true">
|
||||
<span class="d20-numeral">20</span>
|
||||
</span>
|
||||
<span class="rule"></span>
|
||||
</div>
|
||||
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
|
||||
|
||||
@@ -378,7 +378,7 @@ const logStyles = css`
|
||||
flex: none;
|
||||
width: 96px;
|
||||
height: 110px;
|
||||
padding: 4px;
|
||||
padding: 3px;
|
||||
clip-path: ${unsafeCSS(SHIELD_CLIP)};
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
@@ -1938,7 +1938,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
const initial = (name?.charAt(0) || "?").toUpperCase();
|
||||
return html`
|
||||
<header class="header">
|
||||
<span class="crest${away ? " away" : ""}" aria-hidden="true">
|
||||
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
|
||||
<span class="crest-face">
|
||||
<span class="initial">${initial}</span>
|
||||
</span>
|
||||
@@ -2096,7 +2096,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
|
||||
<div class="body">
|
||||
<span class="quest-title">${instance.title}</span>
|
||||
<span class="${meta.late ? "meta late" : "meta"}">${meta.text}</span>
|
||||
<span class="meta ${meta.late ? "late" : nothing}">${meta.text}</span>
|
||||
</div>
|
||||
<button
|
||||
class="complete"
|
||||
|
||||
@@ -10,10 +10,7 @@
|
||||
* 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. 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
|
||||
* probe_ms was given. The Python panel tests drive this harness so the
|
||||
* assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -46,34 +43,12 @@ 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 = hass;
|
||||
element.hass = spec.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() ?? "",
|
||||
@@ -93,6 +68,6 @@ if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...snapshot(), probe, tap });
|
||||
const payload = JSON.stringify({ ...snapshot(), probe });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.8.0"
|
||||
version = "0.7.1"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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 eleven documented fields at their defaults on a fresh database.
|
||||
— the ten 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 eleven fields, asserted literally so the payload contract is
|
||||
#: documented ten fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
@@ -73,7 +73,6 @@ DEFAULT_SETTINGS = {
|
||||
"afternoon_reminder_enabled": True,
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
"timezone": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +103,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 eleven documented fields at their defaults."""
|
||||
"""GET returns the ten documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
@@ -140,7 +139,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 eleven
|
||||
# ...and the DATABASE holds the merged document: the complete ten
|
||||
# fields, keyed by field name, under the well-known meta_state key.
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
|
||||
@@ -514,10 +514,8 @@ async def test_regenerate_household_never_duplicates(
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
|
||||
# The seed's creates materialized the horizon BEFORE Ada's custody
|
||||
# pattern existed, so the first household walk may legitimately
|
||||
# drop her away-day rows; only the second run must be stable.
|
||||
assert await _instance_count(database) > 0
|
||||
before = await _instance_count(database)
|
||||
assert before > 0 # the seed already materialized a few days
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
@@ -527,8 +525,9 @@ async def test_regenerate_household_never_duplicates(
|
||||
assert body["scope"] == "household"
|
||||
assert body["definition_id"] is None
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
assert body["count"] >= before
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first >= before
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
@@ -545,18 +544,17 @@ async def test_regenerate_definition_scope_rematerializes(
|
||||
) -> None:
|
||||
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
||||
|
||||
Creating a definition through the admin route already materializes
|
||||
its horizon (the core's create path regenerates); the scoped
|
||||
regenerate call rebuilds the same rows, and re-running it leaves
|
||||
the row count exactly where the create left it.
|
||||
Creating a definition through the admin route materializes NOTHING
|
||||
(the core's create path does not regenerate); the scoped regenerate
|
||||
call is what materializes its instances, and re-running it leaves
|
||||
the row count exactly where the first run left it.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
ada = await _create_child(client, "Ada")
|
||||
definition_id = await _create_daily_definition(client, [ada])
|
||||
|
||||
created_count = await _instance_count(database)
|
||||
assert created_count > 0
|
||||
assert await _instance_count(database) == 0
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
@@ -570,7 +568,7 @@ async def test_regenerate_definition_scope_rematerializes(
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first == body["count"] == created_count
|
||||
assert after_first == body["count"]
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
"""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()
|
||||
@@ -151,17 +151,12 @@ async def _seed_missed_household(
|
||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||
)
|
||||
# Today's quest: created after the yesterday walk so it has no
|
||||
# past-due instance; ``end_date`` keeps it (and create's horizon
|
||||
# regeneration) to today only.
|
||||
# past-due instance, then materialized for today only.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": today_iso,
|
||||
"end_date": today_iso,
|
||||
}
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
|
||||
@@ -149,18 +149,13 @@ async def _seed_household(
|
||||
(today + datetime.timedelta(days=3)).isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# The stale quest's rule ends YESTERDAY, so create's own
|
||||
# regeneration writes nothing and its only instance is the
|
||||
# yesterday one written below.
|
||||
# The stale quest is created AFTER the today walk, so its only
|
||||
# instance is the yesterday one written below.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
|
||||
@@ -102,20 +102,15 @@ async def _seed_missed_household(
|
||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||
)
|
||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||
# A quest due TODAY only (created after the yesterday walk so it
|
||||
# has no past-due instance; ``end_date`` keeps create's horizon
|
||||
# regeneration to today): the post-downtime catch-up run's
|
||||
# A quest due TODAY (created after the yesterday walk so it has
|
||||
# no past-due instance): the post-downtime catch-up run's
|
||||
# target — inside [watermark=today, later_today) but not
|
||||
# inside the day-one sweep's window.
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": today_iso,
|
||||
"end_date": today_iso,
|
||||
}
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
|
||||
@@ -468,11 +468,7 @@ async def _materialize_with_change(
|
||||
proceeds with the snapped presence). This deterministically
|
||||
exercises the snapshot-to-insert gap that a config edit can
|
||||
interleave into.
|
||||
|
||||
``create_quest_definition`` already materializes its horizon, so the
|
||||
table is cleared first: the gated walk must be the only writer.
|
||||
"""
|
||||
await database.execute("DELETE FROM quest_instances")
|
||||
original_upsert = QuestInstancesDao.upsert_if_valid
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
@@ -411,36 +411,6 @@ def test_panel_hard_rules_hold_in_the_card_sources() -> None:
|
||||
assert "@click" not in sealed_template
|
||||
|
||||
|
||||
def test_party_board_hint_clears_the_dock() -> None:
|
||||
"""PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at
|
||||
1920x1080. The hint's line box (top + font-size x line-height) must
|
||||
end above the dock's top edge (1080 - bottom inset - dock height)
|
||||
with a visible gap, at the 27px/600 design size. The hint is a <p>,
|
||||
so its user-agent 1em top margin must be reset or it shifts the box
|
||||
down by a whole font-size."""
|
||||
style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
|
||||
rules = {selector.strip(): body for selector, body in _css_rules(style)}
|
||||
sizes = _size_tokens(_read(REWARD_TOKENS))
|
||||
|
||||
def px(body: str, prop: str) -> float:
|
||||
declared = re.search(rf"(?<![-\w]){prop}:\s*([^;]+);", body)
|
||||
assert declared is not None, prop
|
||||
value = _font_size_px(declared.group(1), sizes)
|
||||
assert value is not None, f"{prop}: {declared.group(1)}"
|
||||
return value
|
||||
|
||||
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
|
||||
assert "margin: 0;" in hint
|
||||
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
|
||||
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
|
||||
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
|
||||
assert px(dock, "height") == 84
|
||||
assert px(dock, "bottom") == 46
|
||||
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
|
||||
assert px(hint, "font-size") == 27
|
||||
assert "font-weight: 600;" in hint
|
||||
|
||||
|
||||
def test_panel_text_sizes_respect_the_22px_floor() -> None:
|
||||
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
|
||||
panel styles resolves against the panel size tokens or an explicit
|
||||
@@ -600,40 +570,6 @@ def _render_party_board(
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_party_board_wordmark_row_is_the_monogram_lockup() -> None:
|
||||
"""PANEL-SPEC §2 item 1: the wordmark row is rule · compass-rose
|
||||
monogram · NestQuest · rule. The monogram is an inline SVG filled
|
||||
with the brand gradient — the old d20 hexagon pair and its "20"
|
||||
numerals are gone, and nothing is fetched from a remote URL or
|
||||
embedded as a raster logo."""
|
||||
source = _read(PARTY_BOARD)
|
||||
assert "d20" not in _style_literal(source, STYLE_CONSTANTS[PARTY_BOARD])
|
||||
assert 'class="d20' not in source
|
||||
assert "HEXAGON_CLIP" not in source
|
||||
|
||||
html = _render_party_board(
|
||||
{"type": "custom:nestquest-party-board-card"},
|
||||
_three_child_states(),
|
||||
)["html"]
|
||||
row = re.search(r'<div class="wordmark-row">(.*?)</div>', html, re.DOTALL)
|
||||
assert row is not None
|
||||
row_html = row.group(1)
|
||||
classes = re.findall(r'<(?:span|svg) class="([^"]+)"', row_html)
|
||||
assert classes == ["rule", "monogram", "wordmark", "rule"]
|
||||
assert '<svg class="monogram" viewBox="0 0 100 100" aria-hidden="true"' in row_html
|
||||
assert "var(--nq-brand-blue)" in row_html
|
||||
assert "var(--nq-brand-purple)" in row_html
|
||||
assert ">N</text>" in row_html
|
||||
assert ">NestQuest</span>" in row_html
|
||||
assert "d20" not in row_html
|
||||
assert "<img" not in row_html
|
||||
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "d20-numeral\">20<" not in bundle
|
||||
assert "data:image/png" not in bundle
|
||||
assert not re.search(r"https?://", bundle)
|
||||
|
||||
|
||||
def test_party_board_renders_discovered_children_in_sort_order() -> None:
|
||||
"""No child_order key: three plates render in the roster's sort
|
||||
order (the coordinator snapshot's order), one per child."""
|
||||
@@ -828,7 +764,6 @@ 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.
|
||||
|
||||
@@ -852,8 +787,6 @@ 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),
|
||||
@@ -953,190 +886,3 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
|
||||
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
|
||||
# …and at zero the card has returned to the party board.
|
||||
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
|
||||
any interpolated part of it is ``nothing``, so a class written as
|
||||
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
|
||||
child and the header showed an unstyled sliver. The rendered markup
|
||||
must carry ``class="crest"`` (present) and ``class="crest away"``
|
||||
(away), each wrapping the face and initial."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
log_config = generated["views"][1]["cards"][0]
|
||||
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
|
||||
result = _render_quest_log(
|
||||
log_config,
|
||||
_three_child_states(),
|
||||
url=f"http://homeassistant.local/nestquest/{slug}",
|
||||
)
|
||||
header = result["html"][result["html"].index('<header class="header">') :]
|
||||
crest = re.match(
|
||||
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
|
||||
r'<span class="crest-face">\s*<span class="initial">',
|
||||
header,
|
||||
)
|
||||
assert crest is not None, header[:600]
|
||||
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
|
||||
|
||||
|
||||
def test_quest_log_meta_line_keeps_its_meta_class() -> None:
|
||||
"""An open quest's meta line is styled by ``.quest .meta`` (and
|
||||
``.quest .meta.late`` when overdue). Written as
|
||||
``meta ${late ? "late" : nothing}``, Lit dropped the whole class
|
||||
attribute for every quest that was not late, leaving the due line
|
||||
unstyled. The rendered markup must carry ``class="meta"`` on time and
|
||||
``class="meta late"`` overdue."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
|
||||
def quest(instance_id: int, title: str, overdue: bool) -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": instance_id,
|
||||
"child_id": 1,
|
||||
"title": title,
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "08:15",
|
||||
"state": "open",
|
||||
"overdue": overdue,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
states = {
|
||||
**_three_child_states(),
|
||||
"sensor.nestquest_ada_quests_due_today": _state(
|
||||
"2",
|
||||
child_name="Ada",
|
||||
child_id=1,
|
||||
present=True,
|
||||
instances=[
|
||||
quest(41, "Make Bed", overdue=False),
|
||||
quest(42, "Brush Teeth", overdue=True),
|
||||
],
|
||||
),
|
||||
}
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
)
|
||||
metas = {}
|
||||
for card in result["html"].split('data-instance-id="')[1:]:
|
||||
meta = re.search(
|
||||
r'<span class="quest-title">.*?</span>'
|
||||
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>",
|
||||
card,
|
||||
re.S,
|
||||
)
|
||||
assert meta is not None, card[:800]
|
||||
metas[card[: card.index('"')]] = meta.group(1)
|
||||
assert set(metas) == {"41", "42"}, metas
|
||||
assert metas["41"].strip() == 'class="meta"', metas["41"]
|
||||
assert metas["42"].strip() == 'class="meta late"', metas["42"]
|
||||
|
||||
|
||||
def test_quest_log_crest_matches_the_spec_geometry() -> None:
|
||||
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
|
||||
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
|
||||
initial in full-opacity white; away swaps only the face gradient.
|
||||
jsdom cannot lay out, so this pins the stylesheet the rendered test
|
||||
above shows is applied."""
|
||||
source = QUEST_LOG.read_text(encoding="utf-8")
|
||||
|
||||
def rule(selector: str) -> str:
|
||||
found = re.search(
|
||||
r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL
|
||||
)
|
||||
assert found is not None, selector
|
||||
return found.group(1)
|
||||
|
||||
crest = rule(".crest")
|
||||
for decl in (
|
||||
"width: 96px;",
|
||||
"height: 110px;",
|
||||
"padding: 4px;",
|
||||
"clip-path: ${unsafeCSS(SHIELD_CLIP)};",
|
||||
"background: rgba(255, 255, 255, 0.22);",
|
||||
):
|
||||
assert decl in crest, decl
|
||||
face = rule(".crest-face")
|
||||
assert "background: var(--nq-p-crest);" in face
|
||||
assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face
|
||||
initial = rule(".crest .initial")
|
||||
for decl in (
|
||||
"font-family: var(--nq-p-font-display);",
|
||||
"font-size: 44px;",
|
||||
"font-weight: 700;",
|
||||
"color: #ffffff;",
|
||||
):
|
||||
assert decl in initial, decl
|
||||
assert "opacity" not in initial
|
||||
away = rule(".crest.away .crest-face")
|
||||
assert away.strip() == "background: var(--nq-p-crest-away);"
|
||||
|
||||
@@ -168,42 +168,6 @@ def test_create_several_children_several_windows_with_due_times(
|
||||
_with_db(tmp_path, "create-many.db")(_body)
|
||||
|
||||
|
||||
def test_create_materializes_future_instances(tmp_path) -> None:
|
||||
"""A fresh create regenerates its rolling horizon immediately.
|
||||
|
||||
Without this the new definition has no ``quest_instances`` rows until
|
||||
some unrelated write regenerates it, so it never reaches the board.
|
||||
"""
|
||||
assert DEFAULT_HORIZON_DAYS >= 7
|
||||
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
today = _today_iso()
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Take out bins",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={0},
|
||||
start_date=today,
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
rows = await database.fetch_all(
|
||||
"SELECT child_id, due_date FROM quest_instances "
|
||||
"WHERE definition_id = ?",
|
||||
(created.definition.id,),
|
||||
)
|
||||
assert rows, "create materialized no instances"
|
||||
assert {row[0] for row in rows} == {child.id}
|
||||
for _child_id, due_date in rows:
|
||||
assert due_date >= today
|
||||
assert datetime.date.fromisoformat(due_date).weekday() == 0
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "create-materializes.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_quest_definition: argument rejections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user