Feature 18: the shared coordinator's refresh pass now fetches GET /api/v1/panel/snapshot through the NestQuestApiClient and reconstructs the exact core dataclasses (NestQuestSnapshot, ChildDaySnapshot, QuestInstanceView) the Feature 10 entities read, so sensor.py and binary_sensor.py are untouched and every entity_id and attribute payload keeps its shape. Why: the integration becomes an API client of the NestQuest service instead of a second reader of the SQLite file, and the panel cards and the four blueprints keep working without changes. - snapshot_from_api_payload maps the route's JSON back onto the dataclasses field by field (state 'completed' -> 'done' so instance_payload renders 'completed' again; counts, presence, next_present, overdue, on_time, completed_at pass through verbatim; due_date is stamped from today_iso, the only date the route serves). - The panel payload omits missed instances (D-009), so admin_instances can no longer carry missed rows — the admin surface for missed quests is the PWA reading the API directly. Documented in the module docstring and pinned by tests. - An unconfigured panel token (the shipped default) gets a typed-error stub instead of a construction crash: setup succeeds, the first refresh fails cleanly, entities come up unavailable and HA's retry cycle applies. Fetch failures log the typed error and mark the entities unavailable while data keeps its last good snapshot. - The runtime record registers before the first refresh so the refresh path always finds the live entry state; the update interval and the serialized forced-refresh behaviour are unchanged. - Tests: a fixture snapshot drives the coordinator (stub client, no network) asserting per-child values, household rollups, cycle day, and the exact instance payload shape, plus unconfigured and failed-fetch cases; DB-seeded tests ride a lazy local-snapshot client through the same JSON path.
518 lines
19 KiB
Python
518 lines
19 KiB
Python
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2)."""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import pytest
|
|
|
|
from conftest import wire_entry_to_registry
|
|
|
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
|
from custom_components.nestquest.children import create_child
|
|
from custom_components.nestquest.completion import complete_instance
|
|
from custom_components.nestquest.const import DOMAIN
|
|
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.sensor import (
|
|
MAX_STATE_LENGTH,
|
|
_state_within_limit,
|
|
)
|
|
|
|
#: The seeding horizon: instances exist for today and a few days out,
|
|
#: enough for the day snapshot without slowing the walk down.
|
|
SEED_HORIZON_DAYS = 3
|
|
|
|
#: The documented per-child day sensor kinds (unique_id suffixes).
|
|
SENSOR_KINDS = (
|
|
"quests_due_today",
|
|
"quests_completed_today",
|
|
"quests_remaining_today",
|
|
"completion_pct_today",
|
|
)
|
|
|
|
|
|
def _today() -> datetime.date:
|
|
return datetime.date.today()
|
|
|
|
|
|
async def _seed_children(database):
|
|
"""Three active children: Ada and Bo share one daily quest; Cory has none."""
|
|
ada = await create_child(database, "Ada")
|
|
bo = await create_child(database, "Bo")
|
|
cory = await create_child(database, "Cory")
|
|
today = _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, cory
|
|
|
|
|
|
async def _setup_seeded_entry(hass, make_entry):
|
|
"""Set up the integration, seed three children, refresh: sensors exist.
|
|
|
|
Children are seeded AFTER setup (the entry's database only exists
|
|
once setup opens it), so the sensors appear through the refresh
|
|
listener — the same path production takes when a child is added.
|
|
"""
|
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
database = entry.runtime_data.database
|
|
ada, bo, cory = await _seed_children(database)
|
|
await coordinator.async_refresh()
|
|
return entry, coordinator, (ada, bo, cory)
|
|
|
|
|
|
def _snapshot_child(coordinator, child_id):
|
|
"""Return the child's current day snapshot from the coordinator."""
|
|
for child in coordinator.data.children:
|
|
if child.child_id == child_id:
|
|
return child
|
|
raise AssertionError(f"child {child_id} missing from the snapshot")
|
|
|
|
|
|
def _sensor(hass, child_id, kind):
|
|
"""Return the registered sensor entity for (child, kind)."""
|
|
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
|
|
|
|
|
async def _complete_first_instance(database, coordinator, child) -> None:
|
|
"""Complete the child's first open instance through the business layer."""
|
|
snapshot_child = _snapshot_child(coordinator, child.id)
|
|
instance_id = snapshot_child.instances[0].instance_id
|
|
await complete_instance(
|
|
database,
|
|
instance_id,
|
|
actor_source="panel",
|
|
actor_child_id=child.id,
|
|
)
|
|
|
|
|
|
async def test_every_active_child_gets_five_sensors_with_documented_identity(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
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 + two binary sensors) + three household sensors,
|
|
# no duplicates.
|
|
assert len(hass.entities) == 24
|
|
expected = {
|
|
ada.id: ("Ada", 1, 1, 0, 100),
|
|
bo.id: ("Bo", 1, 0, 1, 0),
|
|
cory.id: ("Cory", 0, 0, 0, 100),
|
|
}
|
|
for child_id, (name, due, completed, remaining, pct) in expected.items():
|
|
due_entity = _sensor(hass, child_id, "quests_due_today")
|
|
completed_entity = _sensor(hass, child_id, "quests_completed_today")
|
|
remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
|
|
pct_entity = _sensor(hass, child_id, "completion_pct_today")
|
|
next_entity = _sensor(hass, child_id, "next_quest")
|
|
assert due_entity.name == f"NestQuest {name} quests due today"
|
|
assert completed_entity.name == (
|
|
f"NestQuest {name} quests completed today"
|
|
)
|
|
assert remaining_entity.name == (
|
|
f"NestQuest {name} quests remaining today"
|
|
)
|
|
assert pct_entity.name == f"NestQuest {name} completion pct today"
|
|
assert due_entity.native_value == due
|
|
assert completed_entity.native_value == completed
|
|
assert remaining_entity.native_value == remaining
|
|
assert pct_entity.native_value == pct
|
|
for entity in (due_entity, completed_entity, remaining_entity):
|
|
assert entity.state_class == "measurement"
|
|
assert entity.native_unit_of_measurement is None
|
|
assert pct_entity.state_class == "measurement"
|
|
assert pct_entity.native_unit_of_measurement == "%"
|
|
assert pct_entity.device_class is None
|
|
device_info = due_entity.device_info
|
|
assert device_info.name == f"NestQuest {name}"
|
|
assert device_info.manufacturer == "NestQuest"
|
|
assert device_info.identifiers == {(DOMAIN, f"child_{child_id}")}
|
|
|
|
|
|
async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
due_entity = _sensor(hass, cory.id, "quests_due_today")
|
|
pct_entity = _sensor(hass, cory.id, "completion_pct_today")
|
|
assert due_entity.native_value == 0
|
|
assert pct_entity.native_value == 100, (
|
|
"zero quests is a perfect day, not a division error"
|
|
)
|
|
attributes = due_entity.extra_state_attributes
|
|
assert attributes["instances"] == []
|
|
assert attributes["admin_instances"] == []
|
|
|
|
|
|
async def test_due_today_attributes_carry_the_panel_payload(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
await _complete_first_instance(database, coordinator, ada)
|
|
await coordinator.async_refresh()
|
|
|
|
ada_due = _sensor(hass, ada.id, "quests_due_today")
|
|
attributes = ada_due.extra_state_attributes
|
|
assert set(attributes) == {
|
|
"instances",
|
|
"admin_instances",
|
|
"child_id",
|
|
"child_name",
|
|
"present",
|
|
}
|
|
assert attributes["child_id"] == ada.id
|
|
assert attributes["child_name"] == "Ada"
|
|
assert attributes["present"] is True
|
|
[instance] = attributes["instances"]
|
|
assert set(instance) == {
|
|
"id",
|
|
"definition_id",
|
|
"child_id",
|
|
"title",
|
|
"icon",
|
|
"window",
|
|
"due_time",
|
|
"state",
|
|
"overdue",
|
|
"completed_at",
|
|
"on_time",
|
|
}
|
|
assert instance["child_id"] == ada.id
|
|
assert instance["title"] == "Brush teeth"
|
|
assert instance["window"] == "morning"
|
|
assert instance["state"] == "completed", (
|
|
"the derived 'done' maps to the panel's 'completed' spelling"
|
|
)
|
|
assert instance["completed_at"] is not None
|
|
assert instance["on_time"] is not None
|
|
assert instance["overdue"] is False
|
|
assert attributes["admin_instances"] == attributes["instances"], (
|
|
"with nothing missed, admin and panel payloads are identical"
|
|
)
|
|
|
|
bo_due = _sensor(hass, bo.id, "quests_due_today")
|
|
[bo_instance] = bo_due.extra_state_attributes["instances"]
|
|
assert bo_instance["state"] == "open"
|
|
assert bo_instance["completed_at"] is None
|
|
assert bo_instance["on_time"] is None
|
|
|
|
|
|
async def test_due_today_payload_omits_missed_admin_equals_panel(
|
|
hass, make_entry, monkeypatch
|
|
) -> None:
|
|
"""D-009: the panel payload omits missed instances.
|
|
|
|
Since Feature 18 the snapshot comes from the API panel route,
|
|
which omits missed rows entirely (the admin surface for missed
|
|
quests is the PWA, reading the API directly). The entity payload
|
|
therefore cannot carry missed rows any more: ``admin_instances``
|
|
equals ``instances`` — open and completed rows only. A missed
|
|
instance is still derived locally (``due_date`` before the
|
|
snapshot's "today"); the pinned-FUTURE clock at the
|
|
``derive_state`` boundary makes today's open instances read
|
|
missed through the same derivation rule, and this test pins the
|
|
change: neither payload carries them, and the count rollups
|
|
(precomputed server-side from the full day) are unaffected here
|
|
because the API counts arrive per the route's own build.
|
|
"""
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
import custom_components.nestquest.core.snapshot as snapshot_module
|
|
|
|
tomorrow = _today() + datetime.timedelta(days=1)
|
|
real_derive_state = snapshot_module.derive_state
|
|
|
|
def _derive_against_pinned_future(instance, latest, today):
|
|
return real_derive_state(instance, latest, tomorrow)
|
|
|
|
monkeypatch.setattr(
|
|
snapshot_module, "derive_state", _derive_against_pinned_future
|
|
)
|
|
await coordinator.async_refresh()
|
|
|
|
bo_due = _sensor(hass, bo.id, "quests_due_today")
|
|
attributes = bo_due.extra_state_attributes
|
|
assert attributes["instances"] == [], (
|
|
"missed instances are omitted from the panel payload"
|
|
)
|
|
assert attributes["admin_instances"] == [], (
|
|
"the API payload omits missed rows, so the admin payload "
|
|
"carries none either (the PWA is the admin surface now)"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("kind", SENSOR_KINDS)
|
|
async def test_no_sensor_state_exceeds_the_255_character_limit(
|
|
hass, make_entry, kind
|
|
) -> None:
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
for child in (ada, bo, cory):
|
|
entity = _sensor(hass, child.id, kind)
|
|
state = entity.native_value
|
|
assert len(str(state)) <= MAX_STATE_LENGTH
|
|
|
|
|
|
def test_state_within_limit_refuses_overlong_strings() -> None:
|
|
"""The truncation guard: numerics pass, overlong strings fail fast."""
|
|
assert _state_within_limit(3) == 3
|
|
assert _state_within_limit("ok") == "ok"
|
|
with pytest.raises(ValueError, match="255"):
|
|
_state_within_limit("x" * (MAX_STATE_LENGTH + 1))
|
|
|
|
|
|
async def test_sensors_update_on_refresh_after_a_completion(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
completed_entity = _sensor(hass, ada.id, "quests_completed_today")
|
|
remaining_entity = _sensor(hass, ada.id, "quests_remaining_today")
|
|
pct_entity = _sensor(hass, ada.id, "completion_pct_today")
|
|
due_entity = _sensor(hass, ada.id, "quests_due_today")
|
|
assert completed_entity.native_value == 0
|
|
assert remaining_entity.native_value == 1
|
|
assert pct_entity.native_value == 0
|
|
|
|
# Coordinator listener wiring: each refresh writes the entity's
|
|
# state (the registration the cards' state_changed handlers ride).
|
|
writes = []
|
|
real_write = due_entity.async_write_ha_state
|
|
|
|
def _record_write():
|
|
writes.append(due_entity.unique_id)
|
|
real_write()
|
|
|
|
due_entity.async_write_ha_state = _record_write
|
|
|
|
await _complete_first_instance(database, coordinator, ada)
|
|
await coordinator.async_refresh()
|
|
|
|
assert completed_entity.native_value == 1
|
|
assert remaining_entity.native_value == 0
|
|
assert pct_entity.native_value == 100
|
|
assert writes == [due_entity.unique_id]
|
|
|
|
|
|
async def test_repeated_refreshes_do_not_duplicate_entities(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
await coordinator.async_refresh()
|
|
await coordinator.async_refresh()
|
|
assert len(hass.entities) == 24
|
|
|
|
|
|
async def test_setup_with_no_children_registers_only_household(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
# No children means no per-child entities; the three household
|
|
# rollups still exist (an empty household is a valid 0).
|
|
assert set(hass.entities) == {
|
|
"nestquest_household_quests_due_today",
|
|
"nestquest_household_quests_completed_today",
|
|
"nestquest_cycle_day",
|
|
}
|
|
assert hass.entities["nestquest_household_quests_due_today"].native_value == 0
|
|
|
|
|
|
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",
|
|
"binary_sensor",
|
|
]
|
|
|
|
|
|
async def test_rename_propagates_to_name_and_device_label(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A manage_child rename shows on the next refresh: the name and
|
|
device label re-derive from the snapshot while the unique_id (and
|
|
therefore history) is untouched."""
|
|
from custom_components.nestquest.children import edit_child
|
|
|
|
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
due_sensor = _sensor(hass, ada.id, "quests_due_today")
|
|
original_unique_id = due_sensor.unique_id
|
|
assert due_sensor.name.startswith("NestQuest Ada ")
|
|
|
|
await edit_child(database, ada.id, display_name="Mirren")
|
|
await coordinator.async_refresh()
|
|
|
|
assert due_sensor.name.startswith("NestQuest Mirren ")
|
|
assert due_sensor.device_info.name == "NestQuest Mirren"
|
|
assert due_sensor.unique_id == original_unique_id
|
|
|
|
|
|
async def test_failed_platform_unload_returns_false_and_retains_runtime(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""When HA reports platforms still loaded, the unload must NOT
|
|
close the database the live entities read: it returns False and
|
|
keeps the runtime record for a retry."""
|
|
entry, _coordinator, _children = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
|
|
async def _refuse_unload(entry_arg, platforms):
|
|
return False
|
|
|
|
hass.config_entries.async_unload_platforms = _refuse_unload
|
|
assert await async_unload_entry(hass, entry) is False
|
|
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
|
assert database.connected is True
|
|
|
|
async def test_next_quest_sensor_reports_earliest_open_quest(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The next-quest sensor carries the earliest open quest's title in
|
|
state with the full shape in attributes."""
|
|
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
next_entity = _sensor(hass, ada.id, "next_quest")
|
|
assert next_entity.name == f"NestQuest Ada next quest"
|
|
assert next_entity.native_value == "Brush teeth"
|
|
attributes = next_entity.extra_state_attributes
|
|
assert attributes["title"] == "Brush teeth"
|
|
assert attributes["window"] == "morning"
|
|
assert attributes["instance_id"] == _snapshot_child(
|
|
coordinator, ada.id
|
|
).instances[0].instance_id
|
|
assert attributes["child_id"] == ada.id
|
|
|
|
|
|
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A child with every quest done (and a zero-quest child) reports
|
|
the documented sentinel, never an empty string."""
|
|
entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
await _complete_first_instance(database, coordinator, ada)
|
|
await coordinator.async_refresh()
|
|
assert _sensor(hass, ada.id, "next_quest").native_value == "none"
|
|
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
|
|
|
|
|
async def test_next_quest_state_truncates_over_the_limit(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A quest title longer than the state limit is truncated; the
|
|
attribute keeps the full title."""
|
|
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
from custom_components.nestquest.quest_definitions import (
|
|
edit_quest_definition,
|
|
)
|
|
|
|
snapshot_child = _snapshot_child(coordinator, ada.id)
|
|
await edit_quest_definition(
|
|
database,
|
|
snapshot_child.instances[0].definition_id,
|
|
title="A very long quest title. " * 30,
|
|
)
|
|
await coordinator.async_refresh()
|
|
next_entity = _sensor(hass, ada.id, "next_quest")
|
|
full_title = _snapshot_child(coordinator, ada.id).instances[0].title
|
|
assert len(full_title) > 255
|
|
state = next_entity.native_value
|
|
assert len(state) <= 255
|
|
assert next_entity.extra_state_attributes["title"] == full_title
|
|
|
|
|
|
async def test_household_rollups_sum_the_active_children(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""Household due/completed equal the sum of the per-child sensors;
|
|
the cycle-day sensor carries the coordinator's answer."""
|
|
import datetime as _dt
|
|
|
|
from custom_components.nestquest.dao_presence import (
|
|
PresenceSchedulesDao,
|
|
)
|
|
|
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
|
hass, make_entry
|
|
)
|
|
database = entry.runtime_data.database
|
|
await _complete_first_instance(database, coordinator, ada)
|
|
await coordinator.async_refresh()
|
|
|
|
household_due = hass.entities["nestquest_household_quests_due_today"]
|
|
household_completed = hass.entities[
|
|
"nestquest_household_quests_completed_today"
|
|
]
|
|
cycle_day_entity = hass.entities["nestquest_cycle_day"]
|
|
per_child_due = [
|
|
_sensor(hass, child.id, "quests_due_today").native_value
|
|
for child in (ada, bo, cory)
|
|
]
|
|
per_child_completed = [
|
|
_sensor(hass, child.id, "quests_completed_today").native_value
|
|
for child in (ada, bo, cory)
|
|
]
|
|
assert household_due.native_value == sum(per_child_due)
|
|
assert household_completed.native_value == sum(per_child_completed)
|
|
assert household_due.name == "NestQuest household quests due today"
|
|
assert household_completed.name == (
|
|
"NestQuest household quests completed today"
|
|
)
|
|
assert cycle_day_entity.name == "NestQuest cycle day"
|
|
assert cycle_day_entity.native_value == 0, "no schedule anywhere"
|
|
|
|
# A schedule on the FIRST active child drives cycle day.
|
|
today = _dt.date.today()
|
|
await PresenceSchedulesDao(database).upsert_by_child(
|
|
ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(),
|
|
"0,1,2,3,4,5,6|",
|
|
)
|
|
await coordinator.async_refresh()
|
|
assert cycle_day_entity.native_value == 4
|
|
assert cycle_day_entity.extra_state_attributes["today"] == (
|
|
today.isoformat()
|
|
)
|