From ad6f1768d72157e745c9d2e942f65a24032c16c3 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:42:50 -0400 Subject: [PATCH 1/2] Add the afternoon open-quests reminder blueprint Lists only present children who still owe quests; sends nothing at all when every present child is clear (a false template condition stops the action). Blueprint test helpers move to a shared module. --- .../automation/afternoon_reminder.yaml | 89 +++++++ tests/blueprint_helpers.py | 196 ++++++++++++++ tests/test_blueprint_afternoon.py | 242 ++++++++++++++++++ tests/test_blueprints.py | 154 ++--------- 4 files changed, 550 insertions(+), 131 deletions(-) create mode 100644 custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml create mode 100644 tests/blueprint_helpers.py create mode 100644 tests/test_blueprint_afternoon.py diff --git a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml new file mode 100644 index 0000000..ffe0b73 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -0,0 +1,89 @@ +blueprint: + name: NestQuest afternoon reminder + description: >- + Sends an afternoon nudge listing ONLY the children who still have + open quests today. Children who are present and all clear are + omitted (the nudge is for stragglers, not a roll call), children + who are not present today are omitted entirely, and the household + rollup sensors are excluded by the same present guard. When every + present child is already done the automation sends NOTHING at all: + a template condition stops the run before the notification. The + notify service is chosen by the household when importing this + blueprint — NestQuest never hard-codes a personal notify target. + domain: automation + input: + afternoon_time: + name: Afternoon time + description: Local time of day the reminder is sent. + default: "15:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the reminder, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_reminder: + name: Send the reminder + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + +mode: single + +variables: + send_reminder: !input send_reminder + +triggers: + - trigger: time + at: !input afternoon_time + +conditions: + - condition: template + value_template: "{{ send_reminder | bool }}" + # Send nothing when every present child is already done: the guard is + # false unless at least one present child has remaining quests today. + - condition: template + value_template: | + {%- set ns = namespace(any_open=false) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set ns.any_open = true -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.any_open -}} + +actions: + - action: !input notify_target + title: NestQuest afternoon reminder + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/tests/blueprint_helpers.py b/tests/blueprint_helpers.py new file mode 100644 index 0000000..5752fbb --- /dev/null +++ b/tests/blueprint_helpers.py @@ -0,0 +1,196 @@ +"""Shared harness for the NestQuest automation-blueprint tests. + +The blueprints are YAML data consumed by Home Assistant's blueprint +engine; the integration itself never imports yaml or jinja2. The +blueprint tests therefore exercise them exactly the way HA would: +parse the YAML (with a loader that understands ``!input`` tags), then +render the message/condition templates with a plain jinja2 environment +over a fixture state set shaped like HA's template state objects. + +The harness is mock-only (see conftest.py), so the fixtures model just +the surface the templates touch: ``states.binary_sensor`` / +``states.sensor`` lists of objects carrying ``entity_id``, ``state`` +and ``attributes`` — plus, for the celebration blueprint, a callable +``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's +``search``/``match`` selectattr tests are Home Assistant additions, +registered here explicitly. + +This module is plain helper code (deliberately not named ``test_*`` so +pytest never collects it); every blueprint test file imports from it. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import jinja2 +import yaml + +REPO_ROOT = Path(__file__).parent.parent +BLUEPRINT_DIR = ( + REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +) + + +class BlueprintInput(str): + """Marker for a parsed blueprint ``!input `` reference.""" + + +def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput: + return BlueprintInput(loader.construct_scalar(node)) + + +class _BlueprintLoader(yaml.SafeLoader): + """SafeLoader extended with Home Assistant's ``!input`` blueprint tag.""" + + +_BlueprintLoader.add_constructor("!input", _input_constructor) + + +def load_blueprints() -> dict[str, dict]: + """Parse every automation blueprint, asserting the directory exists.""" + assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}" + paths = sorted(BLUEPRINT_DIR.glob("*.yaml")) + assert paths, f"no blueprints under {BLUEPRINT_DIR}" + return { + path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader) + for path in paths + } + + +def blueprint(name: str) -> dict: + """Return one parsed blueprint by its file stem.""" + blueprints = load_blueprints() + assert name in blueprints, f"{name}.yaml is missing" + return blueprints[name] + + +def morning_summary() -> dict: + """Return the parsed morning-summary blueprint.""" + return blueprint("morning_summary") + + +def single_action(bp: dict) -> dict: + """Return the single action of a one-action blueprint.""" + actions = bp["actions"] + assert isinstance(actions, list) and len(actions) == 1 + return actions[0] + + +def morning_action(bp: dict) -> dict: + """Return the single notify action of the morning summary.""" + return single_action(bp) + + +def iter_strings(node: object): + """Yield every string value in a parsed YAML structure, recursively.""" + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for value in node.values(): + yield from iter_strings(value) + elif isinstance(node, list): + for value in node: + yield from iter_strings(value) + + +class TemplateState: + """Stand-in for an HA template state object: entity_id/state/attributes.""" + + def __init__(self, entity_id: str, state: str, attributes: dict | None = None): + self.entity_id = entity_id + self.state = state + self.attributes = dict(attributes or {}) + + +class StatesFixture: + """HA's template ``states`` object over a fixture entity set. + + Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``, + ``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is + callable as ``states(entity_id)`` returning the entity's state + string — or ``None`` when the fixture holds no such entity, the + way HA returns ``None`` for a nonexistent entity id. + """ + + def __init__( + self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=() + ): + self.binary_sensor = list(binary_sensors) + self.sensor = list(sensors) + self.input_datetime = list(input_datetimes) + self.input_text = list(input_texts) + self._all = ( + self.binary_sensor + + self.sensor + + self.input_datetime + + self.input_text + ) + + def __call__(self, entity_id: str): + """Return the entity's state string, or None when absent.""" + for entity in self._all: + if entity.entity_id == entity_id: + return entity.state + return None + + +def make_states( + binary_sensors=(), sensors=(), input_datetimes=(), input_texts=() +) -> StatesFixture: + """Build the ``states`` fixture: per-domain lists of TemplateState.""" + return StatesFixture( + binary_sensors, sensors, input_datetimes, input_texts + ) + + +def make_template_environment() -> jinja2.Environment: + """A plain jinja2 environment with HA's regex tests and bool filter.""" + env = jinja2.Environment() + env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None + env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None + + def _bool(value, default=False): + if isinstance(value, str): + return value.strip().lower() in ("true", "on", "open", "yes", "1") + return bool(value) or default + + env.filters["bool"] = _bool + return env + + +def render(template: str, **variables) -> str: + """Render a template the way HA would substitute its variables.""" + return make_template_environment().from_string(template).render(**variables) + + +# --------------------------------------------------------------------------- +# Entity-reference discipline (Feature 10 shapes only). The scanners +# are shared so every blueprint test file can assert the same rules; +# the directory-wide tests in test_blueprints.py apply them to every +# blueprint file automatically. +# --------------------------------------------------------------------------- +#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py). +ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple( + re.compile(pattern) + for pattern in ( + r"sensor\.nestquest_[a-z0-9_]+_quests_due_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today", + r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today", + r"sensor\.nestquest_[a-z0-9_]+_next_quest", + r"binary_sensor\.nestquest_[a-z0-9_]+_all_done", + r"binary_sensor\.nestquest_[a-z0-9_]+_present_today", + r"sensor\.nestquest_household_quests_due_today", + r"sensor\.nestquest_household_quests_completed_today", + r"sensor\.nestquest_cycle_day", + ) +) + +#: An unescaped, id-like entity reference: any found must be a full +#: match for one of the Feature 10 shapes above. +_ENTITY_LITERAL_RE = re.compile(r"(? dict: + """Return the parsed afternoon-reminder blueprint.""" + return blueprint("afternoon_reminder") + + +def afternoon_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, guard second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def all_clear_guard(bp: dict) -> str: + """Return the template that is false when every present child is done.""" + return afternoon_conditions(bp)[1]["value_template"] + + +# --------------------------------------------------------------------------- +# Inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_afternoon_reminder_declares_inputs_with_selectors() -> None: + """The three inputs exist, carry selectors, and default sanely.""" + inputs = afternoon_reminder()["blueprint"]["input"] + expected = {"afternoon_time", "notify_target", "send_reminder"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["afternoon_time"]["default"] == DEFAULT_AFTERNOON_REMINDER_TIME + assert DEFAULT_AFTERNOON_REMINDER_TIME == "15:00" + assert inputs["send_reminder"]["default"] is True + assert "time" in inputs["afternoon_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_reminder"]["selector"] + + +def test_afternoon_reminder_wires_inputs_not_literals() -> None: + """Time trigger, toggle and guard conditions, and notify action use !input.""" + bp = afternoon_reminder() + assert bp["variables"]["send_reminder"] == BlueprintInput("send_reminder") + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("afternoon_time") + + toggle, guard = afternoon_conditions(bp) + assert toggle["condition"] == "template" + assert "send_reminder" in toggle["value_template"] + assert guard["condition"] == "template" + + action = single_action(bp) + assert action["action"] == BlueprintInput("notify_target") + assert action["title"] == AFTERNOON_TITLE + assert isinstance(action["message"], str) and action["message"].strip() + + +# --------------------------------------------------------------------------- +# Rendering: only present children with open quests are listed. +# --------------------------------------------------------------------------- +def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None: + """Present+open Alice is listed; present+clear Carol, absent Bob, + and the household rollup are all omitted.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 3, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 3}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "7"), + ], + ) + rendered = render(single_action(afternoon_reminder())["message"], states=states) + assert rendered == "Alice still has 2 quest(s) open." + assert "Carol" not in rendered + assert "Bob" not in rendered + assert "household" not in rendered + + +def test_afternoon_reminder_all_clear_guard_renders_false() -> None: + """With every present child done (and absent children owing), the + guard condition is false: the automation sends nothing at all.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "2", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 2, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 2}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "2"), + ], + ) + guard = all_clear_guard(afternoon_reminder()) + assert render(guard, states=states).strip() == "False" + + +def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None: + """A present child with open quests makes the guard true.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "1", + {"child_id": 1}, + ), + ], + ) + guard = all_clear_guard(afternoon_reminder()) + assert render(guard, states=states).strip() == "True" + + +def test_afternoon_reminder_send_reminder_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + toggle = afternoon_conditions(afternoon_reminder())[0] + assert render(toggle["value_template"], send_reminder=True).strip() == "True" + assert render(toggle["value_template"], send_reminder=False).strip() == "False" + + +def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name (HA registry suffix _2 on the + second one's entity ids) both appear, each with their own count.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "3", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "3", + {"child_id": 2}, + ), + ], + ) + rendered = render(single_action(afternoon_reminder())["message"], states=states) + assert rendered.split("\n") == [ + "Ada still has 1 quest(s) open.", + "Ada still has 3 quest(s) open.", + ] + + +def test_afternoon_reminder_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = afternoon_reminder() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert single_action(bp)["action"] == BlueprintInput("notify_target") \ No newline at end of file diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index dda97b7..fbd49c3 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,17 +1,17 @@ -"""Tests for the NestQuest automation blueprints. +"""Tests for the NestQuest morning-summary automation blueprint. The blueprints are YAML data consumed by Home Assistant's blueprint engine; the integration itself never imports yaml or jinja2. These tests therefore exercise them exactly the way HA would: parse the YAML (with a loader that understands ``!input`` tags), then render the message template with a plain jinja2 environment over a fixture state -set shaped like HA's template state objects. +set shaped like HA's template state objects — all via the shared +harness in :mod:`tests.blueprint_helpers`. -The harness is mock-only (see conftest.py), so the fixtures model just -the surface the templates touch: ``states.binary_sensor`` / -``states.sensor`` lists of objects carrying ``entity_id``, ``state`` and -``attributes``. Jinja's ``search``/``match`` selectattr tests are -Home Assistant additions, registered here explicitly. +This module also carries the directory-wide blueprint discipline tests +(entity-id shapes, domain mentions, notify-target hygiene), which scan +EVERY blueprint file under the automation directory, so new blueprint +files are covered automatically. Rendering policy under test (settled for the morning summary): a PRESENT child always appears — with remaining quests owed, or as "all @@ -21,111 +21,27 @@ omitted entirely, never listed with zero quests. from __future__ import annotations import re -from pathlib import Path from types import SimpleNamespace -import jinja2 -import yaml - from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME -REPO_ROOT = Path(__file__).parent.parent -BLUEPRINT_DIR = ( - REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +from tests.blueprint_helpers import ( + ALLOWED_ENTITY_SHAPES, + BlueprintInput, + _ENTITY_LITERAL_RE, + _MENTION_RE, + iter_strings, + load_blueprints, + make_states, + morning_action, + morning_summary, + render, + TemplateState, ) MORNING_TITLE = "NestQuest morning summary" -class BlueprintInput(str): - """Marker for a parsed blueprint ``!input `` reference.""" - - -def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput: - return BlueprintInput(loader.construct_scalar(node)) - - -class _BlueprintLoader(yaml.SafeLoader): - """SafeLoader extended with Home Assistant's ``!input`` blueprint tag.""" - - -_BlueprintLoader.add_constructor("!input", _input_constructor) - - -def load_blueprints() -> dict[str, dict]: - """Parse every automation blueprint, asserting the directory exists.""" - assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}" - paths = sorted(BLUEPRINT_DIR.glob("*.yaml")) - assert paths, f"no blueprints under {BLUEPRINT_DIR}" - return { - path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader) - for path in paths - } - - -def iter_strings(node: object): - """Yield every string value in a parsed YAML structure, recursively.""" - if isinstance(node, str): - yield node - elif isinstance(node, dict): - for value in node.values(): - yield from iter_strings(value) - elif isinstance(node, list): - for value in node: - yield from iter_strings(value) - - -def morning_summary() -> dict: - """Return the parsed morning-summary blueprint.""" - blueprints = load_blueprints() - assert "morning_summary" in blueprints, "morning_summary.yaml is missing" - return blueprints["morning_summary"] - - -def morning_action(bp: dict) -> dict: - """Return the single notify action of a summary blueprint.""" - actions = bp["actions"] - assert isinstance(actions, list) and len(actions) == 1 - return actions[0] - - -class TemplateState: - """Stand-in for an HA template state object: entity_id/state/attributes.""" - - def __init__(self, entity_id: str, state: str, attributes: dict | None = None): - self.entity_id = entity_id - self.state = state - self.attributes = dict(attributes or {}) - - -def make_states(binary_sensors=(), sensors=()) -> SimpleNamespace: - """Build the ``states`` fixture: per-domain lists of TemplateState.""" - return SimpleNamespace( - binary_sensor=list(binary_sensors), - sensor=list(sensors), - ) - - -def make_template_environment() -> jinja2.Environment: - """A plain jinja2 environment with HA's regex tests and bool filter.""" - env = jinja2.Environment() - env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None - env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None - - def _bool(value, default=False): - if isinstance(value, str): - return value.strip().lower() in ("true", "on", "open", "yes", "1") - return bool(value) or default - - env.filters["bool"] = _bool - return env - - -def render(template: str, **variables) -> str: - """Render a template the way HA would substitute its variables.""" - return make_template_environment().from_string(template).render(**variables) - - # --------------------------------------------------------------------------- # YAML parsing and blueprint shape. # --------------------------------------------------------------------------- @@ -177,34 +93,10 @@ def test_morning_summary_wires_inputs_not_literals() -> None: # --------------------------------------------------------------------------- -# Entity-reference discipline (Feature 10 shapes only). +# Entity-reference discipline (Feature 10 shapes only). These tests +# scan EVERY blueprint in the directory, so new blueprint files are +# automatically held to the same rules. # --------------------------------------------------------------------------- -#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py). -ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple( - re.compile(pattern) - for pattern in ( - r"sensor\.nestquest_[a-z0-9_]+_quests_due_today", - r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today", - r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today", - r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today", - r"sensor\.nestquest_[a-z0-9_]+_next_quest", - r"binary_sensor\.nestquest_[a-z0-9_]+_all_done", - r"binary_sensor\.nestquest_[a-z0-9_]+_present_today", - r"sensor\.nestquest_household_quests_due_today", - r"sensor\.nestquest_household_quests_completed_today", - r"sensor\.nestquest_cycle_day", - ) -) - -#: An unescaped, id-like entity reference: any found must be a full -#: match for one of the Feature 10 shapes above. -_ENTITY_LITERAL_RE = re.compile(r"(? None: """Every id-like entity string in the YAML is a Feature 10 shape.""" for name, bp in load_blueprints().items(): @@ -439,4 +331,4 @@ def test_morning_summary_send_summary_toggle_renders() -> None: """The enable condition renders truthy/false from the input value.""" condition = morning_summary()["conditions"][0] assert render(condition["value_template"], send_summary=True).strip() == "True" - assert render(condition["value_template"], send_summary=False).strip() == "False" + assert render(condition["value_template"], send_summary=False).strip() == "False" \ No newline at end of file -- 2.55.0 From 715aac994a9ab0badb771ba80f3cecc63dda1150 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:45:50 -0400 Subject: [PATCH 2/2] Nest notify payload fields under the action's data key The modern HA action schema passes title and message to the notify service only under data; fix the morning summary and afternoon reminder blueprints and the test accessors accordingly. --- .../automation/afternoon_reminder.yaml | 35 ++++++------- .../automation/morning_summary.yaml | 51 ++++++++++--------- tests/blueprint_helpers.py | 10 ++++ tests/test_blueprint_afternoon.py | 10 ++-- tests/test_blueprints.py | 18 ++++--- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml index ffe0b73..34ea11c 100644 --- a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -69,21 +69,22 @@ conditions: actions: - action: !input notify_target - title: NestQuest afternoon reminder - message: | - {%- set ns = namespace(lines=[]) -%} - {%- for due in states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} - {%- if due.attributes.get('present') -%} - {%- set remaining_values = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') - | selectattr('attributes.child_id', 'eq', due.attributes.child_id) - | map(attribute='state') - | list -%} - {%- if remaining_values and (remaining_values | first | int) > 0 -%} - {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} - {%- set ns.lines = ns.lines + [line] -%} + data: + title: NestQuest afternoon reminder + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} {%- endif -%} - {%- endif -%} - {%- endfor -%} - {{- ns.lines | join('\n') -}} \ No newline at end of file + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml index 20f74b4..ea38e1d 100644 --- a/custom_components/nestquest/blueprints/automation/morning_summary.yaml +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -46,32 +46,33 @@ conditions: actions: - action: !input notify_target - title: NestQuest morning summary - message: | - {%- set ns = namespace(lines=[]) -%} - {%- for due in states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} - {%- if due.attributes.get('present') -%} - {%- set remaining_values = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') - | selectattr('attributes.child_id', 'eq', due.attributes.child_id) - | map(attribute='state') - | list -%} - {%- if remaining_values -%} - {%- set remaining = remaining_values | first | int -%} - {%- if remaining > 0 -%} - {%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + data: + title: NestQuest morning summary + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values -%} + {%- set remaining = remaining_values | first | int -%} + {%- if remaining > 0 -%} + {%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- endif -%} {%- else -%} - {%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} {%- endif -%} - {%- else -%} - {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} + {%- set ns.lines = ns.lines + [line] -%} {%- endif -%} - {%- set ns.lines = ns.lines + [line] -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. {%- endif -%} - {%- endfor -%} - {%- if ns.lines -%} - {{- ns.lines | join('\n') -}} - {%- else -%} - No children present today. - {%- endif -%} diff --git a/tests/blueprint_helpers.py b/tests/blueprint_helpers.py index 5752fbb..d4ec16e 100644 --- a/tests/blueprint_helpers.py +++ b/tests/blueprint_helpers.py @@ -82,6 +82,16 @@ def morning_action(bp: dict) -> dict: return single_action(bp) +def notify_data(bp: dict, index: int = 0) -> dict: + """Return a notify action's service ``data`` (title/message live + there in the modern HA action schema, not at the action's top level).""" + action = bp["actions"][index] + assert isinstance(action.get("data"), dict), ( + "notify payload fields must nest under data:" + ) + return action["data"] + + def iter_strings(node: object): """Yield every string value in a parsed YAML structure, recursively.""" if isinstance(node, str): diff --git a/tests/test_blueprint_afternoon.py b/tests/test_blueprint_afternoon.py index 1fe5458..7c13c77 100644 --- a/tests/test_blueprint_afternoon.py +++ b/tests/test_blueprint_afternoon.py @@ -17,6 +17,7 @@ import re from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME from tests.blueprint_helpers import ( + notify_data, BlueprintInput, blueprint, iter_strings, @@ -80,8 +81,9 @@ def test_afternoon_reminder_wires_inputs_not_literals() -> None: action = single_action(bp) assert action["action"] == BlueprintInput("notify_target") - assert action["title"] == AFTERNOON_TITLE - assert isinstance(action["message"], str) and action["message"].strip() + data = notify_data(afternoon_reminder()) + assert data["title"] == AFTERNOON_TITLE + assert isinstance(data["message"], str) and data["message"].strip() # --------------------------------------------------------------------------- @@ -125,7 +127,7 @@ def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> No TemplateState("sensor.nestquest_household_quests_due_today", "7"), ], ) - rendered = render(single_action(afternoon_reminder())["message"], states=states) + rendered = render(notify_data(afternoon_reminder())["message"], states=states) assert rendered == "Alice still has 2 quest(s) open." assert "Carol" not in rendered assert "Bob" not in rendered @@ -218,7 +220,7 @@ def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None: ), ], ) - rendered = render(single_action(afternoon_reminder())["message"], states=states) + rendered = render(notify_data(afternoon_reminder())["message"], states=states) assert rendered.split("\n") == [ "Ada still has 1 quest(s) open.", "Ada still has 3 quest(s) open.", diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index fbd49c3..ac8a991 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -26,6 +26,7 @@ from types import SimpleNamespace from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME from tests.blueprint_helpers import ( + notify_data, ALLOWED_ENTITY_SHAPES, BlueprintInput, _ENTITY_LITERAL_RE, @@ -88,8 +89,9 @@ def test_morning_summary_wires_inputs_not_literals() -> None: action = morning_action(bp) assert action["action"] == BlueprintInput("notify_target") - assert action["title"] == MORNING_TITLE - assert isinstance(action["message"], str) and action["message"].strip() + data = notify_data(bp) + assert data["title"] == MORNING_TITLE + assert isinstance(data["message"], str) and data["message"].strip() # --------------------------------------------------------------------------- @@ -139,7 +141,7 @@ def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None: including HA collision-suffixed duplicates (a second child with the same name gets entity ids ending _2) — and rejects everything else.""" - message = morning_action(morning_summary())["message"] + message = notify_data(morning_summary())["message"] match = re.search( r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message ) @@ -216,7 +218,7 @@ def morning_fixture() -> SimpleNamespace: def test_morning_summary_lists_present_child_and_omits_absent() -> None: """Present Alice appears with her count; absent Bob and decoys never do.""" - message = morning_action(morning_summary())["message"] + message = notify_data(morning_summary())["message"] rendered = render(message, states=morning_fixture()) assert rendered == "Alice owes 2 quest(s) today." assert "Bob" not in rendered @@ -250,7 +252,7 @@ def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert "Alice owes 2 quest(s) today." in rendered assert "Carol is all clear today (0 quests remaining)." in rendered @@ -267,7 +269,7 @@ def test_morning_summary_present_child_without_sensors_is_flagged() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert rendered == "Frances's quest count is unavailable today." @@ -299,7 +301,7 @@ def test_morning_summary_collision_suffixed_duplicate_names() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) lines = rendered.split("\n") assert lines == [ "Ada owes 1 quest(s) today.", @@ -323,7 +325,7 @@ def test_morning_summary_no_present_children() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert rendered == "No children present today." -- 2.55.0