diff --git a/README.md b/README.md index 441ad74..5f54fdd 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,101 @@ 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: 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.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. +``` + +### 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/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/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml new file mode 100644 index 0000000..34ea11c --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -0,0 +1,90 @@ +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 + 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 -%} + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file 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..7d0e029 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -0,0 +1,109 @@ +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.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: + 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: queued + +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 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.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) + not in ((states(last_celebrated_helper) or '') + .split(',') + | select('ne', '') + | list) }} + +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.event.data.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file 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..7809903 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml @@ -0,0 +1,121 @@ +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 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) -%} + {%- 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 not remaining_values or (remaining_values | first | int(-1)) != 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/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml new file mode 100644 index 0000000..ea38e1d --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -0,0 +1,78 @@ +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 + 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 ~ "'s quest count is unavailable today." -%} + {%- endif -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. + {%- endif -%} 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/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/options_flow.py b/custom_components/nestquest/options_flow.py index 32ddd3b..bf5c59f 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,51 @@ 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): + 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, + 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 +139,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 +157,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 +321,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 +359,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/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..e60ab72 --- /dev/null +++ b/custom_components/nestquest/sweep.py @@ -0,0 +1,132 @@ +"""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 asyncio +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" + +#: 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" + + +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). + + 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 + if today is not None + else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + ) + today_iso = today_date.isoformat() + dao = MetaStateDao(database) + 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) + 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/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/blueprint_helpers.py b/tests/blueprint_helpers.py new file mode 100644 index 0000000..d4ec16e --- /dev/null +++ b/tests/blueprint_helpers.py @@ -0,0 +1,206 @@ +"""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 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): + 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") + data = notify_data(afternoon_reminder()) + assert data["title"] == AFTERNOON_TITLE + assert isinstance(data["message"], str) and data["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(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 + 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(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.", + ] + + +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") + +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 new file mode 100644 index 0000000..f877854 --- /dev/null +++ b/tests/test_blueprint_celebration.py @@ -0,0 +1,313 @@ +"""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 fixture: HA exposes an + event trigger's payload as trigger.event.data.""" + return { + "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", + } + } + } + + +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") + +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" + + +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" + ) + + +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"] diff --git a/tests/test_blueprint_end_of_day.py b/tests/test_blueprint_end_of_day.py new file mode 100644 index 0000000..58856c5 --- /dev/null +++ b/tests/test_blueprint_end_of_day.py @@ -0,0 +1,338 @@ +"""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") + +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" diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..ac8a991 --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,336 @@ +"""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 — all via the shared +harness in :mod:`tests.blueprint_helpers`. + +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 +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 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, + _MENTION_RE, + iter_strings, + load_blueprints, + make_states, + morning_action, + morning_summary, + render, + TemplateState, +) + +MORNING_TITLE = "NestQuest morning summary" + + +# --------------------------------------------------------------------------- +# 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") + data = notify_data(bp) + assert data["title"] == MORNING_TITLE + assert isinstance(data["message"], str) and data["message"].strip() + + +# --------------------------------------------------------------------------- +# 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. +# --------------------------------------------------------------------------- +def test_entity_literals_match_feature_10_shapes() -> 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_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 = notify_data(morning_summary())["message"] + match = re.search( + r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message + ) + assert match is not None, "due-sensor pattern missing from the template" + pattern = match.group(1).replace("\\\\", "\\") + assert pattern.endswith("$") + 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: + """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", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"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", "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 = notify_data(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( + sensors=[ + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 2}, + ), + ], + ) + 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 + + +def test_morning_summary_present_child_without_sensors_is_flagged() -> None: + """A present child whose remaining-count sensor is missing (the + platform failed to add it) still gets a line, flagged unavailable.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_frances_quests_due_today", + "2", + {"child_name": "Frances", "child_id": 1, "present": True}, + ), + ], + ) + rendered = render(notify_data(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(notify_data(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( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": False}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "3", + {"child_id": 1}, + ), + ], + ) + rendered = render(notify_data(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" \ 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_options_flow.py b/tests/test_options_flow.py index af949b6..f0c8385 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,127 @@ 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_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()) + # 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( + { + **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] == "" 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..c76ac81 --- /dev/null +++ b/tests/test_sweep.py @@ -0,0 +1,259 @@ +"""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] + + +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 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"