Compare commits

..
Author SHA1 Message Date
orchestrator-agent 3a2e6faa69 Document the automation package and test event validity
README gains the blueprint section: all four blueprints, their
inputs, the wiring table to the options-flow notification settings,
the celebration helper setup, event/sensor payload reference, and a
copy-pasteable example package. Tests assert every event trigger
references a real NestQuest event and no reminder is produced for an
absent child.
2026-09-19 14:52:12 -04:00
orchestrator-agent 5092369d49 Add the day-complete celebration blueprint 2026-09-19 14:51:01 -04:00
orchestrator-agent f6166f25d9 Compare celebration stamps as whole entries; queue events
Child ids sharing a prefix no longer suppress each other (split and
compare entries for equality), and queued mode keeps a second child's
day-complete event from being dropped while the first notify runs.
2026-09-19 14:50:59 -04:00
orchestrator-agent bf84793a1c Add the day-complete celebration blueprint
Triggers on nestquest_child_day_complete (never a zero-quest day),
notifies with the payload's child name, and dedupes per child per
day via an input_text helper holding <child_id>:<today> entries
pruned to today — a re-cleared day does not re-fire, but a second
child clearing the same day still celebrates.
2026-09-19 14:47:52 -04:00
orchestrator-agent 9d3cb30617 Add the end-of-day parent report blueprint 2026-09-19 14:47:38 -04:00
orchestrator-agent b913772007 Report when remaining counts are unavailable, not all-clear
A missing or non-numeric remaining sensor reads as -1 in the
all-clear guard, so a degraded day reaches the parent instead of
looking like a clear day.
2026-09-19 14:47:37 -04:00
orchestrator-agent d2f9c50930 Add the end-of-day parent report blueprint
Defaults to 20:00 local — deliberately before the midnight missed
sweep, reading the same day's data. Every present child gets a
completion line; owed lines name still-open quests; absent children
are omitted; nothing sends when all clear unless opted in.
2026-09-19 14:46:13 -04:00
orchestrator-agent 19a17b647d Add the afternoon open-quests reminder blueprint 2026-09-19 14:45:52 -04:00
6 changed files with 977 additions and 1 deletions
+88
View File
@@ -67,6 +67,94 @@ actions:
message: "Every quest cleared — legendary!"
```
## Automation Blueprints
NestQuest ships four automation blueprints in
`custom_components/nestquest/blueprints/automation/`. Import them in
Home Assistant under **Settings → Automations & Scenes → Blueprints**
(or place the files in your `config/blueprints/automation/` folder),
then create one automation per blueprint, filling the inputs with your
household's choices.
### Wiring to the integration's notification settings
The integration's options flow records your notification choices —
notify target, morning/afternoon/end-of-day times, and a toggle per
automation. The blueprints take their own inputs at import time;
fill them from the same values you stored in the options flow (the
integration does not push its settings into imported automations):
| Blueprint input | Options-flow counterpart |
|---|---|
| `morning_time` (default 08:00) | `morning_summary_time` |
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
| `report_time` (default 20:00) | `end_of_day_report_time` |
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
The end-of-day report deliberately runs BEFORE the midnight missed
sweep and reads the same day's data — do not schedule it at the
day-rollover time.
### The four blueprints
1. **Morning summary** (`morning_summary.yaml`) — at the configured
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
is on and what they owe from `sensor.nestquest_<child>_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 `<child_id>:<today>` and
celebrates each child at most once per day — a re-cleared day does
not re-fire, but a second child clearing the same day still
celebrates.
### Example package
```yaml
# One automation per blueprint — Settings → Automations & Scenes →
# Create → Blueprint, then pick each NestQuest blueprint.
#
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
# afternoon_reminder: afternoon_time 15:00, same notify target
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
# day_complete_celebration: input_text.nestquest_last_celebrated,
# celebration_message "Legendary! {{ trigger.payload.child_name }} cleared every quest today!"
#
# Every blueprint has its own send/celebrate toggle input; turn an
# automation off entirely from HA's automation menu.
```
### Events the automations rely on
| Event | Payload |
|---|---|
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
(attributes: `child_id`, `child_name`, `present`, `instances`,
`admin_instances`), `..._quests_remaining_today`,
`..._quests_completed_today`, and
`binary_sensor.nestquest_<child>_present_today`.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
@@ -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
"<child_id>:<today>" inside that helper, and a child who
un-completes a quest and completes it again the same day is
celebrated once, not twice — while a SECOND child clearing their
day the same date is still celebrated in full. The helper is
pruned to today's entries on every celebration, so it never
grows past one entry per child per day. A helper that does not
exist yet, or has never been set, lets the first celebration
through. The notification message may use the event payload's
child_name, quests_due and quests_completed. The notify service
is chosen by the household when importing this blueprint —
NestQuest never hard-codes a personal notify target.
domain: automation
input:
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the celebration,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
celebration_message:
name: Celebration message
description: >-
The message sent when a child clears every quest today. May
use the event payload: trigger.payload.child_name,
trigger.payload.quests_due and trigger.payload.quests_completed.
default: "Legendary! {{ trigger.payload.child_name }} cleared every quest today!"
selector:
text: {}
celebrate:
name: Send the celebration
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
last_celebrated_helper:
name: Last-celebrated helper
description: >-
A single input_text helper remembering which children were
already celebrated today, as "<child_id>:<date>" 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.payload.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.payload.child_id ~ ':' ~ today]) | join(',') }}
@@ -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') -}}
+25 -1
View File
@@ -241,4 +241,28 @@ def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert single_action(bp)["action"] == BlueprintInput("notify_target")
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
+296
View File
@@ -0,0 +1,296 @@
"""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
``<child_id>:<today>`` entry stored, and the action rewrites the
helper with only today's entries plus the new one — so a child who
un-completes and re-completes the same day is celebrated once, not
twice, while a second child clearing the same day is still celebrated.
The harness (YAML ``!input`` loader, state fixtures, jinja rendering)
is shared in :mod:`tests.blueprint_helpers`; the directory-wide shape
and entity-discipline tests in test_blueprints.py cover this file
too. ``now()`` in the dedupe condition is injected by the test as a
fixed callable, keeping the render deterministic.
"""
from __future__ import annotations
import datetime
import re
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
from tests.blueprint_helpers import (
BlueprintInput,
notify_data,
blueprint,
iter_strings,
make_states,
render,
TemplateState,
)
CELEBRATION_TITLE = "NestQuest day complete"
HELPER_ENTITY = "input_text.nestquest_last_celebrated"
#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local.
FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0)
def celebration() -> dict:
"""Return the parsed day-complete-celebration blueprint."""
return blueprint("day_complete_celebration")
def celebration_conditions(bp: dict) -> list[dict]:
"""Return the blueprint's conditions: toggle first, dedupe second."""
conditions = bp["conditions"]
assert isinstance(conditions, list) and len(conditions) == 2
return conditions
def dedupe_condition(bp: dict) -> str:
"""Return the once-per-child-per-day dedupe template (true = celebrate)."""
return celebration_conditions(bp)[1]["value_template"]
def stamp_value_template(bp: dict) -> str:
"""Return the helper-stamp action's value template."""
stamp = bp["actions"][1]
return stamp["data"]["value"]
def trigger_payload(child_id: int, child_name: str) -> dict:
"""A nestquest_child_day_complete trigger payload fixture."""
return {
"payload": {
"child_id": child_id,
"child_name": child_name,
"quests_due": 3,
"quests_completed": 3,
"occurred_at": "2026-09-19T15:00:00+00:00",
}
}
def render_dedupe(
helper_state: str | None, child_id: int = 1
) -> str:
"""Render the dedupe condition with the helper holding helper_state.
``None`` models a helper entity the fixture does not hold (never
created or not yet set), which HA exposes as a missing state.
"""
states = make_states(
input_texts=(
[]
if helper_state is None
else [TemplateState(HELPER_ENTITY, helper_state)]
)
)
return render(
dedupe_condition(celebration()),
states=states,
last_celebrated_helper=HELPER_ENTITY,
now=lambda: FIXED_NOW,
trigger=trigger_payload(child_id, "Ada"),
).strip()
def render_stamp(helper_state: str | None, child_id: int = 1) -> str:
"""Render the stamp action's value with the helper holding helper_state."""
states = make_states(
input_texts=(
[]
if helper_state is None
else [TemplateState(HELPER_ENTITY, helper_state)]
)
)
return render(
stamp_value_template(celebration()),
states=states,
last_celebrated_helper=HELPER_ENTITY,
now=lambda: FIXED_NOW,
trigger=trigger_payload(child_id, "Ada"),
).strip()
# ---------------------------------------------------------------------------
# Trigger, inputs, defaults, and wiring.
# ---------------------------------------------------------------------------
def test_celebration_triggers_on_the_child_day_complete_event() -> None:
"""The event trigger references the real nestquest_child_day_complete."""
trigger = celebration()["triggers"][0]
assert trigger["trigger"] == "event"
assert trigger["event_type"] == "nestquest_child_day_complete"
assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE
def test_celebration_declares_inputs_with_selectors() -> None:
"""The four inputs exist, carry selectors, and default sanely."""
bp = celebration()
inputs = bp["blueprint"]["input"]
expected = {
"notify_target",
"celebration_message",
"celebrate",
"last_celebrated_helper",
}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["celebrate"]["default"] is True
assert "text" in inputs["notify_target"]["selector"]
assert "text" in inputs["celebration_message"]["selector"]
assert "boolean" in inputs["celebrate"]["selector"]
assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [
{"domain": "input_text"}
]
# The description documents the zero-quest-day rule and the dedupe.
description = bp["blueprint"]["description"]
assert "zero-quest day" in description
assert "once" in description.lower()
def test_celebration_wires_inputs_not_literals() -> None:
"""Notify and stamp actions both use !input; the dedupe reads the helper."""
bp = celebration()
assert bp["variables"]["celebrate"] == BlueprintInput("celebrate")
assert bp["variables"]["last_celebrated_helper"] == BlueprintInput(
"last_celebrated_helper"
)
toggle, dedupe = celebration_conditions(bp)
assert toggle["condition"] == "template"
assert "celebrate" in toggle["value_template"]
assert dedupe["condition"] == "template"
assert "last_celebrated_helper" in dedupe["value_template"]
assert "child_id" in dedupe["value_template"], (
"the dedupe must be per child, not per household"
)
notify_action, stamp_action = bp["actions"]
assert notify_action["action"] == BlueprintInput("notify_target")
data = notify_data(bp)
assert data["title"] == CELEBRATION_TITLE
assert data["message"] == BlueprintInput("celebration_message")
assert stamp_action["action"] == "input_text.set_value"
assert stamp_action["target"]["entity_id"] == BlueprintInput(
"last_celebrated_helper"
)
assert "now()" in stamp_action["data"]["value"]
def test_celebration_message_default_renders_the_child_name() -> None:
"""The default message renders the trigger payload's child_name."""
default_message = celebration()["blueprint"]["input"]["celebration_message"][
"default"
]
rendered = render(default_message, trigger=trigger_payload(1, "Ada"))
assert rendered == "Legendary! Ada cleared every quest today!"
# ---------------------------------------------------------------------------
# The once-per-child-per-day dedupe condition.
# ---------------------------------------------------------------------------
def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None:
"""A helper without THIS child's entry for today lets the
celebration through — including when another child was celebrated
earlier the same day."""
assert render_dedupe(None) == "True"
assert render_dedupe("") == "True"
assert render_dedupe("2:2026-09-19") == "True", (
"a second child clearing the same day must still celebrate"
)
assert render_dedupe("1:2026-09-18") == "True", (
"yesterday's entry never blocks today"
)
def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None:
"""A child already celebrated today is suppressed — the un-complete
and re-complete the same day case."""
assert render_dedupe("1:2026-09-19") == "False"
assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False"
def test_celebration_stamp_prunes_to_today_and_appends() -> None:
"""The stamp keeps only today's entries plus the new one: yesterday's
entries are dropped (the helper never outgrows its text limit and
stale entries cannot block next week), and the celebrated child is
recorded."""
assert render_stamp(None, child_id=1) == "1:2026-09-19"
assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19"
assert render_stamp("1:2026-09-19", child_id=2) == (
"1:2026-09-19,2:2026-09-19"
)
assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == (
"3:2026-09-19"
)
def test_celebration_celebrate_toggle_renders() -> None:
"""The enable condition renders truthy/false from the input value."""
toggle = celebration_conditions(celebration())[0]
assert render(toggle["value_template"], celebrate=True).strip() == "True"
assert render(toggle["value_template"], celebrate=False).strip() == "False"
def test_celebration_never_hard_codes_notify_target() -> None:
"""No notify service literal outside the input's placeholder text."""
bp = celebration()
spec = bp["blueprint"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert bp["actions"][0]["action"] == BlueprintInput("notify_target")
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"
)
+338
View File
@@ -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"