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.
This commit is contained in:
2026-09-19 14:42:50 -04:00
parent fcc7a29f05
commit ad6f1768d7
4 changed files with 550 additions and 131 deletions
+196
View File
@@ -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 <name>`` 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"(?<![\\\w])(?:sensor|binary_sensor)\.[A-Za-z0-9_]+")
#: A domain-dot mention, ignoring the template's escaped-dot regexes
#: (one backslash is stripped so ``binary_sensor\\.`` counts too).
_MENTION_RE = re.compile(r"(?:sensor|binary_sensor)\.([A-Za-z0-9_]+)")
+242
View File
@@ -0,0 +1,242 @@
"""Tests for the NestQuest afternoon-reminder automation blueprint.
The afternoon reminder nudges only the stragglers: a child appears
exactly when they are PRESENT today and still have open quests.
Present-and-clear children, absent children, and the household rollup
sensors are all omitted, and when nobody qualifies the automation
sends nothing at all — the all-clear template condition stops the run
before the notification. 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_AFTERNOON_REMINDER_TIME
from tests.blueprint_helpers import (
BlueprintInput,
blueprint,
iter_strings,
make_states,
render,
single_action,
TemplateState,
)
AFTERNOON_TITLE = "NestQuest afternoon reminder"
def afternoon_reminder() -> 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")
+23 -131
View File
@@ -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 <name>`` 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"(?<![\\\w])(?:sensor|binary_sensor)\.[A-Za-z0-9_]+")
#: A domain-dot mention, ignoring the template's escaped-dot regexes
#: (one backslash is stripped so ``binary_sensor\\.`` counts too).
_MENTION_RE = re.compile(r"(?:sensor|binary_sensor)\.([A-Za-z0-9_]+)")
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():
@@ -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"