Add per-child all-done and present-today binary sensors

all_done is on exactly when the child owed quests today and owes none
remaining (a zero-quest day stays off so celebrations never fire on
an empty day); present_today mirrors the presence engine's answer
for the HA-local date.
This commit is contained in:
2026-09-19 11:56:10 -04:00
parent 236e8f4adf
commit df4c9eb21a
5 changed files with 340 additions and 9 deletions
+134
View File
@@ -0,0 +1,134 @@
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.completion import complete_instance
SEED_HORIZON_DAYS = 3
def _binary(hass, child_id: int, kind: str):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
async def _setup_entry(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry
async def _seed_two_children(database) -> tuple:
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return ada, bo
async def test_all_done_off_with_open_quests_and_zero_quests(
hass, make_entry
) -> None:
"""all_done is OFF while quests are owed AND on a zero-quest day;
a child with no quests never fires the celebration automation."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory") # zero quests
await coordinator.async_refresh()
assert _binary(hass, ada.id, "all_done").is_on is False
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "all_done").is_on is False
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
snapshot_ada = [
child for child in coordinator.data.children
if child.child_id == ada.id
][0]
instance_id = snapshot_ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "all_done").is_on is True
async def test_present_today_on_without_schedule_off_when_absent(
hass, make_entry
) -> None:
"""No schedule means present every day; an all-absent pattern
today reports OFF."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
await coordinator.async_refresh()
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
await PresenceSchedulesDao(database).upsert_by_child(
cory.child_id, 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "present_today").is_on is True
assert _binary(hass, cory.child_id, "present_today").is_on is False
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
all_done = _binary(hass, ada.id, "all_done")
present = _binary(hass, ada.id, "present_today")
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
assert all_done.name == "NestQuest Ada all done"
assert present.name == "NestQuest Ada present today"
for entity in (all_done, present):
assert entity.device_info.name == "NestQuest Ada"
assert entity.extra_state_attributes == {
"child_id": ada.id,
"child_name": "Ada",
}
+2 -2
View File
@@ -28,10 +28,10 @@ def test_logger() -> None:
def test_platforms() -> None:
"""Verify PLATFORMS is defined as the sensor platform list."""
"""Verify PLATFORMS is defined as the sensor + binary_sensor list."""
assert hasattr(const, "PLATFORMS")
assert isinstance(const.PLATFORMS, list)
assert const.PLATFORMS == ["sensor"]
assert const.PLATFORMS == ["sensor", "binary_sensor"]
def test_sqlite_db_filename() -> None:
+11 -5
View File
@@ -104,13 +104,16 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
hass, make_entry
)
database = entry.runtime_data.database
assert hass.config_entries.forwarded_platforms == ["sensor"]
assert hass.config_entries.forwarded_platforms == [
"sensor",
"binary_sensor",
]
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
# Two quest children plus the zero-quest child: three children x
# five sensors, no duplicates.
assert len(hass.entities) == 15
# (five sensors + two binary sensors), no duplicates.
assert len(hass.entities) == 21
expected = {
ada.id: ("Ada", 1, 1, 0, 100),
bo.id: ("Bo", 1, 0, 1, 0),
@@ -324,7 +327,7 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
)
await coordinator.async_refresh()
await coordinator.async_refresh()
assert len(hass.entities) == 15
assert len(hass.entities) == 21
async def test_setup_with_no_children_registers_no_entities(
@@ -339,7 +342,10 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
assert hass.config_entries.unloaded_platforms == ["sensor"]
assert hass.config_entries.unloaded_platforms == [
"sensor",
"binary_sensor",
]
async def test_rename_propagates_to_name_and_device_label(