Add the afternoon open-quests reminder blueprint
This commit was merged in pull request #118.
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
blueprint:
|
||||||
|
name: NestQuest afternoon reminder
|
||||||
|
description: >-
|
||||||
|
Sends an afternoon nudge listing ONLY the children who still have
|
||||||
|
open quests today. Children who are present and all clear are
|
||||||
|
omitted (the nudge is for stragglers, not a roll call), children
|
||||||
|
who are not present today are omitted entirely, and the household
|
||||||
|
rollup sensors are excluded by the same present guard. When every
|
||||||
|
present child is already done the automation sends NOTHING at all:
|
||||||
|
a template condition stops the run before the notification. The
|
||||||
|
notify service is chosen by the household when importing this
|
||||||
|
blueprint — NestQuest never hard-codes a personal notify target.
|
||||||
|
domain: automation
|
||||||
|
input:
|
||||||
|
afternoon_time:
|
||||||
|
name: Afternoon time
|
||||||
|
description: Local time of day the reminder is sent.
|
||||||
|
default: "15:00"
|
||||||
|
selector:
|
||||||
|
time: {}
|
||||||
|
notify_target:
|
||||||
|
name: Notify service
|
||||||
|
description: >-
|
||||||
|
The Home Assistant notify service to call for the reminder,
|
||||||
|
for example notify.mobile_app_your_phone — a placeholder;
|
||||||
|
pick the notify service for your own device.
|
||||||
|
selector:
|
||||||
|
text: {}
|
||||||
|
send_reminder:
|
||||||
|
name: Send the reminder
|
||||||
|
description: >-
|
||||||
|
Turn off to keep the automation installed but skip the
|
||||||
|
notification entirely.
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean: {}
|
||||||
|
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
variables:
|
||||||
|
send_reminder: !input send_reminder
|
||||||
|
|
||||||
|
triggers:
|
||||||
|
- trigger: time
|
||||||
|
at: !input afternoon_time
|
||||||
|
|
||||||
|
conditions:
|
||||||
|
- condition: template
|
||||||
|
value_template: "{{ send_reminder | bool }}"
|
||||||
|
# Send nothing when every present child is already done: the guard is
|
||||||
|
# false unless at least one present child has remaining quests today.
|
||||||
|
- condition: template
|
||||||
|
value_template: |
|
||||||
|
{%- set ns = namespace(any_open=false) -%}
|
||||||
|
{%- for due in states.sensor
|
||||||
|
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||||
|
{%- if due.attributes.get('present') -%}
|
||||||
|
{%- set remaining_values = states.sensor
|
||||||
|
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||||
|
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||||
|
| map(attribute='state')
|
||||||
|
| list -%}
|
||||||
|
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||||
|
{%- set ns.any_open = true -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{{- ns.any_open -}}
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- action: !input notify_target
|
||||||
|
data:
|
||||||
|
title: NestQuest afternoon reminder
|
||||||
|
message: |
|
||||||
|
{%- set ns = namespace(lines=[]) -%}
|
||||||
|
{%- for due in states.sensor
|
||||||
|
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||||
|
{%- if due.attributes.get('present') -%}
|
||||||
|
{%- set remaining_values = states.sensor
|
||||||
|
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||||
|
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||||
|
| map(attribute='state')
|
||||||
|
| list -%}
|
||||||
|
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||||
|
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
|
||||||
|
{%- set ns.lines = ns.lines + [line] -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{{- ns.lines | join('\n') -}}
|
||||||
@@ -46,32 +46,33 @@ conditions:
|
|||||||
|
|
||||||
actions:
|
actions:
|
||||||
- action: !input notify_target
|
- action: !input notify_target
|
||||||
title: NestQuest morning summary
|
data:
|
||||||
message: |
|
title: NestQuest morning summary
|
||||||
{%- set ns = namespace(lines=[]) -%}
|
message: |
|
||||||
{%- for due in states.sensor
|
{%- set ns = namespace(lines=[]) -%}
|
||||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
{%- for due in states.sensor
|
||||||
{%- if due.attributes.get('present') -%}
|
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||||
{%- set remaining_values = states.sensor
|
{%- if due.attributes.get('present') -%}
|
||||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
{%- set remaining_values = states.sensor
|
||||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||||
| map(attribute='state')
|
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||||
| list -%}
|
| map(attribute='state')
|
||||||
{%- if remaining_values -%}
|
| list -%}
|
||||||
{%- set remaining = remaining_values | first | int -%}
|
{%- if remaining_values -%}
|
||||||
{%- if remaining > 0 -%}
|
{%- set remaining = remaining_values | first | int -%}
|
||||||
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
|
{%- 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 -%}
|
{%- 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 -%}
|
{%- endif -%}
|
||||||
{%- else -%}
|
{%- set ns.lines = ns.lines + [line] -%}
|
||||||
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- set ns.lines = ns.lines + [line] -%}
|
{%- endfor -%}
|
||||||
|
{%- if ns.lines -%}
|
||||||
|
{{- ns.lines | join('\n') -}}
|
||||||
|
{%- else -%}
|
||||||
|
No children present today.
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endfor -%}
|
|
||||||
{%- if ns.lines -%}
|
|
||||||
{{- ns.lines | join('\n') -}}
|
|
||||||
{%- else -%}
|
|
||||||
No children present today.
|
|
||||||
{%- endif -%}
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Shared harness for the NestQuest automation-blueprint tests.
|
||||||
|
|
||||||
|
The blueprints are YAML data consumed by Home Assistant's blueprint
|
||||||
|
engine; the integration itself never imports yaml or jinja2. The
|
||||||
|
blueprint tests therefore exercise them exactly the way HA would:
|
||||||
|
parse the YAML (with a loader that understands ``!input`` tags), then
|
||||||
|
render the message/condition templates with a plain jinja2 environment
|
||||||
|
over a fixture state set shaped like HA's template state objects.
|
||||||
|
|
||||||
|
The harness is mock-only (see conftest.py), so the fixtures model just
|
||||||
|
the surface the templates touch: ``states.binary_sensor`` /
|
||||||
|
``states.sensor`` lists of objects carrying ``entity_id``, ``state``
|
||||||
|
and ``attributes`` — plus, for the celebration blueprint, a callable
|
||||||
|
``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's
|
||||||
|
``search``/``match`` selectattr tests are Home Assistant additions,
|
||||||
|
registered here explicitly.
|
||||||
|
|
||||||
|
This module is plain helper code (deliberately not named ``test_*`` so
|
||||||
|
pytest never collects it); every blueprint test file imports from it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
BLUEPRINT_DIR = (
|
||||||
|
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintInput(str):
|
||||||
|
"""Marker for a parsed blueprint ``!input <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 notify_data(bp: dict, index: int = 0) -> dict:
|
||||||
|
"""Return a notify action's service ``data`` (title/message live
|
||||||
|
there in the modern HA action schema, not at the action's top level)."""
|
||||||
|
action = bp["actions"][index]
|
||||||
|
assert isinstance(action.get("data"), dict), (
|
||||||
|
"notify payload fields must nest under data:"
|
||||||
|
)
|
||||||
|
return action["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def iter_strings(node: object):
|
||||||
|
"""Yield every string value in a parsed YAML structure, recursively."""
|
||||||
|
if isinstance(node, str):
|
||||||
|
yield node
|
||||||
|
elif isinstance(node, dict):
|
||||||
|
for value in node.values():
|
||||||
|
yield from iter_strings(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for value in node:
|
||||||
|
yield from iter_strings(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateState:
|
||||||
|
"""Stand-in for an HA template state object: entity_id/state/attributes."""
|
||||||
|
|
||||||
|
def __init__(self, entity_id: str, state: str, attributes: dict | None = None):
|
||||||
|
self.entity_id = entity_id
|
||||||
|
self.state = state
|
||||||
|
self.attributes = dict(attributes or {})
|
||||||
|
|
||||||
|
|
||||||
|
class StatesFixture:
|
||||||
|
"""HA's template ``states`` object over a fixture entity set.
|
||||||
|
|
||||||
|
Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``,
|
||||||
|
``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is
|
||||||
|
callable as ``states(entity_id)`` returning the entity's state
|
||||||
|
string — or ``None`` when the fixture holds no such entity, the
|
||||||
|
way HA returns ``None`` for a nonexistent entity id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
|
||||||
|
):
|
||||||
|
self.binary_sensor = list(binary_sensors)
|
||||||
|
self.sensor = list(sensors)
|
||||||
|
self.input_datetime = list(input_datetimes)
|
||||||
|
self.input_text = list(input_texts)
|
||||||
|
self._all = (
|
||||||
|
self.binary_sensor
|
||||||
|
+ self.sensor
|
||||||
|
+ self.input_datetime
|
||||||
|
+ self.input_text
|
||||||
|
)
|
||||||
|
|
||||||
|
def __call__(self, entity_id: str):
|
||||||
|
"""Return the entity's state string, or None when absent."""
|
||||||
|
for entity in self._all:
|
||||||
|
if entity.entity_id == entity_id:
|
||||||
|
return entity.state
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_states(
|
||||||
|
binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
|
||||||
|
) -> StatesFixture:
|
||||||
|
"""Build the ``states`` fixture: per-domain lists of TemplateState."""
|
||||||
|
return StatesFixture(
|
||||||
|
binary_sensors, sensors, input_datetimes, input_texts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_template_environment() -> jinja2.Environment:
|
||||||
|
"""A plain jinja2 environment with HA's regex tests and bool filter."""
|
||||||
|
env = jinja2.Environment()
|
||||||
|
env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None
|
||||||
|
env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None
|
||||||
|
|
||||||
|
def _bool(value, default=False):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in ("true", "on", "open", "yes", "1")
|
||||||
|
return bool(value) or default
|
||||||
|
|
||||||
|
env.filters["bool"] = _bool
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def render(template: str, **variables) -> str:
|
||||||
|
"""Render a template the way HA would substitute its variables."""
|
||||||
|
return make_template_environment().from_string(template).render(**variables)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entity-reference discipline (Feature 10 shapes only). The scanners
|
||||||
|
# are shared so every blueprint test file can assert the same rules;
|
||||||
|
# the directory-wide tests in test_blueprints.py apply them to every
|
||||||
|
# blueprint file automatically.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py).
|
||||||
|
ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple(
|
||||||
|
re.compile(pattern)
|
||||||
|
for pattern in (
|
||||||
|
r"sensor\.nestquest_[a-z0-9_]+_quests_due_today",
|
||||||
|
r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today",
|
||||||
|
r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today",
|
||||||
|
r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today",
|
||||||
|
r"sensor\.nestquest_[a-z0-9_]+_next_quest",
|
||||||
|
r"binary_sensor\.nestquest_[a-z0-9_]+_all_done",
|
||||||
|
r"binary_sensor\.nestquest_[a-z0-9_]+_present_today",
|
||||||
|
r"sensor\.nestquest_household_quests_due_today",
|
||||||
|
r"sensor\.nestquest_household_quests_completed_today",
|
||||||
|
r"sensor\.nestquest_cycle_day",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
#: An unescaped, id-like entity reference: any found must be a full
|
||||||
|
#: match for one of the Feature 10 shapes above.
|
||||||
|
_ENTITY_LITERAL_RE = re.compile(r"(?<![\\\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_]+)")
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""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 (
|
||||||
|
notify_data,
|
||||||
|
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")
|
||||||
|
data = notify_data(afternoon_reminder())
|
||||||
|
assert data["title"] == AFTERNOON_TITLE
|
||||||
|
assert isinstance(data["message"], str) and data["message"].strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rendering: only present children with open quests are listed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None:
|
||||||
|
"""Present+open Alice is listed; present+clear Carol, absent Bob,
|
||||||
|
and the household rollup are all omitted."""
|
||||||
|
states = make_states(
|
||||||
|
sensors=[
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_alice_quests_due_today",
|
||||||
|
"3",
|
||||||
|
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_alice_quests_remaining_today",
|
||||||
|
"2",
|
||||||
|
{"child_id": 1},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_carol_quests_due_today",
|
||||||
|
"2",
|
||||||
|
{"child_name": "Carol", "child_id": 2, "present": True},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_carol_quests_remaining_today",
|
||||||
|
"0",
|
||||||
|
{"child_id": 2},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_bob_quests_due_today",
|
||||||
|
"2",
|
||||||
|
{"child_name": "Bob", "child_id": 3, "present": False},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_bob_quests_remaining_today",
|
||||||
|
"1",
|
||||||
|
{"child_id": 3},
|
||||||
|
),
|
||||||
|
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
|
||||||
|
assert rendered == "Alice still has 2 quest(s) open."
|
||||||
|
assert "Carol" not in rendered
|
||||||
|
assert "Bob" not in rendered
|
||||||
|
assert "household" not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_afternoon_reminder_all_clear_guard_renders_false() -> None:
|
||||||
|
"""With every present child done (and absent children owing), the
|
||||||
|
guard condition is false: the automation sends nothing at all."""
|
||||||
|
states = make_states(
|
||||||
|
sensors=[
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_alice_quests_due_today",
|
||||||
|
"2",
|
||||||
|
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_alice_quests_remaining_today",
|
||||||
|
"0",
|
||||||
|
{"child_id": 1},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_bob_quests_due_today",
|
||||||
|
"2",
|
||||||
|
{"child_name": "Bob", "child_id": 2, "present": False},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_bob_quests_remaining_today",
|
||||||
|
"1",
|
||||||
|
{"child_id": 2},
|
||||||
|
),
|
||||||
|
TemplateState("sensor.nestquest_household_quests_due_today", "2"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
guard = all_clear_guard(afternoon_reminder())
|
||||||
|
assert render(guard, states=states).strip() == "False"
|
||||||
|
|
||||||
|
|
||||||
|
def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None:
|
||||||
|
"""A present child with open quests makes the guard true."""
|
||||||
|
states = make_states(
|
||||||
|
sensors=[
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_alice_quests_due_today",
|
||||||
|
"3",
|
||||||
|
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_alice_quests_remaining_today",
|
||||||
|
"1",
|
||||||
|
{"child_id": 1},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
guard = all_clear_guard(afternoon_reminder())
|
||||||
|
assert render(guard, states=states).strip() == "True"
|
||||||
|
|
||||||
|
|
||||||
|
def test_afternoon_reminder_send_reminder_toggle_renders() -> None:
|
||||||
|
"""The enable condition renders truthy/false from the input value."""
|
||||||
|
toggle = afternoon_conditions(afternoon_reminder())[0]
|
||||||
|
assert render(toggle["value_template"], send_reminder=True).strip() == "True"
|
||||||
|
assert render(toggle["value_template"], send_reminder=False).strip() == "False"
|
||||||
|
|
||||||
|
|
||||||
|
def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None:
|
||||||
|
"""Two children sharing a display name (HA registry suffix _2 on the
|
||||||
|
second one's entity ids) both appear, each with their own count."""
|
||||||
|
states = make_states(
|
||||||
|
sensors=[
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_ada_quests_due_today",
|
||||||
|
"2",
|
||||||
|
{"child_name": "Ada", "child_id": 1, "present": True},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_ada_quests_remaining_today",
|
||||||
|
"1",
|
||||||
|
{"child_id": 1},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_ada_quests_due_today_2",
|
||||||
|
"3",
|
||||||
|
{"child_name": "Ada", "child_id": 2, "present": True},
|
||||||
|
),
|
||||||
|
TemplateState(
|
||||||
|
"sensor.nestquest_ada_quests_remaining_today_2",
|
||||||
|
"3",
|
||||||
|
{"child_id": 2},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
|
||||||
|
assert rendered.split("\n") == [
|
||||||
|
"Ada still has 1 quest(s) open.",
|
||||||
|
"Ada still has 3 quest(s) open.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
|
||||||
|
"""No notify service literal outside the input's placeholder text."""
|
||||||
|
bp = afternoon_reminder()
|
||||||
|
spec = bp["blueprint"]
|
||||||
|
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||||
|
|
||||||
|
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||||
|
for value in iter_strings(outside_input):
|
||||||
|
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||||
|
|
||||||
|
target_description = spec["input"]["notify_target"]["description"]
|
||||||
|
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||||
|
"the input description should show a placeholder example"
|
||||||
|
)
|
||||||
|
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||||
+33
-139
@@ -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
|
The blueprints are YAML data consumed by Home Assistant's blueprint
|
||||||
engine; the integration itself never imports yaml or jinja2. These
|
engine; the integration itself never imports yaml or jinja2. These
|
||||||
tests therefore exercise them exactly the way HA would: parse the YAML
|
tests therefore exercise them exactly the way HA would: parse the YAML
|
||||||
(with a loader that understands ``!input`` tags), then render the
|
(with a loader that understands ``!input`` tags), then render the
|
||||||
message template with a plain jinja2 environment over a fixture state
|
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
|
This module also carries the directory-wide blueprint discipline tests
|
||||||
the surface the templates touch: ``states.binary_sensor`` /
|
(entity-id shapes, domain mentions, notify-target hygiene), which scan
|
||||||
``states.sensor`` lists of objects carrying ``entity_id``, ``state`` and
|
EVERY blueprint file under the automation directory, so new blueprint
|
||||||
``attributes``. Jinja's ``search``/``match`` selectattr tests are
|
files are covered automatically.
|
||||||
Home Assistant additions, registered here explicitly.
|
|
||||||
|
|
||||||
Rendering policy under test (settled for the morning summary): a
|
Rendering policy under test (settled for the morning summary): a
|
||||||
PRESENT child always appears — with remaining quests owed, or as "all
|
PRESENT child always appears — with remaining quests owed, or as "all
|
||||||
@@ -21,111 +21,28 @@ omitted entirely, never listed with zero quests.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import jinja2
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME
|
from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parent.parent
|
from tests.blueprint_helpers import (
|
||||||
BLUEPRINT_DIR = (
|
notify_data,
|
||||||
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
|
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"
|
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.
|
# YAML parsing and blueprint shape.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -172,39 +89,16 @@ def test_morning_summary_wires_inputs_not_literals() -> None:
|
|||||||
|
|
||||||
action = morning_action(bp)
|
action = morning_action(bp)
|
||||||
assert action["action"] == BlueprintInput("notify_target")
|
assert action["action"] == BlueprintInput("notify_target")
|
||||||
assert action["title"] == MORNING_TITLE
|
data = notify_data(bp)
|
||||||
assert isinstance(action["message"], str) and action["message"].strip()
|
assert data["title"] == MORNING_TITLE
|
||||||
|
assert isinstance(data["message"], str) and data["message"].strip()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 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:
|
def test_entity_literals_match_feature_10_shapes() -> None:
|
||||||
"""Every id-like entity string in the YAML is a Feature 10 shape."""
|
"""Every id-like entity string in the YAML is a Feature 10 shape."""
|
||||||
for name, bp in load_blueprints().items():
|
for name, bp in load_blueprints().items():
|
||||||
@@ -247,7 +141,7 @@ def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None:
|
|||||||
including HA collision-suffixed duplicates (a second child with
|
including HA collision-suffixed duplicates (a second child with
|
||||||
the same name gets entity ids ending _2) — and rejects everything
|
the same name gets entity ids ending _2) — and rejects everything
|
||||||
else."""
|
else."""
|
||||||
message = morning_action(morning_summary())["message"]
|
message = notify_data(morning_summary())["message"]
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message
|
r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message
|
||||||
)
|
)
|
||||||
@@ -324,7 +218,7 @@ def morning_fixture() -> SimpleNamespace:
|
|||||||
|
|
||||||
def test_morning_summary_lists_present_child_and_omits_absent() -> None:
|
def test_morning_summary_lists_present_child_and_omits_absent() -> None:
|
||||||
"""Present Alice appears with her count; absent Bob and decoys never do."""
|
"""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())
|
rendered = render(message, states=morning_fixture())
|
||||||
assert rendered == "Alice owes 2 quest(s) today."
|
assert rendered == "Alice owes 2 quest(s) today."
|
||||||
assert "Bob" not in rendered
|
assert "Bob" not in rendered
|
||||||
@@ -358,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 "Alice owes 2 quest(s) today." in rendered
|
||||||
assert "Carol is all clear today (0 quests remaining)." in rendered
|
assert "Carol is all clear today (0 quests remaining)." in rendered
|
||||||
|
|
||||||
@@ -375,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."
|
assert rendered == "Frances's quest count is unavailable today."
|
||||||
|
|
||||||
|
|
||||||
@@ -407,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")
|
lines = rendered.split("\n")
|
||||||
assert lines == [
|
assert lines == [
|
||||||
"Ada owes 1 quest(s) today.",
|
"Ada owes 1 quest(s) today.",
|
||||||
@@ -431,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."
|
assert rendered == "No children present today."
|
||||||
|
|
||||||
|
|
||||||
@@ -439,4 +333,4 @@ def test_morning_summary_send_summary_toggle_renders() -> None:
|
|||||||
"""The enable condition renders truthy/false from the input value."""
|
"""The enable condition renders truthy/false from the input value."""
|
||||||
condition = morning_summary()["conditions"][0]
|
condition = morning_summary()["conditions"][0]
|
||||||
assert render(condition["value_template"], send_summary=True).strip() == "True"
|
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"
|
||||||
Reference in New Issue
Block a user