Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 6cb62b0260 Correct deleteDefinition doc: retirement clears future open instances
Replace the over-broad "everything is preserved" with the precise hybrid
contract (NQ-262-2). Comment-only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:44:45 -04:00
overseerandClaude Opus 5.5 ba0b2032cc Correct retirement docs: deactivation clears future open instances
Retirement preserves the definition row, schedule rule, assignees,
windows and all durable completion history, but like any deactivation
it regenerates, so future uncompleted instances are cleared and none
are recreated while inactive. Fixes the "deletes nothing / all
instances preserved" wording in the core docstrings, the admin DELETE
route description, ADMIN-SPEC and the retire test's docstring
(review finding NQ-262-1). No behavioural change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:40:44 -04:00
overseerandClaude Opus 5.5 ed230961e0 Add hybrid admin delete for quest definitions
Owner decision 2026-09-26: "delete if there's no history,
retire/deactivate if there is". A definition with no completion event
on any instance is hard-deleted (instances, assignees, windows, the row
and its unreferenced rule) in one transaction under the connection
lock; one with history is retired via set_quest_definition_active and
everything is preserved (D-005).

- core: QuestDefinitionsDao.delete_if_no_history (atomic history check +
  deletes) and quest_definitions.delete_quest_definition
- api: DELETE /api/v1/admin/quest-definitions/{id} on the admin router
- admin: Delete task control with a hybrid-explaining confirm in the
  edit sheet; the list removes or shows the row inactive
- docs: ADMIN-SPEC Definitions section

Task 3efb07b4-7bfa-45af-b36a-57d81249a39d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:31:10 -04:00
24 changed files with 319 additions and 2022 deletions
-277
View File
@@ -1,282 +1,5 @@
# NestQuest Release Notes
## Version 0.9.7 — 2026-09-29
### Scope
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
task fd1093b5 "Tasks are still showing over due when the local time has not
passed").
### Behaviour
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
the panel's OWN local clock (the wall time where the panel physically is)
compared with the quest's due time, instead of trusting the API snapshot's
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
passed on the panel, regardless of the API host's timezone, Home Assistant's
configured `time_zone`, or whether the 0.9.6 timezone changes have been
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
no due time is never overdue.
- The API's `overdue` field is unchanged; it still serves the admin plane and
automations. This is a panel display correction only.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
### Known limitations
- The comparison uses only wall-clock time. If the API is unreachable across
local midnight and the coordinator serves its last-good snapshot, a quest
from the previous day with a later due time can briefly read as not overdue
until that time passes again. On the normal path the API rolls the household
day over and the missed sweep removes the previous day's open quests.
- The Quest Log header clock still formats in Home Assistant's configured
`time_zone`; if HA is set to a zone other than the household's, that header
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
timezone setting does not affect the header clock.
### Rollback
- No schema change: rolling back to 0.9.6 requires no database restore.
## Version 0.9.6 — 2026-09-28
### Scope
Patch release carrying the household time-zone fix onto `main` (PR #275, task
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
45243478).
### Behaviour
- The Home Assistant integration now reports `hass.config.time_zone` to the API
in an `X-NestQuest-Timezone` header on every panel request.
- While the API's `timezone` setting has never been explicitly chosen
(`timezone_configured` false), the API adopts and persists the reported zone
(under the settings lock), so the panel's overdue/today calculation, the
admin plane and the missed-sweep scheduler are all household-local even when
the API host runs UTC. No manual setup is required.
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
while it is sleeping, and re-reads the stored zone before sweeping, so it no
longer fires at the old (UTC) rollover.
- An admin-chosen zone — including an explicit choice of empty (API-host local)
— still wins over the reported zone. A timezone-only admin update is recorded
as an explicit choice (`timezone_configured` true); an explicitly supplied
`timezone_configured` value in the same update is honored as given. An invalid
reported zone is logged and ignored; the panel keeps serving on the stored
settings.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
- Deployment note: this fix needs BOTH the API service and the HA integration
upgraded. The integration supplies the zone and the API adopts it; upgrading
only one side leaves the previous behaviour. An install that sets or clears
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
stored by an older release.
### Known limitations
- A zone stored by a release before 0.9.6 without the explicit-choice marker
(for example through the previously documented
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
over it once. If a zone different from Home Assistant's is intended, re-save
it in the admin Preferences, which now records the explicit choice.
- If `timezone` is empty and `timezone_configured` is true (an explicit
"host local" choice), that choice is respected and the API host's zone (UTC
on the standard deployment) still applies until an admin selects a zone in
the admin Preferences.
- A change to Home Assistant's own `time_zone` takes effect after the
integration reloads or Home Assistant restarts.
### Rollback
- No schema change: rolling back to 0.9.5 requires no database restore. The API
and the integration may be rolled back independently; rolling back only the
integration restores the previous stored-setting-only behaviour.
## Version 0.9.5 — 2026-09-27
### Scope
Patch release carrying the kids' panel Return button onto `main`.
- **Return button:** the Quest Log header now shows an explicit Return control —
a left chevron plus "The Party", 72px high and sized to its label, sitting left
of the crest — so a child
can go straight back to the main Party board instead of waiting out the 40 s
idle return. It calls the same navigation the idle return uses
(`board_path` + `location-changed`), so there is exactly one navigation path.
It is hidden when no board path is configured, appears on every header-bearing
Quest Log screen (normal, away, and "no quests today"), and does not appear on
the Quest-complete screen, which keeps its 12 s auto-return. The control meets
the panel's hard rules (72px tap target, 26px label, `:active` feedback only,
clear of the window columns and the dock), and the 40 s idle return and
crest-tap navigation are unchanged. (PR #272.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added — only the bundled panel card changed.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.4 — 2026-09-26
### Scope
Patch release fixing the admin timezone control that shipped in 0.9.3.
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
re-detected the browser zone whenever the timezone field was empty, so an admin
could not leave it empty ("use the API host's local time") — closing and
reopening the screen set it again. A new persisted, API-only
`timezone_configured` marker (default `false`) now distinguishes "never set"
from "admin chose empty": the one-time auto-set runs only when the timezone is
empty AND the marker is false, and any saved timezone change — including
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
#268.)
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
as in 0.9.3.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. Settings stored before this release have no `timezone_configured` value
and read it as `false`, so an install whose timezone is empty will auto-set the
browser zone once on the next open — after which the choice persists.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.3 — 2026-09-26
### Scope
Patch release carrying the admin timezone control onto `main`.
- **Admins set the system timezone from the PWA:** the Preferences screen gains a
Timezone field (a text input with a local datalist of common IANA zones;
`UTC` or `Area/Location`). On a fresh install the value is set AUTOMATICALLY
from the browser's IANA zone when the setting is empty on first load — no
geolocation prompt and no network lookup — and an admin can change or clear it
at any time. Only a changed value is sent, and the API's 422 for an unknown zone
is shown by the form. (PR #266.)
- This makes the household-local clock (overdue, daily rollover) configurable
from the UI, replacing the manual
`PATCH /api/v1/admin/settings {"timezone": ...}` step noted in 0.9.1. The
setting still defaults to the API host's local time when left empty.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.2 — 2026-09-26
### Scope
Patch release carrying the admin "Delete task" capability onto `main`.
- **Delete a task in the admin PWA:** the definition edit sheet gains an
admin-only Delete action with hybrid semantics (D-017, owner decision "delete
if there's no history, retire/deactivate if there is"). If the definition has
**no completion history**, it is hard-deleted together with its instances,
assignees, windows and its schedule rule (when unreferenced), all in one
transaction under the connection lock. If it has **any completion history** (a
completion or an un-completion), nothing is deleted: it is retired by
deactivation instead, preserving the definition, rule, assignees, windows and
all history, with future uncompleted instances cleared as with any
deactivation. (PR #262.)
- **API:** `DELETE /api/v1/admin/quest-definitions/{id}` lives on the admin plane
(`require_admin`; a non-admin is refused and an unknown id is 404). The response
reports `deleted`/`retired` and carries the retired definition (`null` when
deleted). The kids' panel gains no control, and `QuestDefinitionsDao` now has
exactly one, history-guarded, delete method.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed beyond the 0.8.0 note. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.1 — 2026-09-26
### Scope
Patch release carrying the owner-reported UI-bug fixes for the kids' panel, plus a
household-timezone correction, onto `main`.
- **Household-local time:** a new `timezone` setting (an IANA name such as
`America/New_York`; empty keeps the API host's local time) is threaded into the
API's clock reads, so overdue and the daily rollover use the household's day
rather than a UTC host's. **Set it once after upgrading** (see the Upgrade
note); the admin UI has no field for it yet. (PR #254.)
- **Overdue and rollover correctness:** quests are no longer marked overdue
before their due time, and the missed-sweep scheduler fires at the configured
rollover across DST transitions — its delay is now a true elapsed-time
difference, with documented handling of nonexistent/ambiguous local times.
(PRs #254, #257.)
- **Complete works on the panel:** a TypeScript ES2022 `useDefineForClassFields`
default emitted native class fields that shadowed Lit's reactive accessors, so
the confirm dialog never re-rendered and a tap appeared to do nothing; the
panel now opens the confirm and completes. (PR #254.)
- **Panel polish:** the party-board hint line no longer hides behind the dock
(#244); the Quest Log header crest renders with its shield classes (#252); the
party-board wordmark is the new compass-rose monogram plus Cinzel Decorative
text, replacing the d20 dice (#253); and the quest meta line keeps its `meta`
class when not late (#255).
### Upgrade note
The database schema is unchanged at version **9**. After upgrading, set the
household timezone once so date, overdue and rollover behaviour match the house
(otherwise it falls back to the API host's local time, which may be UTC):
```
PATCH /api/v1/admin/settings {"timezone": "America/New_York"}
```
No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.0 — 2026-09-26
### Scope
-2
View File
@@ -17,8 +17,6 @@ export interface HouseholdSettings {
afternoon_reminder_enabled: boolean;
end_of_day_report_enabled: boolean;
celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
}
/** Only the supplied fields are updated; the API answers with all of them. */
-6
View File
@@ -258,12 +258,6 @@
color: var(--nq-a-ink-secondary);
}
.settings-hint {
margin: 0;
font-size: 12.5px;
color: var(--nq-a-ink-secondary);
}
.settings-input {
width: 100%;
min-height: var(--nq-a-tap-min);
+1 -280
View File
@@ -1,4 +1,3 @@
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
@@ -22,7 +21,6 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
timezone: "America/Chicago",
};
function child(
@@ -372,11 +370,10 @@ describe("SettingsScreen — preferences", () => {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all eleven settings form fields", async () => {
it("renders the current value of the ten settings form fields (timezone is API-only)", async () => {
const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00");
expect(field(form, "Timezone").value).toBe("America/Chicago");
expect(field(form, "Notify target").value).toBe("notify.family");
expect(field(form, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
@@ -499,49 +496,6 @@ describe("SettingsScreen — preferences", () => {
expect(writes()).toHaveLength(1);
});
it("a timezone picked from the list patches exactly the timezone", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
});
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
"an invalid other timezone %j is rejected before any request",
async (value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.",
);
expect(field(form, "Other timezone").value).toBe(value);
expect(writes()).toEqual([]);
},
);
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
writeResponse = () =>
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toBe(
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
);
expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Other timezone").disabled).toBe(false);
expect(settings.timezone).toBe("America/Chicago");
});
it("a failed load shows the reason and Try again refetches", async () => {
let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
@@ -558,236 +512,3 @@ describe("SettingsScreen — preferences", () => {
await screen.findByRole("form", { name: "Preferences" });
});
});
describe("SettingsScreen — no timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) {
const real = Intl.DateTimeFormat.prototype.resolvedOptions;
zoneSpy = vi
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockImplementation(function (this: Intl.DateTimeFormat) {
return { ...real.call(this), timeZone } as Intl.ResolvedDateTimeFormatOptions;
});
}
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
// A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
zoneSpy?.mockRestore();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
it("an empty timezone stays empty on load and nothing is written", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
expect(settings.timezone).toBe("");
});
it("StrictMode's doubled effects write nothing either", async () => {
mockBrowserZone("America/New_York");
render(
<StrictMode>
<SettingsScreen onClose={vi.fn()} />
</StrictMode>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
});
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]);
});
it("saving another field sends only that field, never a timezone", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
expect(settings.timezone).toBe("");
});
it("an admin clearing the timezone saves exactly the empty zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
});
});
describe("SettingsScreen — manual timezone control", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
function save(form: HTMLElement) {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
/** The Timezone control, which must be a real select, not a free-text box. */
function picker(form: HTMLElement): HTMLSelectElement {
const control = within(form).getByRole("combobox", { name: "Timezone" });
expect(control).toBeInstanceOf(HTMLSelectElement);
return control as HTMLSelectElement;
}
it("is a picker of curated zones with host-local and other-zone choices", async () => {
const form = await renderForm();
const select = picker(form);
const values = Array.from(select.options).map((o) => o.value);
expect(values[0]).toBe("");
expect(select.options[0].textContent).toBe("API host local time (no zone set)");
for (const zone of [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
]) {
expect(values).toContain(zone);
}
expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…");
expect(select.value).toBe("");
expect(within(form).queryByLabelText("Other timezone")).toBeNull();
});
it("explains that it is the application timezone for every surface", async () => {
const form = await renderForm();
const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? "");
expect(help?.textContent).toMatch(/application timezone/);
expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/);
expect(help?.textContent).toMatch(/API host's local time/);
});
it("a picked zone saves via PATCH and is shown after a reload", async () => {
const form = await renderForm();
expect(writes()).toEqual([]);
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("America/New_York");
expect(writes()).toHaveLength(1);
});
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "__other__" } });
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
expect(entry.value).toBe("");
fireEvent.change(entry, { target: { value: " America/Boise " } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("__other__");
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
"America/Boise",
);
});
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
const form = await renderForm();
expect(picker(form).value).toBe("__other__");
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
"Asia/Kathmandu",
);
expect(writes()).toEqual([]);
});
it("choosing API host local time clears the zone and persists after a reload", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
const form = await renderForm();
expect(picker(form).value).toBe("America/Denver");
fireEvent.change(picker(form), { target: { value: "" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("");
});
it("picking a zone and then the saved one again sends nothing", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
fireEvent.change(picker(form), { target: { value: "UTC" } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
expect(writes()).toEqual([]);
});
it("never sends a timezone_configured field", async () => {
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
save(form);
await screen.findByText("Preferences saved.");
const again = screen.getByRole("form", { name: "Preferences" });
fireEvent.change(picker(again), { target: { value: "" } });
save(again);
await waitFor(() => expect(writes()).toHaveLength(2));
for (const write of writes()) {
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
expect(write.body).not.toHaveProperty("timezone_configured");
}
});
});
+1 -95
View File
@@ -5,9 +5,7 @@
* household's child profiles. Every write refetches the list.
*
* Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. The
* timezone — the application's source of local time — is only ever set by
* the admin picking or typing a zone here and saving it.
* notifications). Save patches only the changed fields.
*
* One write at a time across both sections.
*/
@@ -254,46 +252,6 @@ const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] =
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"America/Halifax",
"America/St_Johns",
"America/Toronto",
"America/Vancouver",
"America/Mexico_City",
"America/Sao_Paulo",
"Europe/London",
"Europe/Dublin",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Athens",
"Africa/Johannesburg",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Perth",
"Australia/Sydney",
"Pacific/Auckland",
];
/** The picker's "Other IANA zone…" option; never sent to the API. */
const OTHER_ZONE = "__other__";
/** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -317,16 +275,11 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
for (const [key, label] of times) {
if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`;
}
const timezone = draft.timezone.trim();
if (timezone && !IANA_ZONE.test(timezone)) {
return "Timezone must be an IANA zone like America/New_York, or UTC.";
}
const next: HouseholdSettings = {
...draft,
horizon_days: horizonDays,
notify_target: draft.notify_target.trim(),
timezone,
};
const changes: SettingsChanges = {};
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
@@ -352,10 +305,6 @@ function PreferencesForm({
}) {
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
// A saved zone outside the curated list opens in the free-text entry.
const [otherZone, setOtherZone] = useState(
() => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone),
);
const disabled = saving || locked;
function submit(event: FormEvent) {
@@ -408,49 +357,6 @@ function PreferencesForm({
/>
</label>
{timeInput("day_rollover_time", "Day rollover")}
<div className="settings-field">
<label className="settings-label" htmlFor="settings-timezone">
Timezone
</label>
<select
id="settings-timezone"
className="settings-input"
aria-describedby="settings-timezone-help"
value={otherZone ? OTHER_ZONE : draft.timezone}
disabled={disabled}
onChange={(event) => {
const choice = event.target.value;
setOtherZone(choice === OTHER_ZONE);
if (choice !== OTHER_ZONE) update({ timezone: choice });
}}
>
<option value="">API host local time (no zone set)</option>
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
<option value={OTHER_ZONE}>Other IANA zone…</option>
</select>
{otherZone ? (
<input
className="settings-input"
type="text"
aria-label="Other timezone"
placeholder="Area/Location, e.g. America/Boise"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
) : null}
<p className="settings-hint" id="settings-timezone-help">
The application timezone: the source of local time for the panel, the scheduler and this
admin app. It changes only when you save it here; with no zone set, the API host&apos;s
local time is used.
</p>
</div>
<label className="settings-field">
<span className="settings-label">Notify target</span>
<input
+3 -3
View File
@@ -168,9 +168,9 @@ are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all eleven
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times, the four enable
toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local; only an
admin's ``PATCH`` ever sets it) — the defaults on a fresh database.
toggles and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local) — the
defaults on a fresh database.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the
+4 -5
View File
@@ -9,8 +9,8 @@ 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
household ``timezone`` the admin stored — the API host may run UTC
while the household does not), calls the pure
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
@@ -185,9 +185,8 @@ async def _household_clock(
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`). The zone
comes from the stored settings ONLY — nothing the request carries
can change it.
``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())
+13 -100
View File
@@ -13,27 +13,6 @@ 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).
A household zone changed while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
wakes the sleeping loop to re-plan whenever the zone or rollover time
it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed settings write has
replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So a write commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting
writer cannot deadlock: it is delayed at most by one settings
read or one sweep.
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
@@ -145,14 +124,6 @@ class MissedSweepScheduler:
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
#: ``(timezone, day_rollover_time)`` the pending sweep was
#: planned with; ``None`` until the first plan.
self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event()
#: Serialises settings writes with planning and sweeping (see
#: the module docstring's guarantee).
self.settings_lock = asyncio.Lock()
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
@@ -171,20 +142,6 @@ class MissedSweepScheduler:
pass
self._task = None
def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. an admin's zone
change), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the
settings and schedules the next sweep for the new household
rollover; an unchanged plan is left sleeping.
"""
if (settings.timezone, settings.day_rollover_time) != self._planned:
self._wake.set()
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
@@ -194,18 +151,12 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
# Under the lock no settings write can commit between this read
# and the recorded plan, so any later one wakes the wait.
async with self.settings_lock:
self._wake.clear()
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
plan = (settings.timezone, settings.day_rollover_time)
self._planned = plan
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
@@ -227,30 +178,15 @@ class MissedSweepScheduler:
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
if not await self._sleep_unless_woken(delay):
continue
# Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The
# lock keeps a settings write from committing between this
# check and the sweep.
async with self.settings_lock:
settings = await core_settings_store.load_settings(
self._database
)
if (
settings.timezone,
settings.day_rollover_time,
) != plan:
continue
await self._run_once(
settings.household_now(self._clock()).date()
)
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception:
# Not silent either: a failed wait or sweep is logged and
# the loop backs off before the next cycle, whose
# watermark re-read makes an already-swept day a no-op.
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
@@ -258,29 +194,6 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _sleep_unless_woken(self, delay: float) -> bool:
"""Wait ``delay``; ``False`` if :meth:`settings_changed` woke it.
Both children are cancelled AND awaited before returning (or
before a cancellation propagates), so none outlives :meth:`stop`;
a sleep that failed re-raises its exception here for the loop's
retry policy.
"""
sleeper = asyncio.ensure_future(self._sleep(delay))
waker = asyncio.ensure_future(self._wake.wait())
try:
await asyncio.wait(
{sleeper, waker}, return_when=asyncio.FIRST_COMPLETED
)
finally:
slept = sleeper.done()
sleeper.cancel()
waker.cancel()
await asyncio.gather(sleeper, waker, return_exceptions=True)
if slept:
sleeper.result()
return slept
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
+2 -6
View File
@@ -217,10 +217,6 @@ class NestQuestApiClient:
self._session = session
self._timeout_seconds = float(timeout_seconds)
def _headers(self) -> dict[str, str]:
"""The Bearer credential every panel-plane request carries."""
return {"Authorization": f"Bearer {self._panel_token}"}
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
@@ -279,7 +275,7 @@ class NestQuestApiClient:
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = self._headers()
headers = {"Authorization": f"Bearer {self._panel_token}"}
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
@@ -383,7 +379,7 @@ class NestQuestApiClient:
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = self._headers()
headers = {"Authorization": f"Bearer {self._panel_token}"}
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
+4 -4
View File
@@ -113,10 +113,10 @@ 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 is the API host's own
# local time. Only an admin sets it (manually, from the admin app);
# nothing sets it automatically. The integration itself always uses
# Home Assistant's ``time_zone`` and ignores this field.
# 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 = ""
+5 -7
View File
@@ -314,14 +314,12 @@ class NestQuestSettings:
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone.
The API service does not run inside Home Assistant, so its one
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 admin's choice, or else the zone the
integration reports from Home Assistant — 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).
: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
@@ -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 TWELVE 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:
@@ -33,8 +33,7 @@ after the first update:
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 an
admin's explicit update ever writes it; nothing sets it automatically.
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.
@@ -197,27 +196,18 @@ async def update_settings(
)
async with _settings_lock(database):
current = await load_settings(database)
return await _write_merged(database, current, changes)
async def _write_merged(
database: NestQuestDatabase,
current: NestQuestSettings,
changes: Mapping[str, Any],
) -> NestQuestSettings:
"""Validate ``changes``, merge onto ``current``, persist; lock held."""
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document)
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.9.7"
"version": "0.9.0"
}
File diff suppressed because it is too large Load Diff
-8
View File
@@ -71,14 +71,6 @@ and time only rather than removing it (the strip is part of the frame).
Reached by tapping a present crest. Header at y=56, page inset 62px:
- **Return control** first in the header, left of the crest: a `--nq-p-button-height`
72px button, radius 10px, `rgba(92,62,26,.1)` with a 2px `rgba(92,62,26,.5)` border
(the confirm dialog's **Not yet** treatment), `chevron-left` 34px + `The Party`
26px/700 Cinzel `--nq-p-ink`, 28px header gap to the crest. Tapping it takes the same
path as the idle return (§3.5) — navigate to `board_path` — immediately. Press
feedback `scale(.98)`, `--nq-dur-micro`. Not rendered when no `board_path` is
configured. The party board is the main display and has no return control; the
Quest complete screen (§5) keeps its auto-return.
- Small crest 96×110 (initial 44px/700 display).
- Title `<Name>'s Quest Log` 56px/700 display.
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
+4 -81
View File
@@ -36,6 +36,7 @@ type PanelInstance = {
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
@@ -153,17 +154,6 @@ function formatWallClock(value: string | null): string | null {
return `${hour12}:${match[2]} ${suffix}`;
}
/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */
function wallClockMinutes(value: string | null): number | null {
const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null;
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours > 23 || minutes > 59 ? null : hours * 60 + minutes;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -263,6 +253,7 @@ function toPanelInstance(value: unknown): PanelInstance | null {
window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null,
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
@@ -383,37 +374,6 @@ const logStyles = css`
gap: 28px;
}
button.return {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: var(--nq-p-button-height);
padding: 0 28px 0 20px;
border: 2px solid rgba(92, 62, 26, 0.5);
border-radius: 10px;
background: rgba(92, 62, 26, 0.1);
color: var(--nq-p-ink);
font-family: var(--nq-p-font-heading);
font-size: 26px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
button.return:active {
transform: scale(0.98);
}
button.return svg {
width: 34px;
height: 34px;
}
.crest {
position: relative;
flex: none;
@@ -1239,18 +1199,6 @@ const ICON_X = html`<svg
<path d="m6 6 12 12"></path>
</svg>`;
const ICON_CHEVRON_LEFT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"></path>
</svg>`;
const ICON_STAR = html`<svg
viewBox="0 0 24 24"
fill="none"
@@ -1758,20 +1706,6 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
/** Overdue by the panel's own clock: the browser runs on the
household's real local time, so an open quest turns overdue once its
``due_time`` wall time has arrived there — whatever zone the API or
Home Assistant computed the API's ``overdue`` flag in. With no due
time it is never overdue (the API's rule too). ``_now`` is reactive
and ticks every 60 s, so the line flips on the minute it falls due. */
private _isOverdue(instance: PanelInstance): boolean {
const due = wallClockMinutes(instance.due_time);
if (instance.state !== "open" || due === null) {
return false;
}
return this._now.getHours() * 60 + this._now.getMinutes() >= due;
}
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
}
@@ -1856,6 +1790,7 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2009,18 +1944,6 @@ export class NestQuestQuestLogCard extends LitElement {
const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
${this._boardPath()
? html`
<button
class="return"
type="button"
@click=${() => this._returnToBoard()}
>
${ICON_CHEVRON_LEFT}
<span>The Party</span>
</button>
`
: nothing}
<span class="crest${away ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
@@ -2152,7 +2075,7 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
const dueAt = formatWallClock(instance.due_time);
const meta = this._isOverdue(instance)
const meta = instance.overdue
? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true,
+1 -59
View File
@@ -14,14 +14,6 @@
* 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 tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and
* reports it as returned: {rendered, label} before the final snapshot.
* With now: <ISO instant> the card's clock is pinned there (argument-less
* new Date() and Date.now() return it; the process TZ decides its local
* wall time), and with advance_to: [<ISO instant>, ...] the clock moves to
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
* one snapshot per instant reported as ticks (the top-level snapshot is
* taken before the clock moves).
* The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
@@ -53,33 +45,6 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
// Pin the clock: argument-less construction and Date.now() read `pinned`,
// every other Date use is the real class.
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
if (pinned !== null) {
const RealDate = Date;
class PinnedDate extends RealDate {
constructor(...args) {
super(...(args.length === 0 ? [pinned] : args));
}
static now() {
return pinned;
}
}
globalThis.Date = PinnedDate;
dom.window.Date = PinnedDate;
}
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
const clockTicks = [];
const realSetInterval = dom.window.setInterval.bind(dom.window);
dom.window.setInterval = (callback, ms, ...rest) => {
if (ms === 60000) {
clockTicks.push(callback);
}
return realSetInterval(callback, ms, ...rest);
};
await import(pathToFileURL(spec.bundle).href);
const serviceCalls = [];
@@ -110,17 +75,6 @@ if (spec.tap_complete !== undefined) {
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
}
let returned = null;
if (spec.tap_return) {
const control = element.shadowRoot.querySelector(".header button.return");
returned = {
rendered: control !== null,
label: control?.textContent?.trim() ?? "",
};
control?.click();
await element.updateComplete;
}
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
@@ -156,18 +110,6 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const settled = snapshot();
let ticks = null;
if (spec.advance_to !== undefined) {
ticks = [];
for (const instant of spec.advance_to) {
pinned = new Date(instant).getTime();
clockTicks.forEach((callback) => callback());
await element.updateComplete;
ticks.push(snapshot());
}
}
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
const payload = JSON.stringify({ ...snapshot(), probe, tap });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.7"
version = "0.9.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+2 -112
View File
@@ -13,8 +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,
with no ``timezone_configured`` marker.
— 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),
@@ -141,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 twelve
# ...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
@@ -223,105 +222,6 @@ async def test_null_resets_a_field_to_its_default(
assert reread.json()["horizon_days"] == 14
async def test_default_settings_carry_no_timezone_configured_marker(
settings_client: SimpleNamespace,
) -> None:
"""The removed auto-set marker is absent from the default response."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert "timezone_configured" not in response.json()
assert response.json()["timezone"] == ""
@pytest.mark.parametrize("zone", ["America/New_York", ""])
async def test_manual_timezone_patch_persists_and_is_returned(
settings_client: SimpleNamespace, zone: str
) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
client = settings_client.client
seeded = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone": zone}
assert patched.json() == expected
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == expected
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
stored = json.loads(raw)
assert stored["timezone"] == zone
assert "timezone_configured" not in stored
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
async def test_timezone_alone_sticks_on_a_fresh_database(
settings_client: SimpleNamespace, zone: str
) -> None:
"""A lone ``timezone`` PATCH needs no other field to persist."""
client = settings_client.client
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == {**DEFAULT_SETTINGS, "timezone": zone}
async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace,
) -> None:
"""The removed marker is rejected like any unknown field; nothing is written."""
response = await settings_client.client.patch(
"/api/v1/admin/settings",
json={"timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone_configured" in str(response.json()["detail"])
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is None
async def test_stored_legacy_timezone_configured_key_is_ignored(
settings_client: SimpleNamespace,
) -> None:
"""A document written before the marker was removed still loads."""
await _dao_meta.MetaStateDao(settings_client.database).set(
SETTINGS_META_KEY,
json.dumps(
{"timezone": "America/Chicago", "timezone_configured": True}
),
)
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
# --- validation: rejected values never change the stored settings -------
@@ -338,9 +238,6 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"),
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
({"timezone": "America/New York"}, "timezone"),
({"timezone": 5}, "timezone"),
],
)
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
@@ -495,13 +392,6 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
)
assert absent_patch.status_code == 401
forbidden_zone = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/New_York"},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_zone.status_code == 403
# No refused request changed anything: the settings are untouched.
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
-25
View File
@@ -196,29 +196,6 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_requests_carry_only_the_bearer_header() -> None:
"""No timezone is reported to the API (task 7f685179): the Bearer
credential is the only header on every request."""
transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'),
)
client = make_client(transport)
await client.get_snapshot()
await client.complete_instance(42, 3)
for call in transport.calls:
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
def test_client_takes_no_household_timezone() -> None:
with pytest.raises(TypeError):
NestQuestApiClient(
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
)
async def test_base_url_trailing_slash_is_normalized() -> None:
"""A base URL with a trailing slash must not double the path slash."""
transport = StubTransport(StubResponse(200, "{}"))
@@ -415,13 +392,11 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
}
)
# A bare object: building the client must not read hass.config.
client = api_client.client_from_entry(object(), entry)
assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token"
assert client._headers() == {"Authorization": "Bearer entry-token"}
def test_client_from_entry_unconfigured_token_raises_at_construction(
+1 -462
View File
@@ -19,8 +19,6 @@ container's zone) and sets the household zone through the real admin
An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time.
Nothing sets the zone automatically: a timezone header on a panel
request never changes the stored settings (task 7f685179).
"""
from __future__ import annotations
@@ -33,7 +31,6 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import scheduler as scheduler_module
from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds
from tests.admin_jwt_harness import (
@@ -49,7 +46,6 @@ _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")
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
@@ -87,12 +83,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
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,
database=runner.app.state.db.database,
)
yield SimpleNamespace(client=client, seed=seed, clock=clock)
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
@@ -245,16 +236,6 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
)
def test_settings_have_no_timezone_configured_marker() -> None:
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
with pytest.raises(TypeError):
_settings.NestQuestSettings(timezone_configured=True)
# A stored document still carrying the removed key loads harmlessly.
assert _settings.NestQuestSettings.from_options_resilient(
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
# --- the missed-sweep scheduler ----------------------------------------------
@@ -326,445 +307,3 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
now: datetime.datetime, target: datetime.time, expected: float
) -> None:
assert _delay_seconds(target, now) == expected
# --- nothing sets the zone automatically (task 7f685179) --------------------
#
# The zone is only ever set by an admin's explicit settings update. A
# timezone header on a panel request — the one Home Assistant used to
# report, or any look-alike — is ignored, and no read writes settings.
#: Timezone-looking request headers a panel request might carry.
_TIMEZONE_HEADERS = [
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
{"X-Timezone": HOUSEHOLD_ZONE},
{"Time-Zone": HOUSEHOLD_ZONE},
]
async def _stored_document(household: SimpleNamespace) -> str | None:
"""The raw stored settings document (``None`` when never written)."""
return await _dao_meta.MetaStateDao(household.database).get(
_settings_store.SETTINGS_META_KEY
)
async def _admin_settings(household: SimpleNamespace) -> dict:
response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
return response.json()
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_changes_settings(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
household.clock["now"] = _utc(13, 59, 59)
headers = {**_panel_headers(), **extra}
snapshot = await household.client.get(
"/api/v1/panel/snapshot", headers=headers
)
assert snapshot.status_code == 200
seed = household.seed
completed = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=headers,
json={"actor_child_id": seed.ada.id},
)
assert completed.status_code == 200
# Nothing was written, and the clock stayed host local (UTC): the
# 10:00 quest was overdue and the 13:59 UTC completion was late.
assert await _stored_document(household) is None
assert (await _admin_settings(household))["timezone"] == ""
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is False
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_replaces_the_stored_zone(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""A stored zone stays exactly as the admin saved it."""
await _set_zone(household, "America/Los_Angeles")
before = await _stored_document(household)
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
assert await _stored_document(household) == before
_today_iso, instance = await _pack_bag(household, "panel")
assert instance["overdue"] is False
async def test_fresh_install_zone_stays_empty_across_reads(
household: SimpleNamespace,
) -> None:
"""Repeated panel and admin reads never write the settings document."""
for hour in (3, 13, 23):
household.clock["now"] = _utc(hour, 0)
for extra in _TIMEZONE_HEADERS:
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
await _pack_bag(household, "admin")
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert "timezone_configured" not in settings
assert await _stored_document(household) is None
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
#
# On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone stored while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight.
_UTC_MIDNIGHT_DELAY = 11 * 3600
#: 13:00 UTC (09:00 EDT) to New York midnight (04:00 UTC).
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def _store_zone(scheduler, database, zone: str) -> None:
"""Store ``zone`` the way a settings writer must: under the lock."""
async with scheduler.settings_lock:
settings = await _settings_store.update_settings(
database, {"timezone": zone}
)
scheduler.settings_changed(settings)
async def test_scheduler_replans_when_zone_changes_while_sleeping(
temp_db_path: str,
) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5
)
finally:
await scheduler.stop()
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_scheduler_rechecks_settings_before_sweeping(
temp_db_path: str, monkeypatch
) -> None:
"""A zone stored without a wake-up still stops the stale sweep."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back.
await _settings_store.update_settings(
database, {"timezone": HOUSEHOLD_ZONE}
)
return
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
finally:
await database.close()
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
#
# A zone write racing the loop's own settings reads must never leave a
# stale plan behind. Each test holds the loop at one of its reads and
# issues a locked zone write there; when the scheduler's settings lock
# is held at that point the write provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window.
def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
"""Run ``during(settings)`` inside the scheduler task's ``nth`` read."""
real_load = _settings_store.load_settings
reads = {"count": 0}
async def load_settings(database):
settings = await real_load(database)
if asyncio.current_task() is scheduler._task:
reads["count"] += 1
if reads["count"] == nth:
await during(settings)
return settings
monkeypatch.setattr(
scheduler_module.core_settings_store, "load_settings", load_settings
)
return real_load
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
"""Start a locked write of ``zone``; let it commit unless serialised."""
write = asyncio.create_task(_store_zone(scheduler, database, zone))
if not scheduler.settings_lock.locked():
await write
return write
async def test_scheduler_follows_successive_zone_changes(
temp_db_path: str, monkeypatch
) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
database = app_state.db.database
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
writes: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None:
writes.append(
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
)
# Read 1 plans New York; read 2 is the re-plan after UTC.
_hold_scheduler_read(monkeypatch, scheduler, 2, back_to_new_york)
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(scheduler, database, "UTC")
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5
)
(write,) = writes
await write
finally:
await scheduler.stop()
# The in-flight read planned UTC; the New York write that followed
# it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch
) -> None:
"""No sweep runs once a zone write has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
return # the UTC rollover arrives
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
writes: list[asyncio.Task] = []
async def store_new_york(_settings) -> None:
writes.append(
await _store_inside_read(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
)
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, store_new_york
)
swept_under: list[str] = []
async def sweep_stub(database, *, today):
swept_under.append((await real_load(database)).timezone)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
(write,) = writes
await write
finally:
await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after
# New York had been stored; the next plan is New York midnight.
assert swept_under == [""]
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
# --- the wait's child tasks (TZ-004) ------------------------------------------
async def test_failed_sleep_is_retried_not_swept(
temp_db_path: str, monkeypatch
) -> None:
"""A sleep that raises backs off and re-plans; nothing is swept."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
raise RuntimeError("timer failed")
if len(delays) == 2:
return # the retry backoff
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
scheduler_module._RETRY_BACKOFF_SECONDS,
pytest.approx(_UTC_MIDNIGHT_DELAY),
]
finally:
await database.close()
async def test_stop_waits_for_the_sleep_to_finish_cancelling(
temp_db_path: str,
) -> None:
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sleeping = asyncio.Event()
sleepers: list[asyncio.Task] = []
cleaned_up: list[bool] = []
async def sleep_stub(_seconds: float) -> None:
sleepers.append(asyncio.current_task())
sleeping.set()
try:
await asyncio.Event().wait()
finally:
# Cancellation cleanup that takes a loop turn.
await asyncio.sleep(0)
cleaned_up.append(True)
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(sleeping.wait(), timeout=5)
finally:
await scheduler.stop()
(sleeper,) = sleepers
assert sleeper.done()
assert cleaned_up == [True]
finally:
await database.close()
-2
View File
@@ -686,8 +686,6 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory
)
assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is not reported to the API (task 7f685179).
assert real_client._headers() == {"Authorization": "Bearer tok"}
set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True
+10 -143
View File
@@ -21,7 +21,6 @@ end-to-end through the quest-log harness.
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
@@ -303,15 +302,14 @@ def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
".quest.tappable:active",
"button.complete:active",
"button.confirm-button:active",
"button.return:active",
):
start = style.index(selector)
block = style[start : style.index("}", start)]
assert "transform: scale(0.98);" in block, selector
assert (
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
== 4
) # quest card, Complete button, confirm buttons, header return control
== 3
) # quest card, Complete button, confirm buttons
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
start = board_style.index("button.plate:active")
block = board_style[start : board_style.index("}", start)]
@@ -831,10 +829,6 @@ def _render_quest_log(
idle_ms: int | None = None,
probe_ms: int | None = None,
tap_complete: int | None = None,
tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
@@ -844,10 +838,7 @@ def _render_quest_log(
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given). ``now`` pins the card's clock to an ISO
instant, ``advance_to`` moves it through further instants firing the
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
sets the node process's ``TZ`` — the panel browser's local zone.
``probe_ms`` was given).
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
@@ -863,19 +854,12 @@ def _render_quest_log(
spec["probe_ms"] = probe_ms
if tap_complete is not None:
spec["tap_complete"] = tap_complete
if tap_return:
spec["tap_return"] = True
if now is not None:
spec["now"] = now
if advance_to is not None:
spec["advance_to"] = advance_to
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
)
assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}"
@@ -939,40 +923,6 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None:
assert result["location"] == "/nestquest"
def test_quest_log_return_control_navigates_to_the_board() -> None:
"""PANEL-SPEC §3: the Quest Log header carries an explicit return
control labelled for a child. Tapping it takes the same path as the
idle return — the strategy-computed board_path (the dashboard root) —
without waiting out the idle window."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
result = _render_quest_log(
generated["views"][1]["cards"][0],
_three_child_states(),
url="http://homeassistant.local/nestquest/ada",
tap_return=True,
)
assert result["headline"] == "Ada's Quest Log"
assert result["returned"] == {"rendered": True, "label": "The Party"}
assert result["location"] == "/nestquest"
def test_quest_log_return_control_follows_the_panel_hard_rules() -> None:
"""The return control is a 72px button with :active feedback only,
lives in the header (never the dock), and reuses the idle return's
_returnToBoard navigation rather than a second mechanism."""
log = _read(QUEST_LOG)
style = _style_literal(log, STYLE_CONSTANTS[QUEST_LOG])
rules = {selector.strip(): body for selector, body in _css_rules(style)}
assert "height: var(--nq-p-button-height);" in rules["button.return"]
assert "transform: scale(0.98);" in rules["button.return:active"]
header = log[log.index("private _renderHeader") : log.index("private _renderColumn")]
assert 'class="return"' in header
assert "@click=${() => this._returnToBoard()}" in header
assert log.count("this._returnToBoard()") == 4
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
"""The 1c screen renders with its countdown line and auto-returns to
the strategy-computed board path when it reaches zero. The design
@@ -1085,12 +1035,9 @@ def test_quest_log_crest_keeps_its_shield_classes() -> None:
)
header = result["html"][result["html"].index('<header class="header">') :]
crest = re.match(
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*'
r'(?:<button class="return"[^>]*>.*?</button>\s*(?:<!--[^>]*-->\s*)*)?'
r'<span ([^>]*)>\s*'
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
r'<span class="crest-face">\s*<span class="initial">',
header,
re.DOTALL,
)
assert crest is not None, header[:600]
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
@@ -1102,13 +1049,12 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
``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 — overdue by the panel's pinned clock
(07:15 local: the 07:00 quest is late, the 08:15 one is not)."""
``class="meta late"`` overdue."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str) -> dict:
def quest(instance_id: int, title: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
@@ -1116,9 +1062,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"due_time": "08:15",
"state": "open",
"overdue": False,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1131,8 +1077,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", "08:15"),
quest(42, "Brush Teeth", "07:00"),
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
],
),
}
@@ -1140,8 +1086,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
tz="America/New_York",
)
metas = {}
for card in result["html"].split('data-instance-id="')[1:]:
@@ -1158,83 +1102,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
assert metas["42"].strip() == 'class="meta late"', metas["42"]
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
"""Map each rendered open quest's instance id to its meta line's
attributes and text."""
metas = {}
for card in html.split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
card,
re.S,
)
assert meta is not None, card[:800]
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
return metas
def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"""Overdue is decided by the panel's own local clock, not the API's
``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM
America/New_York: the API's flag had been computed on a UTC wall clock
(11:15). Here the API still says ``overdue: true`` and Home
Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest
is due, at 08:15 (and 08:16, via the card's own clock tick) it is
overdue, and a quest with no due time is never overdue."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str | None) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"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(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"],
tz="America/New_York",
)
due_by = ('class="meta"', "Due by 8:15 AM")
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
not_due = ('class="meta"', "Due today")
at_0715, at_0815, at_0816 = (
_quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]),
)
assert at_0715 == {"42": due_by, "43": not_due}, at_0715
assert at_0815 == {"42": overdue, "43": not_due}, at_0815
assert at_0816 == {"42": overdue, "43": not_due}, at_0816
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
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.7"
version = "0.9.0"
source = { virtual = "." }
[package.dev-dependencies]