Files
NestQuest/tests/test_blueprint_end_of_day.py
orchestrator-agent b913772007 Report when remaining counts are unavailable, not all-clear
A missing or non-numeric remaining sensor reads as -1 in the
all-clear guard, so a degraded day reaches the parent instead of
looking like a clear day.
2026-09-19 14:47:37 -04:00

339 lines
12 KiB
Python

"""Tests for the NestQuest end-of-day report automation blueprint.
The end-of-day report closes the day for the parent: every PRESENT
child gets a completion line (so the parent always receives the day's
summary when the report runs), children who still owe quests get their
open count called out on that line, and absent children are omitted
entirely. The report runs deliberately before the midnight
missed-quest sweep so it reads the same day's data, and by default an
all-clear day sends nothing — unless the household opts into
send-even-when-clear. The harness (YAML ``!input`` loader, state
fixtures, jinja rendering) is shared in
:mod:`tests.blueprint_helpers`; the directory-wide shape and
entity-discipline tests in test_blueprints.py cover this file too.
"""
from __future__ import annotations
import re
from custom_components.nestquest.const import DEFAULT_END_OF_DAY_REPORT_TIME
from tests.blueprint_helpers import (
BlueprintInput,
notify_data,
blueprint,
iter_strings,
make_states,
render,
single_action,
TemplateState,
)
REPORT_TITLE = "NestQuest end-of-day report"
def end_of_day_report() -> dict:
"""Return the parsed end-of-day-report blueprint."""
return blueprint("end_of_day_report")
def report_conditions(bp: dict) -> list[dict]:
"""Return the blueprint's conditions: toggle first, guard second."""
conditions = bp["conditions"]
assert isinstance(conditions, list) and len(conditions) == 2
return conditions
def clear_guard(bp: dict) -> str:
"""Return the template guarding an all-clear day (false = no report)."""
return report_conditions(bp)[1]["value_template"]
def owing_clear_absent_fixture():
"""Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent."""
return make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_alice_quests_completed_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_carol_quests_due_today",
"2",
{"child_name": "Carol", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_carol_quests_remaining_today",
"0",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_carol_quests_completed_today",
"2",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 3, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 3},
),
TemplateState(
"sensor.nestquest_bob_quests_completed_today",
"1",
{"child_id": 3},
),
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
],
)
def all_clear_fixture():
"""Alice present with everything done; nobody owes anything."""
return make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"0",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_alice_quests_completed_today",
"3",
{"child_id": 1},
),
TemplateState("sensor.nestquest_household_quests_due_today", "3"),
],
)
# ---------------------------------------------------------------------------
# Inputs, defaults, and wiring.
# ---------------------------------------------------------------------------
def test_end_of_day_report_declares_inputs_with_selectors() -> None:
"""The four inputs exist, carry selectors, and default sanely."""
bp = end_of_day_report()
inputs = bp["blueprint"]["input"]
expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME
assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00"
assert inputs["send_report"]["default"] is True
assert inputs["send_even_when_clear"]["default"] is False
assert "time" in inputs["report_time"]["selector"]
assert "text" in inputs["notify_target"]["selector"]
assert "boolean" in inputs["send_report"]["selector"]
assert "boolean" in inputs["send_even_when_clear"]["selector"]
# The description must note the deliberate ordering: the report runs
# before the midnight missed sweep, reading the same day's data.
description = bp["blueprint"]["description"].lower()
assert "before" in description
assert "sweep" in description
assert "same day" in description
def test_end_of_day_report_wires_inputs_not_literals() -> None:
"""Time trigger, toggle and guard conditions, and notify action use !input."""
bp = end_of_day_report()
assert bp["variables"]["send_report"] == BlueprintInput("send_report")
assert bp["variables"]["send_even_when_clear"] == BlueprintInput(
"send_even_when_clear"
)
trigger = bp["triggers"][0]
assert trigger["trigger"] == "time"
assert trigger["at"] == BlueprintInput("report_time")
toggle, guard = report_conditions(bp)
assert toggle["condition"] == "template"
assert "send_report" in toggle["value_template"]
assert guard["condition"] == "template"
assert "send_even_when_clear" in guard["value_template"]
action = single_action(bp)
assert action["action"] == BlueprintInput("notify_target")
data = notify_data(end_of_day_report())
assert data["title"] == REPORT_TITLE
assert isinstance(data["message"], str) and data["message"].strip()
# ---------------------------------------------------------------------------
# Rendering the day's summary.
# ---------------------------------------------------------------------------
def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None:
"""The owing child's open count is called out with the completion
ratio; the present-clear child still gets the day's completion
line; the absent child is omitted entirely."""
rendered = render(
notify_data(end_of_day_report())["message"],
states=owing_clear_absent_fixture(),
)
assert rendered.split("\n") == [
"Alice: 2 still open (1/3 done).",
"Carol: 2/2 done.",
]
assert "Bob" not in rendered
assert "household" not in rendered
def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None:
"""An all-clear day with send_even_when_clear off sends nothing:
the guard condition is false."""
rendered = render(
clear_guard(end_of_day_report()),
states=all_clear_fixture(),
send_even_when_clear=False,
)
assert rendered.strip() == "False"
def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None:
"""With send_even_when_clear on, the guard lets an all-clear day
through and the message renders the day's completion summary."""
guard = clear_guard(end_of_day_report())
assert (
render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip()
== "True"
)
message = render(
notify_data(end_of_day_report())["message"],
states=all_clear_fixture(),
)
assert message == "Alice: 3/3 done."
def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None:
"""A present child with open quests passes the guard even with
send_even_when_clear off."""
rendered = render(
clear_guard(end_of_day_report()),
states=owing_clear_absent_fixture(),
send_even_when_clear=False,
)
assert rendered.strip() == "True"
def test_end_of_day_report_collision_suffixed_duplicate_names() -> None:
"""Two children sharing a display name (HA registry suffix _2 on the
second one's entity ids) both appear, each with their own counts."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_ada_quests_due_today",
"2",
{"child_name": "Ada", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_completed_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_due_today_2",
"3",
{"child_name": "Ada", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today_2",
"0",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_ada_quests_completed_today_2",
"3",
{"child_id": 2},
),
],
)
rendered = render(notify_data(end_of_day_report())["message"], states=states)
assert rendered.split("\n") == [
"Ada: 1 still open (1/2 done).",
"Ada: 3/3 done.",
]
def test_end_of_day_report_never_hard_codes_notify_target() -> None:
"""No notify service literal outside the input's placeholder text."""
bp = end_of_day_report()
spec = bp["blueprint"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert single_action(bp)["action"] == BlueprintInput("notify_target")
def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None:
"""A present child whose remaining sensor is missing or reads
'unavailable' must NOT look like all-clear: the degraded state
reaches the parent (Codex review regression)."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"unavailable",
{"child_id": 1},
),
],
)
rendered = render(
clear_guard(end_of_day_report()),
states=states,
send_even_when_clear=False,
)
assert rendered.strip() == "True"
# A missing remaining sensor entirely: also report-worthy.
states_missing = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
],
)
rendered_missing = render(
clear_guard(end_of_day_report()),
states=states_missing,
send_even_when_clear=False,
)
assert rendered_missing.strip() == "True"