From 3de9f4b970c49e131cddafcd6b3600626e1f5a66 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:15:21 -0400 Subject: [PATCH 01/14] Add the nightly missed-quest sweep Fires one nestquest_quest_missed event per past-due open instance at the configured rollover time and at startup, watermark-guarded in the new nestquest_meta_state table (migration 7, strictly additive). Read-only over the domain tables; never reports an instance that was ever completed. --- custom_components/nestquest/__init__.py | 22 +- custom_components/nestquest/dao_instances.py | 37 +++ custom_components/nestquest/dao_meta.py | 44 ++++ custom_components/nestquest/migrations.py | 11 +- custom_components/nestquest/schema.py | 16 ++ custom_components/nestquest/sweep.py | 103 ++++++++ tests/test_migrations.py | 5 +- tests/test_schema.py | 4 +- tests/test_sweep.py | 239 +++++++++++++++++++ 9 files changed, 473 insertions(+), 8 deletions(-) create mode 100644 custom_components/nestquest/dao_meta.py create mode 100644 custom_components/nestquest/sweep.py create mode 100644 tests/test_sweep.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 5c66d8c..143e9e0 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -35,6 +35,7 @@ from .migrations import apply_migrations from .services import async_deregister_services, async_register_services from .store import async_get_db_path from .admin_allowlist import seed_setup_admin +from .sweep import run_missed_sweep _LOGGER = LOGGER @@ -327,18 +328,23 @@ def _register_day_rollover_listener( database: NestQuestDatabase, options: dict[str, Any], ) -> Callable[[], Any]: - """Register the daily materialization listener and return its remover. + """Register the daily rollover listener and return its remover. - Fires once per day at the configured local ``day_rollover_time`` and - materializes the rolling horizon ``[today, today + horizon_days]`` - through the shared :func:`_run_horizon_materialization` path, using the - entry's configured (validated) ``horizon_days``. + Fires once per day at the configured local ``day_rollover_time``: + first re-materializes the rolling horizon + ``[today, today + horizon_days]`` through the shared + :func:`_run_horizon_materialization` path (the entry's configured, + validated ``horizon_days``), then runs the Feature 11 missed sweep + — announcing yesterday's still-open quests as missed AFTER today's + instances exist, watermark-guarded so a re-fire of the listener the + same night announces nothing twice. """ hour, minute = _day_rollover_hour_minute(options) horizon_days = _configured_horizon_days(options) async def _run_materialization(_now: datetime.datetime) -> None: await _run_horizon_materialization(hass, database, horizon_days) + await run_missed_sweep(hass, database) return async_track_time_change( hass, _run_materialization, hour=hour, minute=minute @@ -498,6 +504,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _run_horizon_materialization( hass, database, _configured_horizon_days(dict(entry.options)) ) + # The missed sweep runs at startup too (Feature 11): if HA was + # down at the rollover time, the previous night's still-open + # quests are announced as missed now instead of waiting for + # the next midnight. Watermark-guarded, so this is a no-op + # when the night's sweep already ran. + await run_missed_sweep(hass, database) _register_services(hass) # The shared Feature 10 coordinator: one refresh cycle every # entity reads from (CONF_UPDATE_INTERVAL seconds, default diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index e483d6c..3d30b61 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -493,6 +493,43 @@ class QuestInstancesDao: ) return [_instance_from_row(row) for row in rows] + async def list_missed_unswept( + self, today_iso: str, watermark: str | None + ) -> list[QuestInstanceRecord]: + """Return past-due open instances the sweep has not announced. + + Feature 11's missed-sweep query: ``due_date`` strictly before + ``today_iso`` (past due), at or after ``watermark`` when one is + recorded, and with NO completion event ever (the Feature 08 + append-only contract: an instance that was ever completed is + never reported missed, even when later reversed — history keeps + both rows and the sweep never mutates any). The watermark is + the HA-local date the sweep last ran, and the window is + ``[watermark, today)``: the nightly run entering day D+1 sweeps + exactly day D's instances, so every day's quests are announced + once — at the midnight after their due date — and a same-night + rerun (watermark == today) sees an empty window. Read-only; + ordered deterministically by (due_date, id). + """ + _validate_date(today_iso, "today_iso") + if watermark is not None: + _validate_date(watermark, "watermark") + sql = ( + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " + "WHERE due_date < ? " + "AND NOT EXISTS (" + " SELECT 1 FROM completion_events " + " WHERE instance_id = quest_instances.id" + ")" + ) + parameters: tuple = (today_iso,) + if watermark is not None: + sql += " AND due_date >= ?" + parameters += (watermark,) + sql += " ORDER BY due_date, id" + rows = await self._database.fetch_all(sql, parameters) + return [_instance_from_row(row) for row in rows] + async def delete_future_uncompleted( self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None ) -> int: diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py new file mode 100644 index 0000000..ada8c09 --- /dev/null +++ b/custom_components/nestquest/dao_meta.py @@ -0,0 +1,44 @@ +"""Typed DAO for the integration-owned ``nestquest_meta_state`` table. + +Generic persistent key/value bookkeeping that belongs to the +integration itself rather than any domain table — currently the +Feature 11 missed-sweep watermark. Values are plain strings; callers +interpret them. All SQL for this table lives here per the feature +guardrails, and every method runs through +:class:`~.db.NestQuestDatabase` so nothing blocks the event loop. +""" +from __future__ import annotations + +from .dao_children import _connection_lock +from .db import NestQuestDatabase + +_TABLE = "nestquest_meta_state" + + +class MetaStateDao: + """Typed async access to the ``nestquest_meta_state`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def get(self, key: str) -> str | None: + """Return the stored value for ``key``, or None when absent.""" + row = await self._database.fetch_one( + f"SELECT value FROM {_TABLE} WHERE key = ?", (key,) + ) + return row[0] if row is not None else None + + async def set(self, key: str, value: str) -> None: + """Upsert one key/value row atomically. + + The write runs inside one transaction under the shared + connection lock, so a racing reader either sees the old value + or the fully-written new one, never a half-applied state. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + await self._database.execute( + f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) " + "ON CONFLICT (key) DO UPDATE SET value = excluded.value", + (key, value), + ) \ No newline at end of file diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 2040366..cf40a7a 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -28,7 +28,10 @@ copying each definition's assignee across. Migration 4 adds the windows table, migration 5 rebuilds ``quest_instances`` onto the widened (definition_id, child_id, due_date, window) key, and migration 6 rebuilds ``completion_events`` with the D-008 ``actor_child_id`` -column. Each callable migration runs inside its own transaction +column. Migration 7 adds the generic ``nestquest_meta_state`` +key/value table for integration-owned bookkeeping (Feature 11's +missed-sweep watermark). Each callable migration runs inside its own +transaction together with its version stamp, so a crash mid-step rolls the statements and the stamp back together. @@ -80,6 +83,7 @@ from .schema import ( SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, + SCHEMA_V7_META_STATE_DDL, ) LOGGER = logging.getLogger(__name__) @@ -414,6 +418,11 @@ MIGRATIONS: Sequence[MigrationStep] = [ # actor_child_id column and its actor-pair coherence CHECK, # backfilling legacy panel rows to their own child_id. _add_actor_child_id, + # Migration 7 (Feature 11): the generic meta_state key/value table + # — a plain new table, so CREATE IF NOT EXISTS suffices for both + # fresh databases (which already made it in migration 1) and + # pre-v7 databases. Strictly additive; no existing table changes. + list(SCHEMA_V7_META_STATE_DDL), ] diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index ab1fb35..b3c3223 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -384,6 +384,21 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ """, ] +#: Generic integration-owned key/value state (schema version 7, +#: Feature 11): persistent bookkeeping that belongs to the integration +#: rather than any domain table — currently the missed-sweep +#: watermark. ``value`` is a plain string; callers interpret it. +META_STATE_TABLE_SQL = """( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL +)""" + +SCHEMA_V7_META_STATE_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL} + """, +] + SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, @@ -395,6 +410,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, + *SCHEMA_V7_META_STATE_DDL, ] diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py new file mode 100644 index 0000000..a36cdf4 --- /dev/null +++ b/custom_components/nestquest/sweep.py @@ -0,0 +1,103 @@ +"""The nightly missed-quest sweep (Feature 11). + +At the configured day-rollover time (the same HA-local listener that +re-materializes the rolling horizon) this job finds every instance +whose ``due_date`` is before the HA-local date, that has NO completion +event, and that falls after the persisted sweep watermark — and fires +ONE ``nestquest_quest_missed`` event per instance with the documented +payload. The sweep is read-only over the domain tables: it never +mutates an instance and never writes a completion event (``missed`` +is derived state, never an event_type — Feature 08's contract). + +Idempotency is the watermark: after a run, ``nestquest_meta_state`` +records the HA-local date the sweep ran, and the next run only +sweeps instances due in ``[watermark, today)``. A second run the +same night therefore fires nothing new, and a run after days of +downtime sweeps exactly the accumulated window once — no +per-instance journal, no re-announcing old misses. An instance swept +as missed that is 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. +""" +from __future__ import annotations + +import datetime +import logging +from zoneinfo import ZoneInfo + +from .const import EVENT_QUEST_MISSED +from .dao_children import ChildrenDao +from .dao_instances import QuestInstancesDao +from .dao_meta import MetaStateDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase + +LOGGER = logging.getLogger(__name__) + +#: meta_state key holding the HA-local date the sweep last ran. +SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" + +#: UTC ISO-8601 timestamp policy shared with the DAO layers. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +async def run_missed_sweep( + hass, + database: NestQuestDatabase, + *, + today: datetime.date | None = None, +) -> int: + """Fire one missed event per unswept past-due open instance. + + ``today`` optionally pins the HA-local date (threaded like the + materialization and completion layers); when omitted it is read + from ``hass.config.time_zone``. Returns the number of events + fired. Fires nothing and returns 0 when the watermark already + covers ``today`` (a same-night rerun). + """ + today_date = ( + today + if today is not None + else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + ) + today_iso = today_date.isoformat() + dao = MetaStateDao(database) + watermark = await dao.get(SWEEP_WATERMARK_KEY) + if watermark == today_iso: + LOGGER.debug("NestQuest missed sweep already ran for %s", today_iso) + return 0 + + instances_dao = QuestInstancesDao(database) + rows = await instances_dao.list_missed_unswept(today_iso, watermark) + children_dao = ChildrenDao(database) + definitions_dao = QuestDefinitionsDao(database) + now_stamp = _now_stamp() + fired = 0 + for instance in rows: + child = await children_dao.get(instance.child_id) + definition = await definitions_dao.get(instance.definition_id) + hass.bus.async_fire( + EVENT_QUEST_MISSED, + { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + }, + ) + fired += 1 + await dao.set(SWEEP_WATERMARK_KEY, today_iso) + LOGGER.info( + "NestQuest missed sweep for %s fired %d event(s)", today_iso, fired + ) + return fired \ No newline at end of file diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 7cf0624..89195e5 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock import pytest from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL from custom_components.nestquest.migrations import ( MIGRATIONS, VERSION_TABLE, @@ -110,6 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "presence_overrides", "quest_instances", "completion_events", + "nestquest_meta_state", VERSION_TABLE, } assert _tables(database) == expected @@ -159,7 +161,8 @@ def test_migration_list_shape() -> None: assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL assert callable(MIGRATIONS[4]) assert callable(MIGRATIONS[5]) - assert len(MIGRATIONS) == 6 + assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL + assert len(MIGRATIONS) == 7 # --------------------------------------------------------------------------- diff --git a/tests/test_schema.py b/tests/test_schema.py index f869176..50a26a2 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -11,6 +11,7 @@ from custom_components.nestquest.schema import ( SCHEMA_V1_ADMIN_USERS_DDL, SCHEMA_V1_CHILDREN_DDL, SCHEMA_V1_COMPLETION_EVENTS_DDL, + SCHEMA_V7_META_STATE_DDL, SCHEMA_V1_PRESENCE_OVERRIDES_DDL, SCHEMA_V1_PRESENCE_SCHEDULES_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL, @@ -168,7 +169,7 @@ def _insert_override(database, child_id, **overrides): # --------------------------------------------------------------------------- -def test_schema_v1_statements_compose_the_eight_tables() -> None: +def test_schema_v1_statements_compose_the_nine_tables() -> None: assert SCHEMA_V1_STATEMENTS == [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, @@ -180,6 +181,7 @@ def test_schema_v1_statements_compose_the_eight_tables() -> None: *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, + *SCHEMA_V7_META_STATE_DDL, ] diff --git a/tests/test_sweep.py b/tests/test_sweep.py new file mode 100644 index 0000000..17ee530 --- /dev/null +++ b/tests/test_sweep.py @@ -0,0 +1,239 @@ +"""Tests for the nightly missed-quest sweep (Feature 11, task 1).""" +from __future__ import annotations + +import datetime +import re + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import create_child, list_children +from custom_components.nestquest.completion import ( + complete_instance, + uncomplete_instance, +) +from custom_components.nestquest.const import EVENT_QUEST_MISSED +from custom_components.nestquest.dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, +) +from custom_components.nestquest.dao_meta import MetaStateDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule +from custom_components.nestquest.sweep import ( + SWEEP_WATERMARK_KEY, + run_missed_sweep, +) + +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _seed(hass, make_entry) -> tuple: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + return entry, ada, bo + + +async def test_sweep_fires_one_event_per_past_due_open_instance( + hass, make_entry +) -> None: + entry, ada, bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + + fired = await run_missed_sweep(hass, database, today=tomorrow) + assert fired == 2, "one per child for today's now-past-due instance" + + events = hass.bus.fired(EVENT_QUEST_MISSED) + assert len(events) == 2 + payload = events[0] + assert payload["child_name"] in ("Ada", "Bo") + assert payload["quest_title"] == "Brush teeth" + assert payload["window"] == "morning" + assert payload["due_date"] == today.isoformat() + assert payload["due_time"] is None + assert _TIMESTAMP.match(payload["occurred_at"]) + assert {payload["child_id"] for payload in events} == {ada.id, bo.id} + # The sweep is read-only over the domain tables. + assert await _count_events(database) == 0 + + +async def test_sweep_is_idempotent_same_night(hass, make_entry) -> None: + entry, _ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + + first = await run_missed_sweep(hass, database, today=tomorrow) + second = await run_missed_sweep(hass, database, today=tomorrow) + assert first == 2 + assert second == 0, "the watermark makes a rerun fire nothing" + assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2 + assert await MetaStateDao(database).get(SWEEP_WATERMARK_KEY) == ( + tomorrow.isoformat() + ) + + +async def test_sweep_only_fires_new_instances_after_watermark( + hass, make_entry +) -> None: + entry, ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + day_after = today + datetime.timedelta(days=2) + + # First sweep one day ahead: today's instances fire. + fired = await run_missed_sweep(hass, database, today=tomorrow) + assert fired == 2 + first_events = hass.bus.fired(EVENT_QUEST_MISSED) + assert all( + payload["due_date"] == today.isoformat() + for payload in first_events + ) + + # One more day passes: only tomorrow's instances fire now. + fired = await run_missed_sweep(hass, database, today=day_after) + assert fired == 2 + all_events = hass.bus.fired(EVENT_QUEST_MISSED) + assert len(all_events) == 4 + assert all( + payload["due_date"] == tomorrow.isoformat() + for payload in all_events[2:] + ) + + +async def test_sweep_never_reports_completed_or_reversed_instances( + hass, make_entry +) -> None: + entry, ada, bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + + instances_ada = await QuestInstancesDao(database).list_by_date_range( + ada.id, today.isoformat(), today.isoformat() + ) + instances_bo = await QuestInstancesDao(database).list_by_date_range( + bo.id, today.isoformat(), today.isoformat() + ) + # Ada completed hers; Bo completed his then reversed it. + await complete_instance( + database, + instances_ada[0].id, + actor_source="panel", + actor_child_id=ada.id, + ) + await complete_instance( + database, + instances_bo[0].id, + actor_source="panel", + actor_child_id=bo.id, + ) + await uncomplete_instance( + database, + instances_bo[0].id, + actor_source="user", + actor_user_id="admin-1", + ) + + fired = await run_missed_sweep(hass, database, today=tomorrow) + assert fired == 0, ( + "no-completion-event rule excludes both the completed and the " + "reversed instance (append-only history keeps both)" + ) + assert hass.bus.fired(EVENT_QUEST_MISSED) == [] + + +async def test_sweep_skips_instance_completed_after_sweep( + hass, make_entry +) -> None: + """A missed instance completed later is never re-announced; the + late completion still lands in the append-only log.""" + entry, ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + day3 = today + datetime.timedelta(days=2) + + await run_missed_sweep(hass, database, today=tomorrow) + instance = ( + await QuestInstancesDao(database).list_by_date_range( + ada.id, today.isoformat(), today.isoformat() + ) + )[0] + await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=ada.id, + ) + fired = await run_missed_sweep(hass, database, today=day3) + assert fired == 0 or all( + payload["instance_id"] != instance.id + for payload in hass.bus.fired(EVENT_QUEST_MISSED)[2:] + ) + + +async def test_rollover_listener_runs_sweep_after_materialization( + hass, make_entry, monkeypatch +) -> None: + import custom_components.nestquest as nestquest + + calls = [] + + async def _record_sweep(hass_arg, database, **kwargs): + calls.append("sweep") + + monkeypatch.setattr(nestquest, "run_missed_sweep", _record_sweep) + + entry = wire_entry_to_registry( + make_entry(options={"day_rollover_time": "00:00"}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + assert calls == ["sweep"], "startup runs the sweep once" + + await hass.time_change.fire() + assert calls == ["sweep", "sweep"], "the rollover listener sweeps too" + + +async def test_v6_database_migrates_to_v7_meta_state( + hass, make_entry +) -> None: + """A database stamped at version 6 gains the meta_state table and + version 7 on the next startup; existing rows are untouched.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + version = await database.fetch_one( + "SELECT version FROM nestquest_schema_version" + ) + assert version == (7,) + tables = await database.fetch_all( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name='nestquest_meta_state'" + ) + assert tables, "migration 7 created the meta_state table" + + +async def _count_events(database) -> int: + rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events") + return rows[0][0] -- 2.55.0 From 8cd43141a9cbd74b7d930fd75db087f7be8f5da1 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:16:50 -0400 Subject: [PATCH 02/14] Serialize the missed sweep's watermark claim The whole watermark read-query-fire-write span runs under a per-database sweep lock; a racing second sweep re-reads the watermark inside the lock and announces nothing. --- custom_components/nestquest/sweep.py | 85 +++++++++++++++++++--------- tests/test_sweep.py | 20 +++++++ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py index a36cdf4..e60ab72 100644 --- a/custom_components/nestquest/sweep.py +++ b/custom_components/nestquest/sweep.py @@ -21,6 +21,7 @@ skip it via the no-completion-event rule. """ from __future__ import annotations +import asyncio import datetime import logging from zoneinfo import ZoneInfo @@ -37,6 +38,25 @@ LOGGER = logging.getLogger(__name__) #: meta_state key holding the HA-local date the sweep last ran. SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" +#: One asyncio.Lock per (connection wrapper, running loop), mirroring +#: the migration runner's pattern: the whole watermark read → query → +#: fire → 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] = {} + + +def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock: + """Return the sweep lock bound to ``database``'s running loop.""" + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _SWEEP_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _SWEEP_LOCKS[key] = lock + return lock + #: UTC ISO-8601 timestamp policy shared with the DAO layers. _UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" @@ -60,6 +80,12 @@ async def run_missed_sweep( from ``hass.config.time_zone``. Returns the number of events fired. Fires nothing and returns 0 when the watermark already covers ``today`` (a same-night rerun). + + The whole watermark read → query → fire → 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. """ today_date = ( today @@ -68,35 +94,38 @@ async def run_missed_sweep( ) today_iso = today_date.isoformat() dao = MetaStateDao(database) - watermark = await dao.get(SWEEP_WATERMARK_KEY) - if watermark == today_iso: - LOGGER.debug("NestQuest missed sweep already ran for %s", today_iso) - return 0 + async with _sweep_lock(database): + watermark = await dao.get(SWEEP_WATERMARK_KEY) + if watermark == today_iso: + LOGGER.debug( + "NestQuest missed sweep already ran for %s", today_iso + ) + return 0 - instances_dao = QuestInstancesDao(database) - rows = await instances_dao.list_missed_unswept(today_iso, watermark) - children_dao = ChildrenDao(database) - definitions_dao = QuestDefinitionsDao(database) - now_stamp = _now_stamp() - fired = 0 - for instance in rows: - child = await children_dao.get(instance.child_id) - definition = await definitions_dao.get(instance.definition_id) - hass.bus.async_fire( - EVENT_QUEST_MISSED, - { - "child_id": instance.child_id, - "child_name": child.display_name if child else None, - "instance_id": instance.id, - "quest_title": definition.title if definition else None, - "window": instance.window, - "due_date": instance.due_date, - "due_time": instance.due_time, - "occurred_at": now_stamp, - }, - ) - fired += 1 - await dao.set(SWEEP_WATERMARK_KEY, today_iso) + instances_dao = QuestInstancesDao(database) + rows = await instances_dao.list_missed_unswept(today_iso, watermark) + children_dao = ChildrenDao(database) + definitions_dao = QuestDefinitionsDao(database) + now_stamp = _now_stamp() + fired = 0 + for instance in rows: + child = await children_dao.get(instance.child_id) + definition = await definitions_dao.get(instance.definition_id) + hass.bus.async_fire( + EVENT_QUEST_MISSED, + { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + }, + ) + fired += 1 + await dao.set(SWEEP_WATERMARK_KEY, today_iso) LOGGER.info( "NestQuest missed sweep for %s fired %d event(s)", today_iso, fired ) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 17ee530..c76ac81 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -237,3 +237,23 @@ async def test_v6_database_migrates_to_v7_meta_state( async def _count_events(database) -> int: rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events") return rows[0][0] + + +async def test_overlapping_sweeps_never_double_announce( + hass, make_entry +) -> None: + """A startup sweep and a rollover sweep racing on the same night + queue on the sweep lock: the winner announces, the loser re-reads + the watermark inside the lock and fires nothing.""" + import asyncio + + entry, _ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + + first, second = await asyncio.gather( + run_missed_sweep(hass, database, today=tomorrow), + run_missed_sweep(hass, database, today=tomorrow), + ) + assert first + second == 2, "each instance announced exactly once" + assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2 -- 2.55.0 From 3c832c1ceecf0d70dd4cf790d8730924022e1510 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:18:18 -0400 Subject: [PATCH 03/14] Add the notification section to the options flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notify target (empty default, household fills their own service), three strict HH:MM automation times (08:00/15:00/20:00 — the report deliberately before the midnight sweep), and four independent enable toggles, all leniently stored and round-tripped. --- custom_components/nestquest/const.py | 20 +++ custom_components/nestquest/options_flow.py | 111 ++++++++++++- tests/test_options_flow.py | 173 ++++++++++++++++++-- 3 files changed, 285 insertions(+), 19 deletions(-) diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 924898f..e81d78e 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -73,6 +73,26 @@ CONF_UPDATE_INTERVAL = "update_interval" DEFAULT_UPDATE_INTERVAL = 300 MIN_UPDATE_INTERVAL = 30 +# Notification automations (Feature 11). The notify target is a free-text +# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the +# household fills in — never a hard-coded personal target; the times are +# strict HH:MM local; each of the four automations has an independent +# enable toggle. Per the owner-settled resolution the end-of-day report +# defaults to 20:00 — deliberately BEFORE the midnight missed sweep — +# and never defaults to the rollover time. +CONF_NOTIFY_TARGET = "notify_target" +CONF_MORNING_SUMMARY_TIME = "morning_summary_time" +DEFAULT_MORNING_SUMMARY_TIME = "08:00" +CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time" +DEFAULT_AFTERNOON_REMINDER_TIME = "15:00" +CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time" +DEFAULT_END_OF_DAY_REPORT_TIME = "20:00" +CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled" +CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled" +CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" +CONF_CELEBRATION_ENABLED = "celebration_enabled" +DEFAULT_AUTOMATION_ENABLED = True + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 32ddd3b..201d128 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -11,12 +11,24 @@ from homeassistant.helpers import config_validation as cv from .const import ( CONF_ADMIN_USER_IDS, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_AFTERNOON_REMINDER_TIME, + CONF_CELEBRATION_ENABLED, CONF_DAY_ROLLOVER_TIME, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_END_OF_DAY_REPORT_TIME, CONF_HORIZON_DAYS, + CONF_MORNING_SUMMARY_ENABLED, + CONF_MORNING_SUMMARY_TIME, + CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_UPDATE_INTERVAL, + DEFAULT_AFTERNOON_REMINDER_TIME, + DEFAULT_AUTOMATION_ENABLED, DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_HORIZON_DAYS, + DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_UPDATE_INTERVAL, DOMAIN, @@ -71,9 +83,47 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[CONF_UPDATE_INTERVAL] = "invalid" + # The notification section (Feature 11) is OPTIONAL in raw submits + # the same way: a missing key keeps the current value. The three + # times are strict HH:MM; the notify target is a non-empty HA + # service name; the toggles are real booleans. + for time_key in ( + CONF_MORNING_SUMMARY_TIME, + CONF_AFTERNOON_REMINDER_TIME, + CONF_END_OF_DAY_REPORT_TIME, + ): + time_value = user_input.get(time_key) + if time_value is not None and ( + not isinstance(time_value, str) or not _parse_time(time_value) + ): + errors[time_key] = "invalid_time" + notify_target = user_input.get(CONF_NOTIFY_TARGET) + if notify_target is not None and ( + not isinstance(notify_target, str) + or not notify_target.strip() + or notify_target != notify_target.strip() + ): + errors[CONF_NOTIFY_TARGET] = "invalid" + for toggle_key in ( + CONF_MORNING_SUMMARY_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_CELEBRATION_ENABLED, + ): + toggle_value = user_input.get(toggle_key) + if toggle_value is not None and not isinstance(toggle_value, bool): + errors[toggle_key] = "invalid" + return errors +def _strict_bool(value: object) -> bool: + """Require a real bool; voluptuous ``bool`` would coerce strings.""" + if not isinstance(value, bool): + raise vol.Invalid(f"expected a boolean, got {value!r}") + return value + + def _build_schema( current: dict[str, Any], *, @@ -85,7 +135,10 @@ def _build_schema( The admin picker is a multi-select of EXISTING Home Assistant users (never a free-text field — the allowlist stores user IDs); its default is the current database allowlist, so an untouched submit - re-saves exactly who is already allowed. + re-saves exactly who is already allowed. The notification section + (Feature 11) carries the household's notify target, the three + automation times, and four enable toggles — defaults from + :mod:`~.const`, never a hard-coded personal target. """ fields: dict[Any, Any] = { vol.Required( @@ -100,6 +153,37 @@ def _build_schema( vol.Required( CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] ): int, + vol.Required( + CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET] + ): str, + vol.Required( + CONF_MORNING_SUMMARY_TIME, + default=current[CONF_MORNING_SUMMARY_TIME], + ): str, + vol.Required( + CONF_AFTERNOON_REMINDER_TIME, + default=current[CONF_AFTERNOON_REMINDER_TIME], + ): str, + vol.Required( + CONF_END_OF_DAY_REPORT_TIME, + default=current[CONF_END_OF_DAY_REPORT_TIME], + ): str, + vol.Required( + CONF_MORNING_SUMMARY_ENABLED, + default=current[CONF_MORNING_SUMMARY_ENABLED], + ): _strict_bool, + vol.Required( + CONF_AFTERNOON_REMINDER_ENABLED, + default=current[CONF_AFTERNOON_REMINDER_ENABLED], + ): _strict_bool, + vol.Required( + CONF_END_OF_DAY_REPORT_ENABLED, + default=current[CONF_END_OF_DAY_REPORT_ENABLED], + ): _strict_bool, + vol.Required( + CONF_CELEBRATION_ENABLED, + default=current[CONF_CELEBRATION_ENABLED], + ): _strict_bool, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -233,6 +317,23 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): self._current_values()[CONF_UPDATE_INTERVAL], ), } + # The notification section (Feature 11) stores the household's + # choices the same fall-back-to-current way, so raw submits + # that predate the section keep their stored values. + current = self._current_values() + for notification_key in ( + CONF_NOTIFY_TARGET, + CONF_MORNING_SUMMARY_TIME, + CONF_AFTERNOON_REMINDER_TIME, + CONF_END_OF_DAY_REPORT_TIME, + CONF_MORNING_SUMMARY_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_CELEBRATION_ENABLED, + ): + data[notification_key] = user_input.get( + notification_key, current[notification_key] + ) if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids elif admin_default: @@ -254,6 +355,14 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + (CONF_NOTIFY_TARGET, ""), + (CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME), + (CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME), + (CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME), + (CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED), ): if key in options: values[key] = options[key] diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index af949b6..ba4dcc5 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -19,6 +19,18 @@ from custom_components.nestquest.const import ( CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, CONF_UPDATE_INTERVAL, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_AFTERNOON_REMINDER_TIME, + CONF_CELEBRATION_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_END_OF_DAY_REPORT_TIME, + CONF_MORNING_SUMMARY_ENABLED, + CONF_MORNING_SUMMARY_TIME, + CONF_NOTIFY_TARGET, + DEFAULT_AFTERNOON_REMINDER_TIME, + DEFAULT_AUTOMATION_ENABLED, + DEFAULT_END_OF_DAY_REPORT_TIME, + DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, @@ -36,6 +48,22 @@ VALID_INPUT = { CONF_PANEL_IDLE_TIMEOUT: 60, } +#: What a schema-validated submit produces from a bare VALID_INPUT on a +#: fresh entry: the Feature 10 refresh interval and the Feature 11 +#: notification section fill from their defaults. +FULL_INPUT = { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + CONF_NOTIFY_TARGET: "", + CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME, + CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME, + CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME, + CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED, +} + def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) @@ -131,10 +159,7 @@ def test_options_flow_submits_through_2024_6_surface() -> None: result = _run(handler.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" - assert result["data"] == { - **VALID_INPUT, - CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, - } + assert result["data"] == FULL_INPUT def test_options_flow_shows_form_initially() -> None: @@ -214,6 +239,10 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, + CONF_NOTIFY_TARGET: "notify.mobile_app_test", + CONF_MORNING_SUMMARY_TIME: "07:00", + CONF_AFTERNOON_REMINDER_TIME: "14:00", + CONF_END_OF_DAY_REPORT_TIME: "21:30", } ) result = _run(_make_flow(entry).async_step_init(None)) @@ -222,6 +251,14 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, + CONF_NOTIFY_TARGET: "notify.mobile_app_test", + CONF_MORNING_SUMMARY_TIME: "07:00", + CONF_AFTERNOON_REMINDER_TIME: "14:00", + CONF_END_OF_DAY_REPORT_TIME: "21:30", + CONF_MORNING_SUMMARY_ENABLED: True, + CONF_AFTERNOON_REMINDER_ENABLED: True, + CONF_END_OF_DAY_REPORT_ENABLED: True, + CONF_CELEBRATION_ENABLED: True, CONF_ADMIN_USER_IDS: [], } @@ -295,16 +332,8 @@ def test_options_flow_creates_entry_with_four_keys() -> None: result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" - assert set(result["data"]) == { - CONF_HORIZON_DAYS, - CONF_DAY_ROLLOVER_TIME, - CONF_PANEL_IDLE_TIMEOUT, - CONF_UPDATE_INTERVAL, - } - assert result["data"] == { - **VALID_INPUT, - CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, - } + assert set(result["data"]) == set(FULL_INPUT) + assert result["data"] == FULL_INPUT def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: @@ -327,10 +356,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( _run(registry.dispatch_options_update(entry)) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] - assert entry.options == { - **VALID_INPUT, - CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, - } + assert entry.options == FULL_INPUT def test_options_flow_validation_never_raises() -> None: @@ -649,3 +675,114 @@ def test_options_flow_omitted_picker_carries_allowlist_forward( assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS] finally: _run(database.close()) + + +def test_options_flow_notification_section_defaults() -> None: + """A bare submit fills the notification section from const defaults: + 08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight + rollover sweep — an empty notify target, and all toggles enabled.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00" + assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00" + assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00" + assert result["data"][CONF_NOTIFY_TARGET] == "" + for toggle in ( + CONF_MORNING_SUMMARY_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_CELEBRATION_ENABLED, + ): + assert result["data"][toggle] is True + + +def test_options_flow_notification_times_must_be_strict_hh_mm() -> None: + flow = _make_flow(_make_entry()) + for bad_time in ("9:00", "25:00", "08:0", "08-00", 800): + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_MORNING_SUMMARY_TIME: bad_time, + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"} + + +def test_options_flow_notify_target_must_be_clean_nonempty_string() -> None: + flow = _make_flow(_make_entry()) + for bad_target in ("", " notify.x ", 42): + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_NOTIFY_TARGET: bad_target, + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"} + + +def test_options_flow_toggles_must_be_real_booleans() -> None: + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_CELEBRATION_ENABLED: "on", + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"} + + +def test_options_flow_notification_stored_values_round_trip() -> None: + """Stored notification settings are the form's defaults and a raw + submit without them keeps the stored values.""" + entry = _make_entry( + options={ + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 90, + CONF_NOTIFY_TARGET: "notify.mobile_app_dad", + CONF_MORNING_SUMMARY_TIME: "07:15", + CONF_AFTERNOON_REMINDER_TIME: "16:45", + CONF_END_OF_DAY_REPORT_TIME: "21:00", + CONF_MORNING_SUMMARY_ENABLED: False, + CONF_AFTERNOON_REMINDER_ENABLED: True, + CONF_END_OF_DAY_REPORT_ENABLED: False, + CONF_CELEBRATION_ENABLED: True, + } + ) + flow = _make_flow(entry) + form = _run(flow.async_step_init(None)) + schema_defaults = form["data_schema"]({}) + assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad" + assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15" + assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False + + # A raw submit carrying only the legacy keys keeps the stored + # notification settings. + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad" + assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15" + assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False + assert result["data"][CONF_CELEBRATION_ENABLED] is True + + +def test_options_flow_no_hard_coded_personal_target_in_source() -> None: + """The notify target's DEFAULT is the empty string: the household + fills their own service name, and no personal target ships as a + value anywhere in the const/flow defaults.""" + from custom_components.nestquest import const + + assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target" + # No const default carries a concrete notify service. + assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == "" + form = _run(_make_flow(_make_entry()).async_step_init(None)) + assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == "" -- 2.55.0 From fbde3eeaa12f063136725c0b5b9e2effc6daf370 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:20:13 -0400 Subject: [PATCH 04/14] Allow saving the empty default notify target An empty target means notifications are not configured yet and is the shipped default; only malformed strings (padded or whitespace-only) reject. --- custom_components/nestquest/options_flow.py | 12 ++++++++---- tests/test_options_flow.py | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 201d128..bf5c59f 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -98,11 +98,15 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[time_key] = "invalid_time" notify_target = user_input.get(CONF_NOTIFY_TARGET) - if notify_target is not None and ( - not isinstance(notify_target, str) - or not notify_target.strip() - or notify_target != notify_target.strip() + if notify_target is not None and not isinstance(notify_target, str): + errors[CONF_NOTIFY_TARGET] = "invalid" + elif isinstance(notify_target, str) and notify_target != ( + notify_target.strip() ): + # An EMPTY target is valid — it means "no notifications + # configured yet" and is the shipped default; a value that + # differs from its own trim (padded or whitespace-only) is + # malformed and rejected. errors[CONF_NOTIFY_TARGET] = "invalid" for toggle_key in ( CONF_MORNING_SUMMARY_ENABLED, diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index ba4dcc5..f0c8385 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -712,9 +712,22 @@ def test_options_flow_notification_times_must_be_strict_hh_mm() -> None: assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"} -def test_options_flow_notify_target_must_be_clean_nonempty_string() -> None: +def test_options_flow_notify_target_validation() -> None: + """The empty target is VALID (it is the shipped default and must be + savable); only malformed non-empty values reject.""" flow = _make_flow(_make_entry()) - for bad_target in ("", " notify.x ", 42): + # The empty default saves cleanly. + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_NOTIFY_TARGET: ""} + ) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_NOTIFY_TARGET] == "" + + # Malformed non-empty values reject. + for bad_target in (" notify.x ", " ", 42): + flow = _make_flow(_make_entry()) result = _run( flow.async_step_init( { -- 2.55.0 From dc7a82f6e253249afef27a2a10cf8cde80ead611 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:24:50 -0400 Subject: [PATCH 05/14] Add the morning summary blueprint Iterates present children from the Feature 10 presence sensors, lists what each owes today from the remaining sensor, omits absent children entirely. Blueprint inputs (time, notify service, send toggle); the notify target is the household's choice, never hard-coded. PyYAML and Jinja2 join the dev-only dependencies for blueprint template tests. --- .../automation/morning_summary.yaml | 80 ++++ pyproject.toml | 2 + tests/test_blueprints.py | 382 ++++++++++++++++++ uv.lock | 145 +++++++ 4 files changed, 609 insertions(+) create mode 100644 custom_components/nestquest/blueprints/automation/morning_summary.yaml create mode 100644 tests/test_blueprints.py diff --git a/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml new file mode 100644 index 0000000..8efda4a --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -0,0 +1,80 @@ +blueprint: + name: NestQuest morning summary + description: >- + Sends a morning notification listing what each child still owes + today. Only children whose NestQuest presence sensor is on are + included; children who are not present today are omitted entirely. + The notify service is chosen by the household when importing this + blueprint — NestQuest never hard-codes a personal notify target. + domain: automation + input: + morning_time: + name: Morning time + description: Local time of day the summary is sent. + default: "08:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the summary, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_summary: + name: Send the summary + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + +mode: single + +variables: + send_summary: !input send_summary + +triggers: + - trigger: time + at: !input morning_time + +conditions: + - condition: template + value_template: "{{ send_summary | bool }}" + +actions: + - action: !input notify_target + title: NestQuest morning summary + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for presence in states.binary_sensor + | selectattr('entity_id', 'search', '^binary_sensor\\.nestquest_.*_present_today$') + | selectattr('state', 'eq', 'on') -%} + {%- set child_id = presence.entity_id.split('.')[1].removeprefix('nestquest_').removesuffix('_present_today') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_remaining_today$') + | map(attribute='state') + | list -%} + {%- set due_sensors = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_due_today$') + | list -%} + {%- set child_name = due_sensors[0].attributes['child_name'] if due_sensors else (child_id | capitalize) -%} + {%- if remaining_values -%} + {%- set remaining = remaining_values | first | int -%} + {%- if remaining > 0 -%} + {%- set line = child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- else -%} + {%- set line = child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- endif -%} + {%- else -%} + {%- set line = child_name ~ "'s quest count is unavailable today." -%} + {%- endif -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. + {%- endif -%} diff --git a/pyproject.toml b/pyproject.toml index cd7c2c2..3a8a289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,10 @@ dependencies = [] [dependency-groups] dev = [ + "jinja2>=3", "pytest>=8.0.0", "pytest-asyncio>=0.24", + "pyyaml>=6", "voluptuous>=0.14", ] diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..977bf3e --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,382 @@ +"""Tests for the NestQuest automation blueprints. + +The blueprints are YAML data consumed by Home Assistant's blueprint +engine; the integration itself never imports yaml or jinja2. These +tests therefore exercise them exactly the way HA would: parse the YAML +(with a loader that understands ``!input`` tags), then render the +message template with a plain jinja2 environment over a fixture state +set shaped like HA's template state objects. + +The harness is mock-only (see conftest.py), so the fixtures model just +the surface the templates touch: ``states.binary_sensor`` / +``states.sensor`` lists of objects carrying ``entity_id``, ``state`` and +``attributes``. Jinja's ``search``/``match`` selectattr tests are +Home Assistant additions, registered here explicitly. + +Rendering policy under test (settled for the morning summary): a +PRESENT child always appears — with remaining quests owed, or as "all +clear" at zero remaining — and a child who is not present that day is +omitted entirely, never listed with zero quests. +""" +from __future__ import annotations + +import re +from pathlib import Path +from types import SimpleNamespace + +import jinja2 +import yaml + +from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME + +REPO_ROOT = Path(__file__).parent.parent +BLUEPRINT_DIR = ( + REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +) + +MORNING_TITLE = "NestQuest morning summary" + + +class BlueprintInput(str): + """Marker for a parsed blueprint ``!input `` reference.""" + + +def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput: + return BlueprintInput(loader.construct_scalar(node)) + + +class _BlueprintLoader(yaml.SafeLoader): + """SafeLoader extended with Home Assistant's ``!input`` blueprint tag.""" + + +_BlueprintLoader.add_constructor("!input", _input_constructor) + + +def load_blueprints() -> dict[str, dict]: + """Parse every automation blueprint, asserting the directory exists.""" + assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}" + paths = sorted(BLUEPRINT_DIR.glob("*.yaml")) + assert paths, f"no blueprints under {BLUEPRINT_DIR}" + return { + path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader) + for path in paths + } + + +def iter_strings(node: object): + """Yield every string value in a parsed YAML structure, recursively.""" + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for value in node.values(): + yield from iter_strings(value) + elif isinstance(node, list): + for value in node: + yield from iter_strings(value) + + +def morning_summary() -> dict: + """Return the parsed morning-summary blueprint.""" + blueprints = load_blueprints() + assert "morning_summary" in blueprints, "morning_summary.yaml is missing" + return blueprints["morning_summary"] + + +def morning_action(bp: dict) -> dict: + """Return the single notify action of a summary blueprint.""" + actions = bp["actions"] + assert isinstance(actions, list) and len(actions) == 1 + return actions[0] + + +class TemplateState: + """Stand-in for an HA template state object: entity_id/state/attributes.""" + + def __init__(self, entity_id: str, state: str, attributes: dict | None = None): + self.entity_id = entity_id + self.state = state + self.attributes = dict(attributes or {}) + + +def make_states(binary_sensors=(), sensors=()) -> SimpleNamespace: + """Build the ``states`` fixture: per-domain lists of TemplateState.""" + return SimpleNamespace( + binary_sensor=list(binary_sensors), + sensor=list(sensors), + ) + + +def make_template_environment() -> jinja2.Environment: + """A plain jinja2 environment with HA's regex tests and bool filter.""" + env = jinja2.Environment() + env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None + env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None + + def _bool(value, default=False): + if isinstance(value, str): + return value.strip().lower() in ("true", "on", "open", "yes", "1") + return bool(value) or default + + env.filters["bool"] = _bool + return env + + +def render(template: str, **variables) -> str: + """Render a template the way HA would substitute its variables.""" + return make_template_environment().from_string(template).render(**variables) + + +# --------------------------------------------------------------------------- +# YAML parsing and blueprint shape. +# --------------------------------------------------------------------------- +def test_blueprints_parse_and_declare_valid_shape() -> None: + """Every blueprint parses and carries the required blueprint keys.""" + blueprints = load_blueprints() + assert blueprints, "no blueprints found" + for name, bp in blueprints.items(): + assert isinstance(bp, dict), f"{name} did not parse to a mapping" + spec = bp["blueprint"] + assert isinstance(spec["name"], str) and spec["name"].strip() + assert spec["domain"] == "automation" + assert isinstance(spec["input"], dict) and spec["input"] + for key in ("triggers", "conditions", "actions"): + assert isinstance(bp[key], list) and bp[key], f"{name}: empty {key}" + + +def test_morning_summary_declares_inputs_with_selectors() -> None: + """The three blueprint inputs exist, carry selectors, and default sanely.""" + inputs = morning_summary()["blueprint"]["input"] + expected = {"morning_time", "notify_target", "send_summary"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["morning_time"]["default"] == DEFAULT_MORNING_SUMMARY_TIME + assert inputs["send_summary"]["default"] is True + assert "time" in inputs["morning_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_summary"]["selector"] + + +def test_morning_summary_wires_inputs_not_literals() -> None: + """Time trigger, toggle condition, and notify action all use !input.""" + bp = morning_summary() + assert bp["variables"]["send_summary"] == BlueprintInput("send_summary") + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("morning_time") + + condition = bp["conditions"][0] + assert condition["condition"] == "template" + assert "send_summary" in condition["value_template"] + + action = morning_action(bp) + assert action["action"] == BlueprintInput("notify_target") + assert action["title"] == MORNING_TITLE + assert isinstance(action["message"], str) and action["message"].strip() + + +# --------------------------------------------------------------------------- +# Entity-reference discipline (Feature 10 shapes only). +# --------------------------------------------------------------------------- +#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py). +ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple( + re.compile(pattern) + for pattern in ( + r"sensor\.nestquest_[a-z0-9_]+_quests_due_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today", + r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today", + r"sensor\.nestquest_[a-z0-9_]+_next_quest", + r"binary_sensor\.nestquest_[a-z0-9_]+_all_done", + r"binary_sensor\.nestquest_[a-z0-9_]+_present_today", + r"sensor\.nestquest_household_quests_due_today", + r"sensor\.nestquest_household_quests_completed_today", + r"sensor\.nestquest_cycle_day", + ) +) + +#: An unescaped, id-like entity reference: any found must be a full +#: match for one of the Feature 10 shapes above. +_ENTITY_LITERAL_RE = re.compile(r"(? None: + """Every id-like entity string in the YAML is a Feature 10 shape.""" + for name, bp in load_blueprints().items(): + for value in iter_strings(bp): + for literal in _ENTITY_LITERAL_RE.findall(value): + assert any( + shape.fullmatch(literal) for shape in ALLOWED_ENTITY_SHAPES + ), f"{name}: invented entity {literal!r}" + # The validator itself must separate Feature 10 ids from lookalikes. + assert any( + shape.fullmatch("binary_sensor.nestquest_alice_present_today") + for shape in ALLOWED_ENTITY_SHAPES + ) + assert any( + shape.fullmatch("sensor.nestquest_bob_quests_remaining_today") + for shape in ALLOWED_ENTITY_SHAPES + ) + for foreign in ( + "binary_sensor.someone_else_present_today", + "binary_sensor.nestquest_alice_at_home", + "sensor.nestquest_alice_streak", + "vacuum.nestquest_robot", + ): + assert not any(shape.fullmatch(foreign) for shape in ALLOWED_ENTITY_SHAPES) + + +def test_domain_mentions_are_nestquest_prefixed() -> None: + """Every sensor/binary_sensor mention (regex or literal) is nestquest_.""" + for name, bp in load_blueprints().items(): + for value in iter_strings(bp): + flattened = value.replace("\\", "") + for tail in _MENTION_RE.findall(flattened): + assert tail.startswith("nestquest_"), ( + f"{name}: non-nestquest entity mention in {value!r}" + ) + + +def test_presence_regex_selects_only_nestquest_present_sensors() -> None: + """The extracted presence regex matches only Feature 10 presence ids.""" + message = morning_action(morning_summary())["message"] + match = re.search( + r"\^(binary_sensor\\\\\.nestquest_\.\*_present_today\$)", message + ) + assert match is not None, "presence pattern missing from the template" + pattern = match.group(1).replace("\\\\", "\\") + assert pattern.endswith("$") + assert re.search(pattern, "binary_sensor.nestquest_alice_present_today") + assert re.search(pattern, "binary_sensor.nestquest_dave_all_done") is None + assert re.search(pattern, "binary_sensor.motion_present_today") is None + assert re.search(pattern, "binary_sensor.nestquest_erin_present_today_2") is None + + +def test_morning_summary_never_hard_codes_notify_target() -> None: + """No notify service literal outside input descriptions; the choice is text.""" + bp = morning_summary() + spec = bp["blueprint"] + assert "hard-code" in spec["description"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert morning_action(bp)["action"] == BlueprintInput("notify_target") + + +# --------------------------------------------------------------------------- +# Message rendering against fixture state sets. +# --------------------------------------------------------------------------- +def morning_fixture() -> SimpleNamespace: + """Two children — Alice present (2 owed), Bob absent — plus decoys.""" + return make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_alice_present_today", "on"), + TemplateState("binary_sensor.nestquest_bob_present_today", "off"), + TemplateState("binary_sensor.motion_present_today", "on"), + TemplateState("binary_sensor.nestquest_dave_all_done", "on"), + TemplateState("binary_sensor.nestquest_erin_present_today", "unavailable"), + ], + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice"}, + ), + TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob"}, + ), + TemplateState("sensor.nestquest_bob_quests_remaining_today", "1"), + TemplateState("sensor.nestquest_household_quests_due_today", "5"), + TemplateState("sensor.nestquest_carol_unrelated", "7"), + ], + ) + + +def test_morning_summary_lists_present_child_and_omits_absent() -> None: + """Present Alice appears with her count; absent Bob and decoys never do.""" + message = morning_action(morning_summary())["message"] + rendered = render(message, states=morning_fixture()) + assert rendered == "Alice owes 2 quest(s) today." + assert "Bob" not in rendered + for absent in ("motion", "Dave", "Erin", "household", "carol"): + assert absent not in rendered + + +def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: + """A present child always appears — zero remaining reads as all clear.""" + states = make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_carol_present_today", "on"), + TemplateState("binary_sensor.nestquest_alice_present_today", "on"), + ], + sensors=[ + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol"}, + ), + TemplateState("sensor.nestquest_carol_quests_remaining_today", "0"), + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice"}, + ), + TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), + ], + ) + rendered = render(morning_action(morning_summary())["message"], states=states) + assert "Alice owes 2 quest(s) today." in rendered + assert "Carol is all clear today (0 quests remaining)." in rendered + + +def test_morning_summary_present_child_without_sensors_is_flagged() -> None: + """A present child with no Feature 10 sensors still gets a line.""" + states = make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_frances_present_today", "on") + ], + sensors=[], + ) + rendered = render(morning_action(morning_summary())["message"], states=states) + assert rendered == "Frances's quest count is unavailable today." + + +def test_morning_summary_no_present_children() -> None: + """With nobody present the body says so rather than listing zeros.""" + states = make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_alice_present_today", "off") + ], + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice"}, + ), + TemplateState("sensor.nestquest_alice_quests_remaining_today", "3"), + ], + ) + rendered = render(morning_action(morning_summary())["message"], states=states) + assert rendered == "No children present today." + + +def test_morning_summary_send_summary_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + condition = morning_summary()["conditions"][0] + assert render(condition["value_template"], send_summary=True).strip() == "True" + assert render(condition["value_template"], send_summary=False).strip() == "False" diff --git a/uv.lock b/uv.lock index 13bd8ec..dd4e390 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "nestquest" version = "0.3.0" @@ -27,8 +113,10 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "jinja2" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pyyaml" }, { name = "voluptuous" }, ] @@ -36,8 +124,10 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "jinja2", specifier = ">=3" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6" }, { name = "voluptuous", specifier = ">=0.14" }, ] @@ -97,6 +187,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" -- 2.55.0 From f31bda2714cdbf6b38b4786e10db01ed4d754630 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:31:00 -0400 Subject: [PATCH 06/14] Include collision-suffixed entity ids in the morning summary Duplicate child names get HA registry suffixes (_2); the template now iterates the due sensors (which carry present/child_id/child_name as attributes) and pairs counts by child_id, so suffixed duplicates are listed and the household rollup is excluded by the presence guard. --- .../automation/morning_summary.yaml | 37 +++--- tests/test_blueprints.py | 118 +++++++++++++----- 2 files changed, 106 insertions(+), 49 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml index 8efda4a..20f74b4 100644 --- a/custom_components/nestquest/blueprints/automation/morning_summary.yaml +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -49,29 +49,26 @@ actions: title: NestQuest morning summary message: | {%- set ns = namespace(lines=[]) -%} - {%- for presence in states.binary_sensor - | selectattr('entity_id', 'search', '^binary_sensor\\.nestquest_.*_present_today$') - | selectattr('state', 'eq', 'on') -%} - {%- set child_id = presence.entity_id.split('.')[1].removeprefix('nestquest_').removesuffix('_present_today') -%} - {%- set remaining_values = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_remaining_today$') - | map(attribute='state') - | list -%} - {%- set due_sensors = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_due_today$') - | list -%} - {%- set child_name = due_sensors[0].attributes['child_name'] if due_sensors else (child_id | capitalize) -%} - {%- if remaining_values -%} - {%- set remaining = remaining_values | first | int -%} - {%- if remaining > 0 -%} - {%- set line = child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values -%} + {%- set remaining = remaining_values | first | int -%} + {%- if remaining > 0 -%} + {%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- endif -%} {%- else -%} - {%- set line = child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} {%- endif -%} - {%- else -%} - {%- set line = child_name ~ "'s quest count is unavailable today." -%} + {%- set ns.lines = ns.lines + [line] -%} {%- endif -%} - {%- set ns.lines = ns.lines + [line] -%} {%- endfor -%} {%- if ns.lines -%} {{- ns.lines | join('\n') -}} diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 977bf3e..dda97b7 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -242,19 +242,26 @@ def test_domain_mentions_are_nestquest_prefixed() -> None: ) -def test_presence_regex_selects_only_nestquest_present_sensors() -> None: - """The extracted presence regex matches only Feature 10 presence ids.""" +def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None: + """The extracted due-sensor regex matches Feature 10 due ids — + including HA collision-suffixed duplicates (a second child with + the same name gets entity ids ending _2) — and rejects everything + else.""" message = morning_action(morning_summary())["message"] match = re.search( - r"\^(binary_sensor\\\\\.nestquest_\.\*_present_today\$)", message + r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message ) - assert match is not None, "presence pattern missing from the template" + assert match is not None, "due-sensor pattern missing from the template" pattern = match.group(1).replace("\\\\", "\\") assert pattern.endswith("$") - assert re.search(pattern, "binary_sensor.nestquest_alice_present_today") - assert re.search(pattern, "binary_sensor.nestquest_dave_all_done") is None - assert re.search(pattern, "binary_sensor.motion_present_today") is None - assert re.search(pattern, "binary_sensor.nestquest_erin_present_today_2") is None + assert re.search(pattern, "sensor.nestquest_alice_quests_due_today") + assert re.search(pattern, "sensor.nestquest_alice_quests_due_today_2") + assert re.search(pattern, "sensor.nestquest_bob_quests_remaining_today") is None + # The household rollup due sensor unavoidably matches the pattern; + # the template's present-attribute guard excludes it (proven in the + # render test below). + assert re.search(pattern, "sensor.nestquest_household_quests_due_today") + assert re.search(pattern, "sensor.carol_unrelated") is None def test_morning_summary_never_hard_codes_notify_target() -> None: @@ -292,15 +299,23 @@ def morning_fixture() -> SimpleNamespace: TemplateState( "sensor.nestquest_alice_quests_due_today", "3", - {"child_name": "Alice"}, + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, ), - TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), TemplateState( "sensor.nestquest_bob_quests_due_today", "2", - {"child_name": "Bob"}, + {"child_name": "Bob", "child_id": 2, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 2}, ), - TemplateState("sensor.nestquest_bob_quests_remaining_today", "1"), TemplateState("sensor.nestquest_household_quests_due_today", "5"), TemplateState("sensor.nestquest_carol_unrelated", "7"), ], @@ -320,23 +335,27 @@ def test_morning_summary_lists_present_child_and_omits_absent() -> None: def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: """A present child always appears — zero remaining reads as all clear.""" states = make_states( - binary_sensors=[ - TemplateState("binary_sensor.nestquest_carol_present_today", "on"), - TemplateState("binary_sensor.nestquest_alice_present_today", "on"), - ], sensors=[ TemplateState( "sensor.nestquest_carol_quests_due_today", "2", - {"child_name": "Carol"}, + {"child_name": "Carol", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 1}, ), - TemplateState("sensor.nestquest_carol_quests_remaining_today", "0"), TemplateState( "sensor.nestquest_alice_quests_due_today", "3", - {"child_name": "Alice"}, + {"child_name": "Alice", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 2}, ), - TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), ], ) rendered = render(morning_action(morning_summary())["message"], states=states) @@ -345,30 +364,71 @@ def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: def test_morning_summary_present_child_without_sensors_is_flagged() -> None: - """A present child with no Feature 10 sensors still gets a line.""" + """A present child whose remaining-count sensor is missing (the + platform failed to add it) still gets a line, flagged unavailable.""" states = make_states( - binary_sensors=[ - TemplateState("binary_sensor.nestquest_frances_present_today", "on") + sensors=[ + TemplateState( + "sensor.nestquest_frances_quests_due_today", + "2", + {"child_name": "Frances", "child_id": 1, "present": True}, + ), ], - sensors=[], ) rendered = render(morning_action(morning_summary())["message"], states=states) assert rendered == "Frances's quest count is unavailable today." +def test_morning_summary_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name get HA registry suffixes on + their entity ids (..._2); both must appear, each with their own + count (Codex review regression).""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "2", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "2", + {"child_id": 2}, + ), + ], + ) + rendered = render(morning_action(morning_summary())["message"], states=states) + lines = rendered.split("\n") + assert lines == [ + "Ada owes 1 quest(s) today.", + "Ada owes 2 quest(s) today.", + ] + + def test_morning_summary_no_present_children() -> None: """With nobody present the body says so rather than listing zeros.""" states = make_states( - binary_sensors=[ - TemplateState("binary_sensor.nestquest_alice_present_today", "off") - ], sensors=[ TemplateState( "sensor.nestquest_alice_quests_due_today", "3", - {"child_name": "Alice"}, + {"child_name": "Alice", "child_id": 1, "present": False}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "3", + {"child_id": 1}, ), - TemplateState("sensor.nestquest_alice_quests_remaining_today", "3"), ], ) rendered = render(morning_action(morning_summary())["message"], states=states) -- 2.55.0 From ad6f1768d72157e745c9d2e942f65a24032c16c3 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:42:50 -0400 Subject: [PATCH 07/14] Add the afternoon open-quests reminder blueprint Lists only present children who still owe quests; sends nothing at all when every present child is clear (a false template condition stops the action). Blueprint test helpers move to a shared module. --- .../automation/afternoon_reminder.yaml | 89 +++++++ tests/blueprint_helpers.py | 196 ++++++++++++++ tests/test_blueprint_afternoon.py | 242 ++++++++++++++++++ tests/test_blueprints.py | 154 ++--------- 4 files changed, 550 insertions(+), 131 deletions(-) create mode 100644 custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml create mode 100644 tests/blueprint_helpers.py create mode 100644 tests/test_blueprint_afternoon.py diff --git a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml new file mode 100644 index 0000000..ffe0b73 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -0,0 +1,89 @@ +blueprint: + name: NestQuest afternoon reminder + description: >- + Sends an afternoon nudge listing ONLY the children who still have + open quests today. Children who are present and all clear are + omitted (the nudge is for stragglers, not a roll call), children + who are not present today are omitted entirely, and the household + rollup sensors are excluded by the same present guard. When every + present child is already done the automation sends NOTHING at all: + a template condition stops the run before the notification. The + notify service is chosen by the household when importing this + blueprint — NestQuest never hard-codes a personal notify target. + domain: automation + input: + afternoon_time: + name: Afternoon time + description: Local time of day the reminder is sent. + default: "15:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the reminder, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_reminder: + name: Send the reminder + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + +mode: single + +variables: + send_reminder: !input send_reminder + +triggers: + - trigger: time + at: !input afternoon_time + +conditions: + - condition: template + value_template: "{{ send_reminder | bool }}" + # Send nothing when every present child is already done: the guard is + # false unless at least one present child has remaining quests today. + - condition: template + value_template: | + {%- set ns = namespace(any_open=false) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set ns.any_open = true -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.any_open -}} + +actions: + - action: !input notify_target + title: NestQuest afternoon reminder + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/tests/blueprint_helpers.py b/tests/blueprint_helpers.py new file mode 100644 index 0000000..5752fbb --- /dev/null +++ b/tests/blueprint_helpers.py @@ -0,0 +1,196 @@ +"""Shared harness for the NestQuest automation-blueprint tests. + +The blueprints are YAML data consumed by Home Assistant's blueprint +engine; the integration itself never imports yaml or jinja2. The +blueprint tests therefore exercise them exactly the way HA would: +parse the YAML (with a loader that understands ``!input`` tags), then +render the message/condition templates with a plain jinja2 environment +over a fixture state set shaped like HA's template state objects. + +The harness is mock-only (see conftest.py), so the fixtures model just +the surface the templates touch: ``states.binary_sensor`` / +``states.sensor`` lists of objects carrying ``entity_id``, ``state`` +and ``attributes`` — plus, for the celebration blueprint, a callable +``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's +``search``/``match`` selectattr tests are Home Assistant additions, +registered here explicitly. + +This module is plain helper code (deliberately not named ``test_*`` so +pytest never collects it); every blueprint test file imports from it. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import jinja2 +import yaml + +REPO_ROOT = Path(__file__).parent.parent +BLUEPRINT_DIR = ( + REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +) + + +class BlueprintInput(str): + """Marker for a parsed blueprint ``!input `` reference.""" + + +def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput: + return BlueprintInput(loader.construct_scalar(node)) + + +class _BlueprintLoader(yaml.SafeLoader): + """SafeLoader extended with Home Assistant's ``!input`` blueprint tag.""" + + +_BlueprintLoader.add_constructor("!input", _input_constructor) + + +def load_blueprints() -> dict[str, dict]: + """Parse every automation blueprint, asserting the directory exists.""" + assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}" + paths = sorted(BLUEPRINT_DIR.glob("*.yaml")) + assert paths, f"no blueprints under {BLUEPRINT_DIR}" + return { + path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader) + for path in paths + } + + +def blueprint(name: str) -> dict: + """Return one parsed blueprint by its file stem.""" + blueprints = load_blueprints() + assert name in blueprints, f"{name}.yaml is missing" + return blueprints[name] + + +def morning_summary() -> dict: + """Return the parsed morning-summary blueprint.""" + return blueprint("morning_summary") + + +def single_action(bp: dict) -> dict: + """Return the single action of a one-action blueprint.""" + actions = bp["actions"] + assert isinstance(actions, list) and len(actions) == 1 + return actions[0] + + +def morning_action(bp: dict) -> dict: + """Return the single notify action of the morning summary.""" + return single_action(bp) + + +def iter_strings(node: object): + """Yield every string value in a parsed YAML structure, recursively.""" + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for value in node.values(): + yield from iter_strings(value) + elif isinstance(node, list): + for value in node: + yield from iter_strings(value) + + +class TemplateState: + """Stand-in for an HA template state object: entity_id/state/attributes.""" + + def __init__(self, entity_id: str, state: str, attributes: dict | None = None): + self.entity_id = entity_id + self.state = state + self.attributes = dict(attributes or {}) + + +class StatesFixture: + """HA's template ``states`` object over a fixture entity set. + + Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``, + ``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is + callable as ``states(entity_id)`` returning the entity's state + string — or ``None`` when the fixture holds no such entity, the + way HA returns ``None`` for a nonexistent entity id. + """ + + def __init__( + self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=() + ): + self.binary_sensor = list(binary_sensors) + self.sensor = list(sensors) + self.input_datetime = list(input_datetimes) + self.input_text = list(input_texts) + self._all = ( + self.binary_sensor + + self.sensor + + self.input_datetime + + self.input_text + ) + + def __call__(self, entity_id: str): + """Return the entity's state string, or None when absent.""" + for entity in self._all: + if entity.entity_id == entity_id: + return entity.state + return None + + +def make_states( + binary_sensors=(), sensors=(), input_datetimes=(), input_texts=() +) -> StatesFixture: + """Build the ``states`` fixture: per-domain lists of TemplateState.""" + return StatesFixture( + binary_sensors, sensors, input_datetimes, input_texts + ) + + +def make_template_environment() -> jinja2.Environment: + """A plain jinja2 environment with HA's regex tests and bool filter.""" + env = jinja2.Environment() + env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None + env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None + + def _bool(value, default=False): + if isinstance(value, str): + return value.strip().lower() in ("true", "on", "open", "yes", "1") + return bool(value) or default + + env.filters["bool"] = _bool + return env + + +def render(template: str, **variables) -> str: + """Render a template the way HA would substitute its variables.""" + return make_template_environment().from_string(template).render(**variables) + + +# --------------------------------------------------------------------------- +# Entity-reference discipline (Feature 10 shapes only). The scanners +# are shared so every blueprint test file can assert the same rules; +# the directory-wide tests in test_blueprints.py apply them to every +# blueprint file automatically. +# --------------------------------------------------------------------------- +#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py). +ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple( + re.compile(pattern) + for pattern in ( + r"sensor\.nestquest_[a-z0-9_]+_quests_due_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today", + r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today", + r"sensor\.nestquest_[a-z0-9_]+_next_quest", + r"binary_sensor\.nestquest_[a-z0-9_]+_all_done", + r"binary_sensor\.nestquest_[a-z0-9_]+_present_today", + r"sensor\.nestquest_household_quests_due_today", + r"sensor\.nestquest_household_quests_completed_today", + r"sensor\.nestquest_cycle_day", + ) +) + +#: An unescaped, id-like entity reference: any found must be a full +#: match for one of the Feature 10 shapes above. +_ENTITY_LITERAL_RE = re.compile(r"(? dict: + """Return the parsed afternoon-reminder blueprint.""" + return blueprint("afternoon_reminder") + + +def afternoon_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, guard second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def all_clear_guard(bp: dict) -> str: + """Return the template that is false when every present child is done.""" + return afternoon_conditions(bp)[1]["value_template"] + + +# --------------------------------------------------------------------------- +# Inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_afternoon_reminder_declares_inputs_with_selectors() -> None: + """The three inputs exist, carry selectors, and default sanely.""" + inputs = afternoon_reminder()["blueprint"]["input"] + expected = {"afternoon_time", "notify_target", "send_reminder"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["afternoon_time"]["default"] == DEFAULT_AFTERNOON_REMINDER_TIME + assert DEFAULT_AFTERNOON_REMINDER_TIME == "15:00" + assert inputs["send_reminder"]["default"] is True + assert "time" in inputs["afternoon_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_reminder"]["selector"] + + +def test_afternoon_reminder_wires_inputs_not_literals() -> None: + """Time trigger, toggle and guard conditions, and notify action use !input.""" + bp = afternoon_reminder() + assert bp["variables"]["send_reminder"] == BlueprintInput("send_reminder") + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("afternoon_time") + + toggle, guard = afternoon_conditions(bp) + assert toggle["condition"] == "template" + assert "send_reminder" in toggle["value_template"] + assert guard["condition"] == "template" + + action = single_action(bp) + assert action["action"] == BlueprintInput("notify_target") + assert action["title"] == AFTERNOON_TITLE + assert isinstance(action["message"], str) and action["message"].strip() + + +# --------------------------------------------------------------------------- +# Rendering: only present children with open quests are listed. +# --------------------------------------------------------------------------- +def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None: + """Present+open Alice is listed; present+clear Carol, absent Bob, + and the household rollup are all omitted.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 3, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 3}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "7"), + ], + ) + rendered = render(single_action(afternoon_reminder())["message"], states=states) + assert rendered == "Alice still has 2 quest(s) open." + assert "Carol" not in rendered + assert "Bob" not in rendered + assert "household" not in rendered + + +def test_afternoon_reminder_all_clear_guard_renders_false() -> None: + """With every present child done (and absent children owing), the + guard condition is false: the automation sends nothing at all.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "2", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 2, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 2}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "2"), + ], + ) + guard = all_clear_guard(afternoon_reminder()) + assert render(guard, states=states).strip() == "False" + + +def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None: + """A present child with open quests makes the guard true.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "1", + {"child_id": 1}, + ), + ], + ) + guard = all_clear_guard(afternoon_reminder()) + assert render(guard, states=states).strip() == "True" + + +def test_afternoon_reminder_send_reminder_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + toggle = afternoon_conditions(afternoon_reminder())[0] + assert render(toggle["value_template"], send_reminder=True).strip() == "True" + assert render(toggle["value_template"], send_reminder=False).strip() == "False" + + +def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name (HA registry suffix _2 on the + second one's entity ids) both appear, each with their own count.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "3", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "3", + {"child_id": 2}, + ), + ], + ) + rendered = render(single_action(afternoon_reminder())["message"], states=states) + assert rendered.split("\n") == [ + "Ada still has 1 quest(s) open.", + "Ada still has 3 quest(s) open.", + ] + + +def test_afternoon_reminder_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = afternoon_reminder() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert single_action(bp)["action"] == BlueprintInput("notify_target") \ No newline at end of file diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index dda97b7..fbd49c3 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,17 +1,17 @@ -"""Tests for the NestQuest automation blueprints. +"""Tests for the NestQuest morning-summary automation blueprint. The blueprints are YAML data consumed by Home Assistant's blueprint engine; the integration itself never imports yaml or jinja2. These tests therefore exercise them exactly the way HA would: parse the YAML (with a loader that understands ``!input`` tags), then render the message template with a plain jinja2 environment over a fixture state -set shaped like HA's template state objects. +set shaped like HA's template state objects — all via the shared +harness in :mod:`tests.blueprint_helpers`. -The harness is mock-only (see conftest.py), so the fixtures model just -the surface the templates touch: ``states.binary_sensor`` / -``states.sensor`` lists of objects carrying ``entity_id``, ``state`` and -``attributes``. Jinja's ``search``/``match`` selectattr tests are -Home Assistant additions, registered here explicitly. +This module also carries the directory-wide blueprint discipline tests +(entity-id shapes, domain mentions, notify-target hygiene), which scan +EVERY blueprint file under the automation directory, so new blueprint +files are covered automatically. Rendering policy under test (settled for the morning summary): a PRESENT child always appears — with remaining quests owed, or as "all @@ -21,111 +21,27 @@ omitted entirely, never listed with zero quests. from __future__ import annotations import re -from pathlib import Path from types import SimpleNamespace -import jinja2 -import yaml - from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME -REPO_ROOT = Path(__file__).parent.parent -BLUEPRINT_DIR = ( - REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +from tests.blueprint_helpers import ( + ALLOWED_ENTITY_SHAPES, + BlueprintInput, + _ENTITY_LITERAL_RE, + _MENTION_RE, + iter_strings, + load_blueprints, + make_states, + morning_action, + morning_summary, + render, + TemplateState, ) MORNING_TITLE = "NestQuest morning summary" -class BlueprintInput(str): - """Marker for a parsed blueprint ``!input `` reference.""" - - -def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput: - return BlueprintInput(loader.construct_scalar(node)) - - -class _BlueprintLoader(yaml.SafeLoader): - """SafeLoader extended with Home Assistant's ``!input`` blueprint tag.""" - - -_BlueprintLoader.add_constructor("!input", _input_constructor) - - -def load_blueprints() -> dict[str, dict]: - """Parse every automation blueprint, asserting the directory exists.""" - assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}" - paths = sorted(BLUEPRINT_DIR.glob("*.yaml")) - assert paths, f"no blueprints under {BLUEPRINT_DIR}" - return { - path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader) - for path in paths - } - - -def iter_strings(node: object): - """Yield every string value in a parsed YAML structure, recursively.""" - if isinstance(node, str): - yield node - elif isinstance(node, dict): - for value in node.values(): - yield from iter_strings(value) - elif isinstance(node, list): - for value in node: - yield from iter_strings(value) - - -def morning_summary() -> dict: - """Return the parsed morning-summary blueprint.""" - blueprints = load_blueprints() - assert "morning_summary" in blueprints, "morning_summary.yaml is missing" - return blueprints["morning_summary"] - - -def morning_action(bp: dict) -> dict: - """Return the single notify action of a summary blueprint.""" - actions = bp["actions"] - assert isinstance(actions, list) and len(actions) == 1 - return actions[0] - - -class TemplateState: - """Stand-in for an HA template state object: entity_id/state/attributes.""" - - def __init__(self, entity_id: str, state: str, attributes: dict | None = None): - self.entity_id = entity_id - self.state = state - self.attributes = dict(attributes or {}) - - -def make_states(binary_sensors=(), sensors=()) -> SimpleNamespace: - """Build the ``states`` fixture: per-domain lists of TemplateState.""" - return SimpleNamespace( - binary_sensor=list(binary_sensors), - sensor=list(sensors), - ) - - -def make_template_environment() -> jinja2.Environment: - """A plain jinja2 environment with HA's regex tests and bool filter.""" - env = jinja2.Environment() - env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None - env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None - - def _bool(value, default=False): - if isinstance(value, str): - return value.strip().lower() in ("true", "on", "open", "yes", "1") - return bool(value) or default - - env.filters["bool"] = _bool - return env - - -def render(template: str, **variables) -> str: - """Render a template the way HA would substitute its variables.""" - return make_template_environment().from_string(template).render(**variables) - - # --------------------------------------------------------------------------- # YAML parsing and blueprint shape. # --------------------------------------------------------------------------- @@ -177,34 +93,10 @@ def test_morning_summary_wires_inputs_not_literals() -> None: # --------------------------------------------------------------------------- -# Entity-reference discipline (Feature 10 shapes only). +# Entity-reference discipline (Feature 10 shapes only). These tests +# scan EVERY blueprint in the directory, so new blueprint files are +# automatically held to the same rules. # --------------------------------------------------------------------------- -#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py). -ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple( - re.compile(pattern) - for pattern in ( - r"sensor\.nestquest_[a-z0-9_]+_quests_due_today", - r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today", - r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today", - r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today", - r"sensor\.nestquest_[a-z0-9_]+_next_quest", - r"binary_sensor\.nestquest_[a-z0-9_]+_all_done", - r"binary_sensor\.nestquest_[a-z0-9_]+_present_today", - r"sensor\.nestquest_household_quests_due_today", - r"sensor\.nestquest_household_quests_completed_today", - r"sensor\.nestquest_cycle_day", - ) -) - -#: An unescaped, id-like entity reference: any found must be a full -#: match for one of the Feature 10 shapes above. -_ENTITY_LITERAL_RE = re.compile(r"(? None: """Every id-like entity string in the YAML is a Feature 10 shape.""" for name, bp in load_blueprints().items(): @@ -439,4 +331,4 @@ def test_morning_summary_send_summary_toggle_renders() -> None: """The enable condition renders truthy/false from the input value.""" condition = morning_summary()["conditions"][0] assert render(condition["value_template"], send_summary=True).strip() == "True" - assert render(condition["value_template"], send_summary=False).strip() == "False" + assert render(condition["value_template"], send_summary=False).strip() == "False" \ No newline at end of file -- 2.55.0 From 715aac994a9ab0badb771ba80f3cecc63dda1150 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:45:50 -0400 Subject: [PATCH 08/14] Nest notify payload fields under the action's data key The modern HA action schema passes title and message to the notify service only under data; fix the morning summary and afternoon reminder blueprints and the test accessors accordingly. --- .../automation/afternoon_reminder.yaml | 35 ++++++------- .../automation/morning_summary.yaml | 51 ++++++++++--------- tests/blueprint_helpers.py | 10 ++++ tests/test_blueprint_afternoon.py | 10 ++-- tests/test_blueprints.py | 18 ++++--- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml index ffe0b73..34ea11c 100644 --- a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -69,21 +69,22 @@ conditions: actions: - action: !input notify_target - title: NestQuest afternoon reminder - message: | - {%- set ns = namespace(lines=[]) -%} - {%- for due in states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} - {%- if due.attributes.get('present') -%} - {%- set remaining_values = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') - | selectattr('attributes.child_id', 'eq', due.attributes.child_id) - | map(attribute='state') - | list -%} - {%- if remaining_values and (remaining_values | first | int) > 0 -%} - {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} - {%- set ns.lines = ns.lines + [line] -%} + data: + title: NestQuest afternoon reminder + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} {%- endif -%} - {%- endif -%} - {%- endfor -%} - {{- ns.lines | join('\n') -}} \ No newline at end of file + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml index 20f74b4..ea38e1d 100644 --- a/custom_components/nestquest/blueprints/automation/morning_summary.yaml +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -46,32 +46,33 @@ conditions: actions: - action: !input notify_target - title: NestQuest morning summary - message: | - {%- set ns = namespace(lines=[]) -%} - {%- for due in states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} - {%- if due.attributes.get('present') -%} - {%- set remaining_values = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') - | selectattr('attributes.child_id', 'eq', due.attributes.child_id) - | map(attribute='state') - | list -%} - {%- if remaining_values -%} - {%- set remaining = remaining_values | first | int -%} - {%- if remaining > 0 -%} - {%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + data: + title: NestQuest morning summary + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values -%} + {%- set remaining = remaining_values | first | int -%} + {%- if remaining > 0 -%} + {%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- endif -%} {%- else -%} - {%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} {%- endif -%} - {%- else -%} - {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} + {%- set ns.lines = ns.lines + [line] -%} {%- endif -%} - {%- set ns.lines = ns.lines + [line] -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. {%- endif -%} - {%- endfor -%} - {%- if ns.lines -%} - {{- ns.lines | join('\n') -}} - {%- else -%} - No children present today. - {%- endif -%} diff --git a/tests/blueprint_helpers.py b/tests/blueprint_helpers.py index 5752fbb..d4ec16e 100644 --- a/tests/blueprint_helpers.py +++ b/tests/blueprint_helpers.py @@ -82,6 +82,16 @@ def morning_action(bp: dict) -> dict: return single_action(bp) +def notify_data(bp: dict, index: int = 0) -> dict: + """Return a notify action's service ``data`` (title/message live + there in the modern HA action schema, not at the action's top level).""" + action = bp["actions"][index] + assert isinstance(action.get("data"), dict), ( + "notify payload fields must nest under data:" + ) + return action["data"] + + def iter_strings(node: object): """Yield every string value in a parsed YAML structure, recursively.""" if isinstance(node, str): diff --git a/tests/test_blueprint_afternoon.py b/tests/test_blueprint_afternoon.py index 1fe5458..7c13c77 100644 --- a/tests/test_blueprint_afternoon.py +++ b/tests/test_blueprint_afternoon.py @@ -17,6 +17,7 @@ import re from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME from tests.blueprint_helpers import ( + notify_data, BlueprintInput, blueprint, iter_strings, @@ -80,8 +81,9 @@ def test_afternoon_reminder_wires_inputs_not_literals() -> None: action = single_action(bp) assert action["action"] == BlueprintInput("notify_target") - assert action["title"] == AFTERNOON_TITLE - assert isinstance(action["message"], str) and action["message"].strip() + data = notify_data(afternoon_reminder()) + assert data["title"] == AFTERNOON_TITLE + assert isinstance(data["message"], str) and data["message"].strip() # --------------------------------------------------------------------------- @@ -125,7 +127,7 @@ def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> No TemplateState("sensor.nestquest_household_quests_due_today", "7"), ], ) - rendered = render(single_action(afternoon_reminder())["message"], states=states) + rendered = render(notify_data(afternoon_reminder())["message"], states=states) assert rendered == "Alice still has 2 quest(s) open." assert "Carol" not in rendered assert "Bob" not in rendered @@ -218,7 +220,7 @@ def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None: ), ], ) - rendered = render(single_action(afternoon_reminder())["message"], states=states) + rendered = render(notify_data(afternoon_reminder())["message"], states=states) assert rendered.split("\n") == [ "Ada still has 1 quest(s) open.", "Ada still has 3 quest(s) open.", diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index fbd49c3..ac8a991 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -26,6 +26,7 @@ from types import SimpleNamespace from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME from tests.blueprint_helpers import ( + notify_data, ALLOWED_ENTITY_SHAPES, BlueprintInput, _ENTITY_LITERAL_RE, @@ -88,8 +89,9 @@ def test_morning_summary_wires_inputs_not_literals() -> None: action = morning_action(bp) assert action["action"] == BlueprintInput("notify_target") - assert action["title"] == MORNING_TITLE - assert isinstance(action["message"], str) and action["message"].strip() + data = notify_data(bp) + assert data["title"] == MORNING_TITLE + assert isinstance(data["message"], str) and data["message"].strip() # --------------------------------------------------------------------------- @@ -139,7 +141,7 @@ def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None: including HA collision-suffixed duplicates (a second child with the same name gets entity ids ending _2) — and rejects everything else.""" - message = morning_action(morning_summary())["message"] + message = notify_data(morning_summary())["message"] match = re.search( r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message ) @@ -216,7 +218,7 @@ def morning_fixture() -> SimpleNamespace: def test_morning_summary_lists_present_child_and_omits_absent() -> None: """Present Alice appears with her count; absent Bob and decoys never do.""" - message = morning_action(morning_summary())["message"] + message = notify_data(morning_summary())["message"] rendered = render(message, states=morning_fixture()) assert rendered == "Alice owes 2 quest(s) today." assert "Bob" not in rendered @@ -250,7 +252,7 @@ def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert "Alice owes 2 quest(s) today." in rendered assert "Carol is all clear today (0 quests remaining)." in rendered @@ -267,7 +269,7 @@ def test_morning_summary_present_child_without_sensors_is_flagged() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert rendered == "Frances's quest count is unavailable today." @@ -299,7 +301,7 @@ def test_morning_summary_collision_suffixed_duplicate_names() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) lines = rendered.split("\n") assert lines == [ "Ada owes 1 quest(s) today.", @@ -323,7 +325,7 @@ def test_morning_summary_no_present_children() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert rendered == "No children present today." -- 2.55.0 From d2f9c50930d168933655c6c5c808a8cd274ac977 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:46:13 -0400 Subject: [PATCH 09/14] Add the end-of-day parent report blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaults to 20:00 local — deliberately before the midnight missed sweep, reading the same day's data. Every present child gets a completion line; owed lines name still-open quests; absent children are omitted; nothing sends when all clear unless opted in. --- .../automation/end_of_day_report.yaml | 119 +++++++ tests/test_blueprint_end_of_day.py | 296 ++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 custom_components/nestquest/blueprints/automation/end_of_day_report.yaml create mode 100644 tests/test_blueprint_end_of_day.py diff --git a/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml new file mode 100644 index 0000000..e1ab3f0 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml @@ -0,0 +1,119 @@ +blueprint: + name: NestQuest end-of-day report + description: >- + Sends the parent an end-of-day report on how the day went. It runs + deliberately BEFORE NestQuest's midnight missed-quest sweep, so it + reads the same day's data (what is still open at report time, and + how much of the day was completed) rather than tomorrow-morning's + hindsight. Every PRESENT child gets a completion line, so the + parent always receives the day's summary; children who still owe + quests get their open count called out on that line, and children + who are not present today are omitted entirely. A child whose + remaining-count sensor is unavailable is reported with the + completion line only. By default the report is skipped when every + present child is already done — turn on send-even-when-clear to + always get the day's completion summary. The notify service is + chosen by the household when importing this blueprint — NestQuest + never hard-codes a personal notify target. + domain: automation + input: + report_time: + name: Report time + description: >- + Local time of day the report is sent — deliberately before the + midnight missed-quest sweep, so the report reads the same + day's data. + default: "20:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the report, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_report: + name: Send the report + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + send_even_when_clear: + name: Send even when clear + description: >- + Send the report even when every present child has already + finished their quests today. When off (the default), an + all-clear day sends nothing. + default: false + selector: + boolean: {} + +mode: single + +variables: + send_report: !input send_report + send_even_when_clear: !input send_even_when_clear + +triggers: + - trigger: time + at: !input report_time + +conditions: + - condition: template + value_template: "{{ send_report | bool }}" + # Skip an all-clear day unless the household opted into + # send-even-when-clear: the guard is false when no present child + # has any quests remaining today. + - condition: template + value_template: | + {%- set ns = namespace(any_open=false) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set ns.any_open = true -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- send_even_when_clear | bool or ns.any_open -}} + +actions: + - action: !input notify_target + data: + title: NestQuest end-of-day report + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set due_count = due.state | int -%} + {%- set completed_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- set completed_count = (completed_values | first | int) if completed_values else 0 -%} + {%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%} + {%- if remaining_count is not none and remaining_count > 0 -%} + {%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%} + {%- endif -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/tests/test_blueprint_end_of_day.py b/tests/test_blueprint_end_of_day.py new file mode 100644 index 0000000..82b49ee --- /dev/null +++ b/tests/test_blueprint_end_of_day.py @@ -0,0 +1,296 @@ +"""Tests for the NestQuest end-of-day report automation blueprint. + +The end-of-day report closes the day for the parent: every PRESENT +child gets a completion line (so the parent always receives the day's +summary when the report runs), children who still owe quests get their +open count called out on that line, and absent children are omitted +entirely. The report runs deliberately before the midnight +missed-quest sweep so it reads the same day's data, and by default an +all-clear day sends nothing — unless the household opts into +send-even-when-clear. The harness (YAML ``!input`` loader, state +fixtures, jinja rendering) is shared in +:mod:`tests.blueprint_helpers`; the directory-wide shape and +entity-discipline tests in test_blueprints.py cover this file too. +""" +from __future__ import annotations + +import re + +from custom_components.nestquest.const import DEFAULT_END_OF_DAY_REPORT_TIME + +from tests.blueprint_helpers import ( + BlueprintInput, + notify_data, + blueprint, + iter_strings, + make_states, + render, + single_action, + TemplateState, +) + +REPORT_TITLE = "NestQuest end-of-day report" + + +def end_of_day_report() -> dict: + """Return the parsed end-of-day-report blueprint.""" + return blueprint("end_of_day_report") + + +def report_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, guard second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def clear_guard(bp: dict) -> str: + """Return the template guarding an all-clear day (false = no report).""" + return report_conditions(bp)[1]["value_template"] + + +def owing_clear_absent_fixture(): + """Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent.""" + return make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_alice_quests_completed_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_carol_quests_completed_today", + "2", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 3, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 3}, + ), + TemplateState( + "sensor.nestquest_bob_quests_completed_today", + "1", + {"child_id": 3}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "7"), + ], + ) + + +def all_clear_fixture(): + """Alice present with everything done; nobody owes anything.""" + return make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_alice_quests_completed_today", + "3", + {"child_id": 1}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "3"), + ], + ) + + +# --------------------------------------------------------------------------- +# Inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_end_of_day_report_declares_inputs_with_selectors() -> None: + """The four inputs exist, carry selectors, and default sanely.""" + bp = end_of_day_report() + inputs = bp["blueprint"]["input"] + expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME + assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00" + assert inputs["send_report"]["default"] is True + assert inputs["send_even_when_clear"]["default"] is False + assert "time" in inputs["report_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_report"]["selector"] + assert "boolean" in inputs["send_even_when_clear"]["selector"] + # The description must note the deliberate ordering: the report runs + # before the midnight missed sweep, reading the same day's data. + description = bp["blueprint"]["description"].lower() + assert "before" in description + assert "sweep" in description + assert "same day" in description + + +def test_end_of_day_report_wires_inputs_not_literals() -> None: + """Time trigger, toggle and guard conditions, and notify action use !input.""" + bp = end_of_day_report() + assert bp["variables"]["send_report"] == BlueprintInput("send_report") + assert bp["variables"]["send_even_when_clear"] == BlueprintInput( + "send_even_when_clear" + ) + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("report_time") + + toggle, guard = report_conditions(bp) + assert toggle["condition"] == "template" + assert "send_report" in toggle["value_template"] + assert guard["condition"] == "template" + assert "send_even_when_clear" in guard["value_template"] + + action = single_action(bp) + assert action["action"] == BlueprintInput("notify_target") + data = notify_data(end_of_day_report()) + assert data["title"] == REPORT_TITLE + assert isinstance(data["message"], str) and data["message"].strip() + + +# --------------------------------------------------------------------------- +# Rendering the day's summary. +# --------------------------------------------------------------------------- +def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None: + """The owing child's open count is called out with the completion + ratio; the present-clear child still gets the day's completion + line; the absent child is omitted entirely.""" + rendered = render( + notify_data(end_of_day_report())["message"], + states=owing_clear_absent_fixture(), + ) + assert rendered.split("\n") == [ + "Alice: 2 still open (1/3 done).", + "Carol: 2/2 done.", + ] + assert "Bob" not in rendered + assert "household" not in rendered + + +def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None: + """An all-clear day with send_even_when_clear off sends nothing: + the guard condition is false.""" + rendered = render( + clear_guard(end_of_day_report()), + states=all_clear_fixture(), + send_even_when_clear=False, + ) + assert rendered.strip() == "False" + + +def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None: + """With send_even_when_clear on, the guard lets an all-clear day + through and the message renders the day's completion summary.""" + guard = clear_guard(end_of_day_report()) + assert ( + render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip() + == "True" + ) + message = render( + notify_data(end_of_day_report())["message"], + states=all_clear_fixture(), + ) + assert message == "Alice: 3/3 done." + + +def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None: + """A present child with open quests passes the guard even with + send_even_when_clear off.""" + rendered = render( + clear_guard(end_of_day_report()), + states=owing_clear_absent_fixture(), + send_even_when_clear=False, + ) + assert rendered.strip() == "True" + + +def test_end_of_day_report_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name (HA registry suffix _2 on the + second one's entity ids) both appear, each with their own counts.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_completed_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "3", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_ada_quests_completed_today_2", + "3", + {"child_id": 2}, + ), + ], + ) + rendered = render(notify_data(end_of_day_report())["message"], states=states) + assert rendered.split("\n") == [ + "Ada: 1 still open (1/2 done).", + "Ada: 3/3 done.", + ] + + +def test_end_of_day_report_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = end_of_day_report() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert single_action(bp)["action"] == BlueprintInput("notify_target") \ No newline at end of file -- 2.55.0 From b9137720072ad271aa513fb96467fb64fa473cb6 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:47:37 -0400 Subject: [PATCH 10/14] Report when remaining counts are unavailable, not all-clear A missing or non-numeric remaining sensor reads as -1 in the all-clear guard, so a degraded day reaches the parent instead of looking like a clear day. --- .../automation/end_of_day_report.yaml | 8 ++-- tests/test_blueprint_end_of_day.py | 44 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml index e1ab3f0..7809903 100644 --- a/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml +++ b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml @@ -66,8 +66,10 @@ conditions: - condition: template value_template: "{{ send_report | bool }}" # Skip an all-clear day unless the household opted into - # send-even-when-clear: the guard is false when no present child - # has any quests remaining today. + # send-even-when-clear: the guard is false only when every present + # child has a usable remaining count of zero. A MISSING or + # non-numeric remaining sensor reads as -1 and counts as open — + # a degraded state must reach the parent, not look like all-clear. - condition: template value_template: | {%- set ns = namespace(any_open=false) -%} @@ -79,7 +81,7 @@ conditions: | selectattr('attributes.child_id', 'eq', due.attributes.child_id) | map(attribute='state') | list -%} - {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%} {%- set ns.any_open = true -%} {%- endif -%} {%- endif -%} diff --git a/tests/test_blueprint_end_of_day.py b/tests/test_blueprint_end_of_day.py index 82b49ee..58856c5 100644 --- a/tests/test_blueprint_end_of_day.py +++ b/tests/test_blueprint_end_of_day.py @@ -293,4 +293,46 @@ def test_end_of_day_report_never_hard_codes_notify_target() -> None: assert re.search(r"notify\.[a-z_]+", target_description), ( "the input description should show a placeholder example" ) - assert single_action(bp)["action"] == BlueprintInput("notify_target") \ No newline at end of file + assert single_action(bp)["action"] == BlueprintInput("notify_target") + +def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None: + """A present child whose remaining sensor is missing or reads + 'unavailable' must NOT look like all-clear: the degraded state + reaches the parent (Codex review regression).""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "unavailable", + {"child_id": 1}, + ), + ], + ) + rendered = render( + clear_guard(end_of_day_report()), + states=states, + send_even_when_clear=False, + ) + assert rendered.strip() == "True" + + # A missing remaining sensor entirely: also report-worthy. + states_missing = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + ], + ) + rendered_missing = render( + clear_guard(end_of_day_report()), + states=states_missing, + send_even_when_clear=False, + ) + assert rendered_missing.strip() == "True" -- 2.55.0 From bf84793a1c6919128008302efefed594348ae5d7 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:47:52 -0400 Subject: [PATCH 11/14] Add the day-complete celebration blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggers on nestquest_child_day_complete (never a zero-quest day), notifies with the payload's child name, and dedupes per child per day via an input_text helper holding : entries pruned to today — a re-cleared day does not re-fire, but a second child clearing the same day still celebrates. --- .../automation/day_complete_celebration.yaml | 104 +++++++ tests/test_blueprint_celebration.py | 258 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml create mode 100644 tests/test_blueprint_celebration.py diff --git a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml new file mode 100644 index 0000000..9165a84 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -0,0 +1,104 @@ +blueprint: + name: NestQuest day complete celebration + description: >- + Celebrates the moment a child clears every quest for the day: the + automation is driven by the nestquest_child_day_complete event, + which NestQuest only fires when quests were owed and all of them + are complete — a zero-quest day can never trigger this + celebration. Once-per-child-per-day dedupe: point + last_celebrated_helper at ONE input_text helper (create it under + Settings, Devices and Services, Helpers — plain text). The + automation records each celebrated child as + ":" inside that helper, and a child who + un-completes a quest and completes it again the same day is + celebrated once, not twice — while a SECOND child clearing their + day the same date is still celebrated in full. The helper is + pruned to today's entries on every celebration, so it never + grows past one entry per child per day. A helper that does not + exist yet, or has never been set, lets the first celebration + through. The notification message may use the event payload's + child_name, quests_due and quests_completed. The notify service + is chosen by the household when importing this blueprint — + NestQuest never hard-codes a personal notify target. + domain: automation + input: + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the celebration, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + celebration_message: + name: Celebration message + description: >- + The message sent when a child clears every quest today. May + use the event payload: trigger.payload.child_name, + trigger.payload.quests_due and trigger.payload.quests_completed. + default: "Legendary! {{ trigger.payload.child_name }} cleared every quest today!" + selector: + text: {} + celebrate: + name: Send the celebration + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + last_celebrated_helper: + name: Last-celebrated helper + description: >- + A single input_text helper remembering which children were + already celebrated today, as ":" entries + separated by commas. The automation skips a child whose entry + for today is already stored, and rewrites the helper with only + today's entries plus the new one after each celebration. + selector: + entity: + filter: + - domain: input_text + +mode: single + +variables: + celebrate: !input celebrate + last_celebrated_helper: !input last_celebrated_helper + +triggers: + - trigger: event + event_type: nestquest_child_day_complete + +conditions: + - condition: template + value_template: "{{ celebrate | bool }}" + # Once-per-child-per-day dedupe: celebrate only when THIS child has + # no entry for today in the helper. An unset or missing helper reads + # as '' and celebrates. + - condition: template + value_template: >- + {{ (trigger.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) + not in (states(last_celebrated_helper) or '') }} + +actions: + - action: !input notify_target + data: + title: NestQuest day complete + message: !input celebration_message + # Record this child's celebration, keeping only today's entries so the + # helper never outgrows its text limit and stale entries cannot block + # next week's celebrations. + - action: input_text.set_value + target: + entity_id: !input last_celebrated_helper + data: + value: >- + {%- set today = now().strftime('%Y-%m-%d') -%} + {%- set ns = namespace(entries=[]) -%} + {%- for entry in (states(last_celebrated_helper) or '').split(',') -%} + {%- if entry and entry.endswith(':' ~ today) -%} + {%- set ns.entries = ns.entries + [entry] -%} + {%- endif -%} + {%- endfor -%} + {{ (ns.entries + [trigger.payload.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py new file mode 100644 index 0000000..e86eca4 --- /dev/null +++ b/tests/test_blueprint_celebration.py @@ -0,0 +1,258 @@ +"""Tests for the NestQuest day-complete celebration automation blueprint. + +The celebration fires on the ``nestquest_child_day_complete`` event — +which NestQuest only fires when quests were owed and all are complete, +so a zero-quest day can never celebrate. Dedupe design (per-child, +per-day): the household points ONE ``input_text`` helper at the +blueprint; the condition celebrates only when THIS child has no +``:`` entry stored, and the action rewrites the +helper with only today's entries plus the new one — so a child who +un-completes and re-completes the same day is celebrated once, not +twice, while a second child clearing the same day is still celebrated. + +The harness (YAML ``!input`` loader, state fixtures, jinja rendering) +is shared in :mod:`tests.blueprint_helpers`; the directory-wide shape +and entity-discipline tests in test_blueprints.py cover this file +too. ``now()`` in the dedupe condition is injected by the test as a +fixed callable, keeping the render deterministic. +""" +from __future__ import annotations + +import datetime +import re + +from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE + +from tests.blueprint_helpers import ( + BlueprintInput, + notify_data, + blueprint, + iter_strings, + make_states, + render, + TemplateState, +) + +CELEBRATION_TITLE = "NestQuest day complete" + +HELPER_ENTITY = "input_text.nestquest_last_celebrated" + +#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local. +FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0) + + +def celebration() -> dict: + """Return the parsed day-complete-celebration blueprint.""" + return blueprint("day_complete_celebration") + + +def celebration_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, dedupe second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def dedupe_condition(bp: dict) -> str: + """Return the once-per-child-per-day dedupe template (true = celebrate).""" + return celebration_conditions(bp)[1]["value_template"] + + +def stamp_value_template(bp: dict) -> str: + """Return the helper-stamp action's value template.""" + stamp = bp["actions"][1] + return stamp["data"]["value"] + + +def trigger_payload(child_id: int, child_name: str) -> dict: + """A nestquest_child_day_complete trigger payload fixture.""" + return { + "payload": { + "child_id": child_id, + "child_name": child_name, + "quests_due": 3, + "quests_completed": 3, + "occurred_at": "2026-09-19T15:00:00+00:00", + } + } + + +def render_dedupe( + helper_state: str | None, child_id: int = 1 +) -> str: + """Render the dedupe condition with the helper holding helper_state. + + ``None`` models a helper entity the fixture does not hold (never + created or not yet set), which HA exposes as a missing state. + """ + states = make_states( + input_texts=( + [] + if helper_state is None + else [TemplateState(HELPER_ENTITY, helper_state)] + ) + ) + return render( + dedupe_condition(celebration()), + states=states, + last_celebrated_helper=HELPER_ENTITY, + now=lambda: FIXED_NOW, + trigger=trigger_payload(child_id, "Ada"), + ).strip() + + +def render_stamp(helper_state: str | None, child_id: int = 1) -> str: + """Render the stamp action's value with the helper holding helper_state.""" + states = make_states( + input_texts=( + [] + if helper_state is None + else [TemplateState(HELPER_ENTITY, helper_state)] + ) + ) + return render( + stamp_value_template(celebration()), + states=states, + last_celebrated_helper=HELPER_ENTITY, + now=lambda: FIXED_NOW, + trigger=trigger_payload(child_id, "Ada"), + ).strip() + + +# --------------------------------------------------------------------------- +# Trigger, inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_celebration_triggers_on_the_child_day_complete_event() -> None: + """The event trigger references the real nestquest_child_day_complete.""" + trigger = celebration()["triggers"][0] + assert trigger["trigger"] == "event" + assert trigger["event_type"] == "nestquest_child_day_complete" + assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE + + +def test_celebration_declares_inputs_with_selectors() -> None: + """The four inputs exist, carry selectors, and default sanely.""" + bp = celebration() + inputs = bp["blueprint"]["input"] + expected = { + "notify_target", + "celebration_message", + "celebrate", + "last_celebrated_helper", + } + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["celebrate"]["default"] is True + assert "text" in inputs["notify_target"]["selector"] + assert "text" in inputs["celebration_message"]["selector"] + assert "boolean" in inputs["celebrate"]["selector"] + assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [ + {"domain": "input_text"} + ] + # The description documents the zero-quest-day rule and the dedupe. + description = bp["blueprint"]["description"] + assert "zero-quest day" in description + assert "once" in description.lower() + + +def test_celebration_wires_inputs_not_literals() -> None: + """Notify and stamp actions both use !input; the dedupe reads the helper.""" + bp = celebration() + assert bp["variables"]["celebrate"] == BlueprintInput("celebrate") + assert bp["variables"]["last_celebrated_helper"] == BlueprintInput( + "last_celebrated_helper" + ) + + toggle, dedupe = celebration_conditions(bp) + assert toggle["condition"] == "template" + assert "celebrate" in toggle["value_template"] + assert dedupe["condition"] == "template" + assert "last_celebrated_helper" in dedupe["value_template"] + assert "child_id" in dedupe["value_template"], ( + "the dedupe must be per child, not per household" + ) + + notify_action, stamp_action = bp["actions"] + assert notify_action["action"] == BlueprintInput("notify_target") + data = notify_data(bp) + assert data["title"] == CELEBRATION_TITLE + assert data["message"] == BlueprintInput("celebration_message") + + assert stamp_action["action"] == "input_text.set_value" + assert stamp_action["target"]["entity_id"] == BlueprintInput( + "last_celebrated_helper" + ) + assert "now()" in stamp_action["data"]["value"] + + +def test_celebration_message_default_renders_the_child_name() -> None: + """The default message renders the trigger payload's child_name.""" + default_message = celebration()["blueprint"]["input"]["celebration_message"][ + "default" + ] + rendered = render(default_message, trigger=trigger_payload(1, "Ada")) + assert rendered == "Legendary! Ada cleared every quest today!" + + +# --------------------------------------------------------------------------- +# The once-per-child-per-day dedupe condition. +# --------------------------------------------------------------------------- +def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None: + """A helper without THIS child's entry for today lets the + celebration through — including when another child was celebrated + earlier the same day.""" + assert render_dedupe(None) == "True" + assert render_dedupe("") == "True" + assert render_dedupe("2:2026-09-19") == "True", ( + "a second child clearing the same day must still celebrate" + ) + assert render_dedupe("1:2026-09-18") == "True", ( + "yesterday's entry never blocks today" + ) + + +def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None: + """A child already celebrated today is suppressed — the un-complete + and re-complete the same day case.""" + assert render_dedupe("1:2026-09-19") == "False" + assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False" + + +def test_celebration_stamp_prunes_to_today_and_appends() -> None: + """The stamp keeps only today's entries plus the new one: yesterday's + entries are dropped (the helper never outgrows its text limit and + stale entries cannot block next week), and the celebrated child is + recorded.""" + assert render_stamp(None, child_id=1) == "1:2026-09-19" + assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19" + assert render_stamp("1:2026-09-19", child_id=2) == ( + "1:2026-09-19,2:2026-09-19" + ) + assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == ( + "3:2026-09-19" + ) + + +def test_celebration_celebrate_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + toggle = celebration_conditions(celebration())[0] + assert render(toggle["value_template"], celebrate=True).strip() == "True" + assert render(toggle["value_template"], celebrate=False).strip() == "False" + + +def test_celebration_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = celebration() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert bp["actions"][0]["action"] == BlueprintInput("notify_target") \ No newline at end of file -- 2.55.0 From f6166f25d95434c00bc9fbbc8ea3beb8e6cf1cc0 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:50:59 -0400 Subject: [PATCH 12/14] Compare celebration stamps as whole entries; queue events Child ids sharing a prefix no longer suppress each other (split and compare entries for equality), and queued mode keeps a second child's day-complete event from being dropped while the first notify runs. --- .../automation/day_complete_celebration.yaml | 13 +++++++++---- tests/test_blueprint_celebration.py | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml index 9165a84..3672adc 100644 --- a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -60,7 +60,7 @@ blueprint: filter: - domain: input_text -mode: single +mode: queued variables: celebrate: !input celebrate @@ -74,12 +74,17 @@ conditions: - condition: template value_template: "{{ celebrate | bool }}" # Once-per-child-per-day dedupe: celebrate only when THIS child has - # no entry for today in the helper. An unset or missing helper reads - # as '' and celebrates. + # no equal entry for today in the helper. Entries compare as whole + # comma-separated tokens — a substring check would wrongly suppress + # child 1 after child 11 was celebrated. An unset or missing helper + # reads as '' and celebrates. - condition: template value_template: >- {{ (trigger.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) - not in (states(last_celebrated_helper) or '') }} + not in ((states(last_celebrated_helper) or '') + .split(',') + | select('ne', '') + | list) }} actions: - action: !input notify_target diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py index e86eca4..9d61277 100644 --- a/tests/test_blueprint_celebration.py +++ b/tests/test_blueprint_celebration.py @@ -255,4 +255,20 @@ def test_celebration_never_hard_codes_notify_target() -> None: assert re.search(r"notify\.[a-z_]+", target_description), ( "the input description should show a placeholder example" ) - assert bp["actions"][0]["action"] == BlueprintInput("notify_target") \ No newline at end of file + assert bp["actions"][0]["action"] == BlueprintInput("notify_target") + +def test_celebration_dedupe_entry_equality_not_substring() -> None: + """Child ids sharing a prefix must not suppress each other: after + child 11 is celebrated, child 1 still celebrates (whole-entry + comparison, not substring).""" + assert render_dedupe("11:2026-09-19", child_id=11) == "False" + assert render_dedupe("11:2026-09-19", child_id=1) == "True", ( + "child 1's entry must not be found inside child 11's entry" + ) + assert render_dedupe("1:2026-09-19", child_id=11) == "True" + + +def test_celebration_mode_is_queued() -> None: + """Two children completing at nearly the same time must both be + processed: queued mode does not drop the second event.""" + assert celebration()["mode"] == "queued" -- 2.55.0 From 3a2e6faa6942b6f4fc925e3a3870d7c0a7694904 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:52:12 -0400 Subject: [PATCH 13/14] Document the automation package and test event validity README gains the blueprint section: all four blueprints, their inputs, the wiring table to the options-flow notification settings, the celebration helper setup, event/sensor payload reference, and a copy-pasteable example package. Tests assert every event trigger references a real NestQuest event and no reminder is produced for an absent child. --- README.md | 88 +++++++++++++++++++++++++++++ tests/test_blueprint_afternoon.py | 26 ++++++++- tests/test_blueprint_celebration.py | 22 ++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 441ad74..0ed4e2b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,94 @@ actions: message: "Every quest cleared — legendary!" ``` +## Automation Blueprints + +NestQuest ships four automation blueprints in +`custom_components/nestquest/blueprints/automation/`. Import them in +Home Assistant under **Settings → Automations & Scenes → Blueprints** +(or place the files in your `config/blueprints/automation/` folder), +then create one automation per blueprint, filling the inputs with your +household's choices. + +### Wiring to the integration's notification settings + +The integration's options flow records your notification choices — +notify target, morning/afternoon/end-of-day times, and a toggle per +automation. The blueprints take their own inputs at import time; +fill them from the same values you stored in the options flow (the +integration does not push its settings into imported automations): + +| Blueprint input | Options-flow counterpart | +|---|---| +| `morning_time` (default 08:00) | `morning_summary_time` | +| `afternoon_time` (default 15:00) | `afternoon_reminder_time` | +| `report_time` (default 20:00) | `end_of_day_report_time` | +| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles | +| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) | + +The end-of-day report deliberately runs BEFORE the midnight missed +sweep and reads the same day's data — do not schedule it at the +day-rollover time. + +### The four blueprints + +1. **Morning summary** (`morning_summary.yaml`) — at the configured + time, lists every child whose `binary_sensor.nestquest__present_today` + is on and what they owe from `sensor.nestquest__quests_remaining_today`. + Children not present today are omitted entirely. + +2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the + configured time, notifies only about present children who still + have open quests. Sends nothing at all when every present child is + clear. + +3. **End-of-day report** (`end_of_day_report.yaml`) — one message per + evening: each present child's completion count and the quests they + still owe (the ones the midnight sweep will mark + `nestquest_quest_missed`). Absent children are omitted. Sends + nothing on an all-clear day unless `send_even_when_clear` is on. + +4. **Day-complete celebration** (`day_complete_celebration.yaml`) — + triggers on the `nestquest_child_day_complete` event (which only + fires when quests were owed and all are complete, so a zero-quest + day never celebrates). Create one `input_text` helper + (`nestquest_last_celebrated`) for the dedupe: the automation + records each celebrated child as `:` and + celebrates each child at most once per day — a re-cleared day does + not re-fire, but a second child clearing the same day still + celebrates. + +### Example package + +```yaml +# One automation per blueprint — Settings → Automations & Scenes → +# Create → Blueprint, then pick each NestQuest blueprint. +# +# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone +# afternoon_reminder: afternoon_time 15:00, same notify target +# end_of_day_report: report_time 20:00, send_even_when_clear false, same target +# day_complete_celebration: input_text.nestquest_last_celebrated, +# celebration_message "Legendary! {{ trigger.payload.child_name }} cleared every quest today!" +# +# Every blueprint has its own send/celebrate toggle input; turn an +# automation off entirely from HA's automation menu. +``` + +### Events the automations rely on + +| Event | Payload | +|---|---| +| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time | +| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at | +| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at | +| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) | + +Sensors the automations read: `sensor.nestquest__quests_due_today` +(attributes: `child_id`, `child_name`, `present`, `instances`, +`admin_instances`), `..._quests_remaining_today`, +`..._quests_completed_today`, and +`binary_sensor.nestquest__present_today`. + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. diff --git a/tests/test_blueprint_afternoon.py b/tests/test_blueprint_afternoon.py index 7c13c77..d721bbc 100644 --- a/tests/test_blueprint_afternoon.py +++ b/tests/test_blueprint_afternoon.py @@ -241,4 +241,28 @@ def test_afternoon_reminder_never_hard_codes_notify_target() -> None: assert re.search(r"notify\.[a-z_]+", target_description), ( "the input description should show a placeholder example" ) - assert single_action(bp)["action"] == BlueprintInput("notify_target") \ No newline at end of file + assert single_action(bp)["action"] == BlueprintInput("notify_target") + +def test_afternoon_reminder_absent_child_regression() -> None: + """The Feature 11 guardrail, stated as one regression test: no + reminder message is ever produced for an absent child.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "3", + {"child_name": "Bob", "child_id": 1, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "3", + {"child_id": 1}, + ), + ], + ) + guard = afternoon_conditions(afternoon_reminder())[-1] + assert render(guard["value_template"], states=states).strip() == "False" + message = render( + notify_data(afternoon_reminder())["message"], states=states + ) + assert "Bob" not in message diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py index 9d61277..2e20b81 100644 --- a/tests/test_blueprint_celebration.py +++ b/tests/test_blueprint_celebration.py @@ -272,3 +272,25 @@ def test_celebration_mode_is_queued() -> None: """Two children completing at nearly the same time must both be processed: queued mode does not drop the second event.""" assert celebration()["mode"] == "queued" + + +def test_every_blueprint_event_reference_is_real() -> None: + """Every event a blueprint triggers on is one NestQuest actually + fires or defines (Feature 10/11) — no invented event names.""" + from tests.blueprint_helpers import load_blueprints + + real_events = { + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", + } + for name, bp in load_blueprints().items(): + for trigger in bp.get("triggers", []): + if trigger.get("trigger") == "event": + assert trigger["event_type"] in real_events, ( + f"{name}: invented event {trigger['event_type']!r}" + ) + assert celebration()["triggers"][0]["event_type"] == ( + "nestquest_child_day_complete" + ) -- 2.55.0 From ca931767680c71915db86fb89e8e5d87cf9ca3e9 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:54:03 -0400 Subject: [PATCH 14/14] Read the celebration payload from trigger.event.data HA event triggers expose the payload as trigger.event.data, not trigger.payload; fix the blueprint, its tests, and the README examples, with a regression guard. --- README.md | 4 +-- .../automation/day_complete_celebration.yaml | 10 +++--- tests/test_blueprint_celebration.py | 31 ++++++++++++++----- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0ed4e2b..5f54fdd 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ triggers: event_type: nestquest_child_day_complete conditions: - condition: template - value_template: "{{ trigger.payload.child_name == 'Declan' }}" + value_template: "{{ trigger.event.data.child_name == 'Declan' }}" actions: - action: notify.mobile_app_declans_phone data: @@ -134,7 +134,7 @@ day-rollover time. # afternoon_reminder: afternoon_time 15:00, same notify target # end_of_day_report: report_time 20:00, send_even_when_clear false, same target # day_complete_celebration: input_text.nestquest_last_celebrated, -# celebration_message "Legendary! {{ trigger.payload.child_name }} cleared every quest today!" +# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!" # # Every blueprint has its own send/celebrate toggle input; turn an # automation off entirely from HA's automation menu. diff --git a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml index 3672adc..7d0e029 100644 --- a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -34,9 +34,9 @@ blueprint: name: Celebration message description: >- The message sent when a child clears every quest today. May - use the event payload: trigger.payload.child_name, - trigger.payload.quests_due and trigger.payload.quests_completed. - default: "Legendary! {{ trigger.payload.child_name }} cleared every quest today!" + use the event payload: trigger.event.data.child_name, + trigger.event.data.quests_due and trigger.event.data.quests_completed. + default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!" selector: text: {} celebrate: @@ -80,7 +80,7 @@ conditions: # reads as '' and celebrates. - condition: template value_template: >- - {{ (trigger.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) + {{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) not in ((states(last_celebrated_helper) or '') .split(',') | select('ne', '') @@ -106,4 +106,4 @@ actions: {%- set ns.entries = ns.entries + [entry] -%} {%- endif -%} {%- endfor -%} - {{ (ns.entries + [trigger.payload.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file + {{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py index 2e20b81..f877854 100644 --- a/tests/test_blueprint_celebration.py +++ b/tests/test_blueprint_celebration.py @@ -65,14 +65,17 @@ def stamp_value_template(bp: dict) -> str: def trigger_payload(child_id: int, child_name: str) -> dict: - """A nestquest_child_day_complete trigger payload fixture.""" + """A nestquest_child_day_complete trigger fixture: HA exposes an + event trigger's payload as trigger.event.data.""" return { - "payload": { - "child_id": child_id, - "child_name": child_name, - "quests_due": 3, - "quests_completed": 3, - "occurred_at": "2026-09-19T15:00:00+00:00", + "event": { + "data": { + "child_id": child_id, + "child_name": child_name, + "quests_due": 3, + "quests_completed": 3, + "occurred_at": "2026-09-19T15:00:00+00:00", + } } } @@ -294,3 +297,17 @@ def test_every_blueprint_event_reference_is_real() -> None: assert celebration()["triggers"][0]["event_type"] == ( "nestquest_child_day_complete" ) + + +def test_celebration_uses_the_event_trigger_data_path() -> None: + """HA exposes an event trigger's payload as trigger.event.data — + never trigger.payload (Codex review regression).""" + bp = celebration() + for value in iter_strings(bp): + assert "trigger.payload" not in value, value + default_message = bp["blueprint"]["input"]["celebration_message"][ + "default" + ] + assert "trigger.event.data.child_name" in default_message + for condition in celebration_conditions(bp): + assert "trigger.payload" not in condition["value_template"] -- 2.55.0