Compare commits

..
Author SHA1 Message Date
overseer 999597bfd7 Assert the panel motion contract and hard rules, not just build them
The confirm dialog (fade + scale .96->1 over --nq-dur-modal/--nq-ease-out),
the seal (fade + scale 1.15->1 over --nq-dur-base), the quest-complete
12 s countdown return and the 40 s idle return already matched
PANEL-SPEC, but nothing pinned them: a token or keyframe edit could
silently drift the panel off the approved mockup timing.

tests/test_panel_cards.py now asserts the entrance timing and easing,
the byte-identical served token copy, and the section 1/6 hard rules
(22px body floor, token-only inks, no hover states, no animation loops,
inert dock strip, whole-plate tap target, control-free away plate), and
the quest-log harness drives the 1c countdown return end-to-end against
the bundle HACS ships.
2026-09-23 11:25:49 -04:00
dev_agent 881f48de43 Navigation: crest tap to child log and idle return (#177) 2026-09-23 11:10:17 -04:00
dev_agent 6d5a730cc7 Auto-register the dashboard on setup (#176) 2026-09-23 10:55:09 -04:00
dev_agent 6d570e1c0e Dashboard strategy generating the views (#175) 2026-09-23 10:06:05 -04:00
dev_agent 316503cf73 Feature 20: Cards auto-discover children from the household roster (#174) 2026-09-23 09:40:52 -04:00
9 changed files with 110 additions and 655 deletions
+71 -132
View File
@@ -159,50 +159,9 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
Run these steps in order to display the NestQuest panel on a TouchHub
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
is simply a Home Assistant client. The path below is zero-config: the
integration registers its panel dashboard itself on setup, so no YAML
and no hand-built views are involved.
is simply a Home Assistant client.
1. **Install the integration and add the config entry.** Install
NestQuest via HACS or manually (see Installation above) and restart
Home Assistant. Then, in *Settings → Devices & Services*, click
**Add Integration**, search for **NestQuest**, and submit the flow —
it has no fields; it just creates the single config entry. Then open
the entry, click **Configure**, and fill the panel-plane API
connection in the options flow:
- **NestQuest API base URL** — the full `http://host:port` URL of
your NestQuest API service (default `http://127.0.0.1:8000`).
- **Panel service token** — the token the panel presents to the API
service as its Bearer credential. It may stay empty until you have
one; a pasted token must be unpadded.
The form's remaining fields (panel idle timeout, coordinator update
interval, last-good snapshot staleness) have defaults that serve the
panel and need no change here.
2. **The panel dashboard appears automatically.** On setup the
integration registers one storage-mode Lovelace dashboard: a sidebar
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
config is nothing but the **`custom:nestquest-party` strategy**
(`strategy: { type: custom:nestquest-party }`). The strategy
generates the views at render time from the household roster:
- **The Party** — the party-board view at `/nestquest/party`; the
dashboard root `/nestquest` shows it as the first view.
- one quest-log view per child at `/nestquest/<child-slug>`
("Ada's Quest Log", …), in roster order.
Nothing is hand-configured: no YAML, no manual views, no
`child_order`, no `quest_log_path`/`board_path` — the board and the
quest logs compute their navigation paths from the dashboard's own
URL — and every generated view is already in **Panel** layout (see
the two constraints below). If the Lovelace surfaces are unavailable
(for example when Lovelace runs in YAML mode), setup logs a warning
and skips the registration; create the dashboard manually with the
same strategy as described in the manual alternative below.
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
a new user for the wall device. Make it **non-admin**, and ensure it is
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
@@ -215,101 +174,81 @@ and no hand-built views are involved.
Assistant user it may also reach other permitted Home Assistant entities
and services.
4. **Open the dashboard and point TouchHub at it.** Open
`http://<home-assistant>/nestquest` in a browser first and check the
party board renders with your children's crests. Then, in the
TouchHub launcher, add the **Home Assistant Lovelace** app to the
dock and paste the dashboard URL from step 2 — the dashboard root,
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
page). Set TouchHub **Auto-Return** to a value GREATER than the
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
launcher before the quest log's idle return can navigate back to the
party board.
2. **Build a dashboard with only the NestQuest panel cards.** The
recommended path is the **zero-config dashboard strategy**: create a
Lovelace dashboard whose URL slug is `nestquest` with
5. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 3, and leave that session logged in.
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`. The manual instructions below describe
building the two views by hand if you prefer not to use the strategy.
**Manual alternative:** create a Lovelace dashboard whose **URL slug
is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
log reads the selected child's slug from the view's URL path, so the
board → log hop is a plain Lovelace navigation.
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
view → View type: Panel). Home Assistant's default Masonry layout constrains
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
layout is what renders the card full-screen at 1920×1080.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
Assistant Lovelace** app to the dock and paste the dashboard URL from
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
loads that URL directly as the kiosk landing page). Set TouchHub
**Auto-Return** to a value GREATER than the quest log's
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
Auto-Return is at or below 40s, TouchHub returns to its launcher before
the quest log's idle return can navigate back to the party board.
4. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 1, and leave that session logged in.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
### The two constraints
- **Panel layout.** Every view must be in **Panel** (single-card)
layout: Home Assistant's default Masonry layout constrains the
1080px-tall NestQuest card to a narrow column, while Panel renders the
card full-screen at 1920×1080. The zero-config path already satisfies
the requirement — the strategy generates every view as
`type: panel` — while the manual alternative below requires setting
the layout by hand on each view (Edit Dashboard → pencil on the view
→ View type: Panel).
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
than the quest log's `idle_return_seconds` (default 40s) — recommend
**60s or higher** — so the launcher does not reclaim the wall device
before the quest log's own idle return navigates back to the party
board.
### Manual alternative: build the dashboard by hand
If you prefer not to use the zero-config dashboard above — or its
registration was skipped — configure a Lovelace dashboard whose **URL
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
yourself. Two options, in decreasing order of convenience:
- **Use the strategy.** Give the dashboard
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`.
- **Build the two views by hand.** A functional panel needs **two**
views in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
see the two constraints above), `confirm_timeout_seconds`, and
`complete_screen_seconds`. The quest log reads the selected child's
slug from the view's URL path, so the board → log hop is a plain
Lovelace navigation.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls. Each view's layout MUST be set to
**Panel** — see the two constraints above for why; the strategy
generates it for you, hand-built views do not get it for free.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
+1 -35
View File
@@ -41,38 +41,4 @@ class MetaStateDao:
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
async def claim(self, key: str, value: str) -> bool:
"""Atomically set ``key`` to ``value`` unless it already is.
The single upsert statement carries a ``WHERE`` guard on the
conflict path (``t.value IS NOT excluded.value``), so the
read-compare-write is ONE SQLite statement — atomic at the
DATABASE level, not just within one process: two connections
(two processes sharing one SQLite file) cannot both observe
"not yet set" and both write, because the second writer's
statement re-evaluates the guard against the first writer's
committed value and matches zero rows.
Returns True only when THIS call changed the row: the first
claimant wins and every later claimant of the same value
returns False. A DIFFERENT value always wins (an advance, e.g.
the sweep watermark moving to a new date) and re-arms the
same-value loser check for the new value.
Runs under the shared connection lock and inside one
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
matched write, 0 for a guarded no-op) is captured by the
wrapper on the executor thread, so no sqlite3 object crosses
back to the event loop.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
f"WHERE {_TABLE}.value IS NOT excluded.value",
(key, value),
)
return result.rowcount == 1
)
+15 -42
View File
@@ -26,17 +26,12 @@ later completed simply gains its completion event; history keeps both
rows (append-only), and the sweep's later runs skip it via the
no-completion-event rule.
The whole watermark read → query → build span runs inside the
The whole watermark read → query → build → write span runs inside the
per-database sweep lock (one lock per connection wrapper and running
loop, the settings store's pattern): an overlapping startup sweep and
a rollover sweep of the SAME process queue instead of interleaving.
The announce decision itself is the DATABASE-level claim
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
(the integration's rollover sweep and the API service's scheduler)
cannot both announce the same missed batch either: both may read the
old watermark and build the events, but the single conditional upsert
lets exactly one of them win the claim — the loser returns [] and
announces nothing.
a rollover sweep queue instead of interleaving, and the loser re-reads
the watermark INSIDE the lock — sees the winner's date — and announces
nothing twice.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
@@ -65,11 +60,11 @@ LOGGER = logging.getLogger(__name__)
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the settings store's pattern: the watermark read → query → build
#: span is one critical section, so an overlapping startup sweep and
#: rollover sweep of the SAME process queue instead of interleaving.
#: Same-process callers are fully serialised by it; the cross-process
#: case is closed by the database-level claim, not by this lock.
#: the settings store's pattern: the whole watermark read → query →
#: build → write span is one critical section, so an overlapping
#: startup sweep and rollover sweep queue instead of double-announcing.
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
@@ -104,24 +99,15 @@ async def run_missed_sweep(
the caller decides how to publish them and announces nothing on
its own.
The watermark read → query → build span runs inside the
per-database sweep lock, so overlapping callers of ONE process
queue instead of interleaving. The announce decision is the
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
conditional upsert on the watermark, atomic across processes):
every caller builds the events optimistically, then only the
caller whose claim actually advanced the watermark returns them;
a loser (another process already announced for ``today``) returns
[] and announces nothing twice. Every event in one run shares one
The whole watermark read → query → build → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice. Every event in one run shares one
``occurred_at`` stamp (one run, one announcement moment), and the
payload comes from the shared
:func:`~.events._instance_payload` builder so the shape can never
drift from the documented §3 contract.
Failure ordering: the watermark is advanced ONLY by the claim,
AFTER the events are fully built — a failure before the claim
leaves the watermark unadvanced, so a later run re-sweeps the same
window and nothing is lost.
"""
today_iso = today.isoformat()
dao = MetaStateDao(database)
@@ -148,23 +134,10 @@ async def run_missed_sweep(
),
)
)
# The database-level claim gates the announcement: only the
# caller that actually advanced the watermark may publish the
# built events, so two processes racing one SQLite file cannot
# both announce the same missed batch. A same-value loser's
# claim matches zero rows and this run announces nothing.
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
if not claimed:
LOGGER.info(
"NestQuest missed sweep for %s lost the watermark claim; "
"announcing nothing",
today_iso,
)
return []
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
LOGGER.info(
"NestQuest missed sweep for %s built %d event(s)",
today_iso,
len(events),
)
return events
+21 -18
View File
@@ -21,6 +21,8 @@ missed instances, and ``admin_instances`` includes them (D-009).
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Callable, Iterable
from typing import Any
@@ -32,7 +34,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import (
@@ -49,12 +50,9 @@ from .core.snapshot import instance_payload
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the roster applies to HA's slugify: HA's
#: slugifier itself does NOT truncate, but the entity registry derives
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
#: entities the slugged object id alone may not exceed
#: 64 - len("sensor.").
#: Entity-id length cap the slug mirror applies: HA truncates the
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64),
#: so the slugged object id alone may not exceed 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
@@ -110,18 +108,23 @@ def _next_open_quest(
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through, which transliterates every script to ASCII
through unidecode (so non-Latin names survive: ``京子`` slugs to
``jing_zi``) before folding to lowercase and turning every run of
non-alphanumerics into one underscore, answering HA's ``unknown``
sentinel when a name slugs to nothing — then caps the result at
the object-id length the entity registry enforces (the registry
truncates the full ``sensor.`` + slug entity_id at 64 characters,
so the slug alone is capped like this; HA's slugify does not
truncate by itself).
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs
entity names through) for accented-Latin names: NFKD-normalize,
fold to ASCII (accents drop out with their combining marks),
lowercase, and turn every run of non-alphanumerics into one
underscore, capped at HA's entity-id length like the registry does.
The documented divergence from HA's vendored slugifier is unidecode:
HA transliterates any script to ASCII (so e.g. non-Latin names
survive), this mirror drops those characters instead, and a name
that then slugs to nothing falls through to HA's ``unknown``
sentinel — the same answer HA's wrapper gives. (The harness is
mock-only — no real HA to import the helper from — and entity ids
are registry identity, so the mirror is deliberate.)
"""
return slugify(text)[:_MAX_SLUG_LENGTH]
slug = unicodedata.normalize("NFKD", text)
slug = slug.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
-1
View File
@@ -21,7 +21,6 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"unidecode>=1.4.0",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
-68
View File
@@ -24,20 +24,16 @@ from __future__ import annotations
import asyncio
import importlib.util
import inspect
import re
import sys
import tempfile
import unicodedata
import weakref
from dataclasses import dataclass
from html.entities import name2codepoint
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from unidecode import unidecode as _unidecode
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
@@ -50,7 +46,6 @@ _HA_MODULES = (
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
"homeassistant.exceptions",
"homeassistant.util",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity",
@@ -99,7 +94,6 @@ for _parent, _child in (
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
("homeassistant", "exceptions"),
("homeassistant", "util"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "entity"),
@@ -224,68 +218,6 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized
# ---------------------------------------------------------------------------
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
#
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
# and strip dashes, then map dashes to the requested separator — and was
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
# CJK and Greek household-name shapes. Transliteration is the point: HA
# derives the per-child entity ids from a name like "京子" as
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
# never dropping characters into "unknown". Deliberately NOT truncating:
# real slugify caps nothing; the entity registry truncates the full
# entity_id instead.
# ---------------------------------------------------------------------------
_HA_QUOTE_PATTERN = re.compile(r"[']+")
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
if text == "" or text is None:
return ""
text = str(text)
text = _HA_QUOTE_PATTERN.sub("-", text)
text = unicodedata.normalize("NFKD", text)
text = _unidecode(text)
text = _HA_CHAR_ENTITY_PATTERN.sub(
lambda match: chr(name2codepoint[match.group(1)]), text
)
try:
text = _HA_DECIMAL_PATTERN.sub(
lambda match: chr(int(match.group(1))), text
)
except (ValueError, OverflowError):
pass
try:
text = _HA_HEX_PATTERN.sub(
lambda match: chr(int(match.group(1), 16)), text
)
except (ValueError, OverflowError):
pass
text = unicodedata.normalize("NFKD", text)
text = text.lower()
text = _HA_QUOTE_PATTERN.sub("", text)
text = _HA_NUMBERS_PATTERN.sub("", text)
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
return "unknown" if text == "" else text.replace("-", separator)
_ha_mock("homeassistant.util").slugify = _ha_slugify
@dataclass
class DeviceInfo:
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
-295
View File
@@ -1,295 +0,0 @@
"""Cross-process safety of the missed-sweep watermark claim.
Feature 11's sweep guards its announcement with the watermark; the
original guard was a read-then-set inside a per-PROCESS asyncio lock,
so two PROCESSES sharing one SQLite file (the HA integration's
rollover sweep and the API service's scheduler) could both read the
old watermark and both announce the same missed batch. These tests
pin the fix:
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
one conditional upsert whose ``rowcount`` says whether THIS caller
changed the row. The first claimant of a value wins; a second
claimant of the same value — even on an INDEPENDENT connection to
the same file, the shape two processes produce — loses.
- :func:`run_missed_sweep` gates the announcement on that claim: two
independent ``NestQuestDatabase`` wrappers (two processes' shape)
racing one seeded database yields exactly one announcer.
- The documented sweep semantics are unchanged: a same-night rerun
announces nothing, and a run after days of downtime announces the
accumulated ``[watermark, today)`` window exactly once.
Everything here runs against the bundled HA-free core copy
(``custom_components.nestquest.core``) with a plain
``asyncio.to_thread`` executor — no Home Assistant, no API server.
"""
from __future__ import annotations
import asyncio
import datetime
from types import SimpleNamespace
import pytest
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_meta import MetaStateDao
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.children import create_child
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.materialize import materialize
from custom_components.nestquest.core.recurrence import ScheduleRule
from custom_components.nestquest.core.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep,
)
#: One missed event per unswept past-due instance — the sweep's only
#: event type.
EVENT_QUEST_MISSED = "nestquest_quest_missed"
async def _executor(fn, *args, **kwargs):
"""Run a synchronous callable on a worker thread and await it.
The :class:`NestQuestDatabase` executor contract (the shape of
``hass.async_add_executor_job``), so sqlite3 calls never block the
event loop.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
async def _open_database(db_path: str) -> NestQuestDatabase:
"""Open (without migrating) one independent connection wrapper."""
database = NestQuestDatabase(_executor)
await database.open(db_path)
return database
async def _seed_missed_household(
db_path: str, today: datetime.date
) -> SimpleNamespace:
"""Seed a household with two past-due open instances on ``db_path``.
One child, two quests whose ONLY instances are due YESTERDAY
(materialized with ``today`` pinned to yesterday, ``end_date``
closing the rules so the later today-walk cannot duplicate them).
Seeding uses its OWN connection, opened and closed here, so the
connections the tests race with start fresh.
"""
database = await _open_database(db_path)
try:
await apply_migrations(database)
today_iso = today.isoformat()
yesterday = today - datetime.timedelta(days=1)
yesterday_iso = yesterday.isoformat()
rule = ScheduleRule.from_dict(
{
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
)
ada = await create_child(database, "Ada", sort_order=0)
await create_quest_definition(
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
)
await create_quest_definition(
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
)
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# 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}
),
[ada.id],
[("afternoon", "17:00")],
)
await materialize(database, today_iso, today_iso, today=today)
instances = QuestInstancesDao(database)
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
assert len(rows) == 2
today_rows = await instances.list_by_child_and_date(
ada.id, today_iso
)
assert len(today_rows) == 1
return SimpleNamespace(
child=ada,
instances=rows,
recoverable_instance=today_rows[0],
yesterday_iso=yesterday_iso,
today_iso=today_iso,
)
finally:
await database.close()
# --- the claim primitive: atomic at the database level ------------------
async def test_claim_first_caller_wins_and_second_same_value_loses(
temp_db_path: str,
) -> None:
"""claim() is a conditional upsert: first True, same-value second False.
A different value always wins (an advance re-arms the check), and
the stored value follows the winner.
"""
database = await _open_database(temp_db_path)
try:
await apply_migrations(database)
dao = MetaStateDao(database)
assert await dao.get(SWEEP_WATERMARK_KEY) is None
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
# Same value again: the row is already there — nothing changed.
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
# A DIFFERENT value is an advance, not a re-claim: it wins.
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
finally:
await database.close()
async def test_claim_is_atomic_across_independent_connections(
temp_db_path: str,
) -> None:
"""Two connections to one file cannot both claim the same value.
This is the two-process shape: independent sqlite3 connections (one
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
event loop with no shared lock. The single conditional upsert is
serialized by SQLite itself, so exactly one caller's rowcount is 1.
"""
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
await apply_migrations(first)
won = await asyncio.gather(
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
)
assert sorted(won) == [False, True]
# One stored row, holding the winner's value.
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
"2026-09-01"
)
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
"2026-09-01"
)
finally:
await first.close()
await second.close()
# --- the sweep: the announcement is gated by the atomic claim -----------
async def test_two_process_shape_race_exactly_one_announces(
temp_db_path: str,
) -> None:
"""Two independent connections racing the sweep: ONE announcer.
Each wrapper carries its own connection AND its own per-process
sweep lock, so nothing in-process serialises the two runs — the
only guard left is the database-level claim. Both read the unset
watermark and both build the events, but exactly one claim wins:
one run returns the missed events, the loser returns [].
"""
today = datetime.datetime.now().astimezone().date()
seed = await _seed_missed_household(temp_db_path, today)
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
results = await asyncio.gather(
run_missed_sweep(first, today=today),
run_missed_sweep(second, today=today),
)
announcers = [r for r in results if r]
assert len(announcers) == 1
event_type, payload = announcers[0][0]
assert event_type == EVENT_QUEST_MISSED
assert payload["due_date"] == seed.yesterday_iso
# Both instances of the seeded window, one event each.
assert len(announcers[0]) == 2
losers = [r for r in results if not r]
assert losers == [[]]
# The watermark holds the run's date for whoever looks next.
assert (
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
== seed.today_iso
)
finally:
await first.close()
await second.close()
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
"""A second sweep for the same date is an empty no-op (the watermark)."""
today = datetime.datetime.now().astimezone().date()
await _seed_missed_household(temp_db_path, today)
database = await _open_database(temp_db_path)
try:
first = await run_missed_sweep(database, today=today)
assert len(first) == 2
second = await run_missed_sweep(database, today=today)
assert second == []
finally:
await database.close()
async def test_post_downtime_run_announces_accumulated_window_once(
temp_db_path: str,
) -> None:
"""After days of downtime the catch-up run sweeps the window once.
The first run sweeps yesterday's instances (watermark := today);
``today`` then jumps two days and the catch-up run — on a
DIFFERENT connection, the two-process shape — announces the
accumulated ``[watermark, later_today)`` window exactly once, and
a rerun on the original connection is silent again.
"""
today = datetime.datetime.now().astimezone().date()
later_today = today + datetime.timedelta(days=2)
seed = await _seed_missed_household(temp_db_path, today)
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
# Day one: yesterday's two instances fire once.
day_one = await run_missed_sweep(first, today=today)
assert len(day_one) == 2
assert all(
payload["due_date"] == seed.yesterday_iso
for _, payload in day_one
)
# The downtime catch-up: the window is [watermark, later_today).
catchup = await run_missed_sweep(second, today=later_today)
assert len(catchup) == 1
event_type, payload = catchup[0]
assert event_type == EVENT_QUEST_MISSED
assert payload["due_date"] == seed.today_iso
assert payload["instance_id"] == seed.recoverable_instance.id
assert payload["child_id"] == seed.child.id
# A further rerun announces nothing.
assert await run_missed_sweep(first, today=later_today) == []
assert (
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
== later_today.isoformat()
)
finally:
await first.close()
await second.close()
+2 -53
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import datetime
import pytest
from homeassistant.util import slugify
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
@@ -638,9 +637,8 @@ async def test_empty_household_publishes_an_empty_roster(
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
"""The roster slugs match homeassistant.util.slugify — the real
slugifier HA derives entity ids from, composed with the entity-id
length cap the entity registry applies to ``sensor.`` ids."""
"""The slug mirror matches homeassistant.util.slugify for the names
children are registered with (verified against HA's own slugify)."""
assert _slugify("Ada") == "ada"
assert _slugify("Mary Jane") == "mary_jane"
assert _slugify("Mary-Jane") == "mary_jane"
@@ -654,52 +652,3 @@ def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
# The registry caps the entity_id at 64 characters, so the slugged
# object id alone is capped at 64 - len("sensor.").
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
"""HA's slugifier transliterates every script through unidecode,
so a non-Latin child name slugs to its romanization — never to the
``unknown`` sentinel — and the roster publishes the same slug HA's
entity-id derivation produces for that name."""
assert _slugify("Дана") == "dana"
assert _slugify("京子") == "jing_zi"
assert _slugify("Zoë") == "zoe"
async def test_non_latin_child_names_resolve_auto_discovery(
hass, make_entry
) -> None:
"""A non-Latin child name slugs through the real slugifier, so the
roster slug IS the object id of the per-child sensor's entity_id
and the cards' auto-discovery resolves that child.
HA derives the per-child entity id from the entity NAME ("NestQuest
京子 quests due today") with the same slugifier the roster uses, so
the two must agree exactly; the reviewed mirror dropped non-Latin
characters (slugging "京子" to "unknown"), so the cards built
"sensor.nestquest_unknown_…" ids that no entity had."""
payload = _payload(
[
_child(ADA, "Дана", [_instance(7, ADA)]),
_child(BO, "京子", [_instance(8, BO)]),
]
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
roster = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"]
assert roster == [
{"child_id": ADA, "name": "Дана", "slug": "dana"},
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
]
for roster_entry in roster:
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
assert due_entity.name == (
f"NestQuest {roster_entry['name']} quests due today"
)
# The entity id HA's registry derives for this entity (from the
# same slugifier) embeds exactly the slug the roster publishes:
# the card builds sensor.nestquest_<slug>_quests_due_today.
assert slugify(due_entity.name) == (
f"nestquest_{roster_entry['slug']}_quests_due_today"
)
Generated
-11
View File
@@ -385,7 +385,6 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "unidecode" },
{ name = "uvicorn" },
{ name = "voluptuous" },
]
@@ -401,7 +400,6 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "unidecode", specifier = ">=1.4.0" },
{ name = "uvicorn", specifier = ">=0.30" },
{ name = "voluptuous", specifier = ">=0.14" },
]
@@ -691,15 +689,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "unidecode"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"