Add per-child daily count and percentage sensors
Four sensors per active child read the coordinator snapshot: quests due today (carrying the panel payload that omits missed instances plus the admin payload that includes them, D-009), completed, remaining, and completion percentage (100 on a zero-quest day). Platform forwarding wired through the harness's entity registry stand-ins.
This commit is contained in:
@@ -26,6 +26,7 @@ from .const import (
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase
|
||||
@@ -511,6 +512,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
),
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
coordinator=coordinator,
|
||||
)
|
||||
# runtime_data is assigned BEFORE the platforms forward so each
|
||||
# platform's async_setup_entry reads its coordinator off the
|
||||
# entry (the HA runtime-data contract); a forward failure below
|
||||
# unwinds it through the except path.
|
||||
entry.runtime_data = runtime_data
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# A failure anywhere after the database is open must not leak the
|
||||
# listeners already registered against that (now-closed) database:
|
||||
@@ -538,19 +553,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await coordinator.async_shutdown()
|
||||
except BaseException:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# closes the already-closed database through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
await database.close()
|
||||
raise
|
||||
assert remove_update_listener is not None
|
||||
assert remove_time_change_listener is not None
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
coordinator=coordinator,
|
||||
)
|
||||
entry.runtime_data = runtime_data
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
@@ -564,6 +575,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.pop(DOMAIN, None)
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# listeners must not outlive the database and coordinator the
|
||||
# teardown below closes.
|
||||
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unload_error: BaseException | None = None
|
||||
if runtime_data is not None:
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
|
||||
@@ -7,7 +7,9 @@ DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
PLATFORMS: list[str] = []
|
||||
# Platforms set up per config entry (Feature 10): the per-child day
|
||||
# sensors. binary_sensor joins when task 4's entities land.
|
||||
PLATFORMS: list[str] = ["sensor"]
|
||||
|
||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||
#: plus the existing Feature 07 regenerate service).
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Per-child daily count and percentage sensors (Feature 10, task 2).
|
||||
|
||||
Naming contract (design/ENTITIES-AND-SERVICES.md §1): the design table
|
||||
documents the entity_id shape ``sensor.nestquest_<child>_quests_due_today``.
|
||||
Home Assistant derives entity_id from the entity NAME and exact
|
||||
entity_id strings are not directly controllable, so names are spelled
|
||||
to slug into the documented shape — ``NestQuest Ada quests due today``
|
||||
slugs to ``sensor.nestquest_ada_quests_due_today`` — while the
|
||||
registry identity (``unique_id``) IS exact: built from the child's
|
||||
database id, never the display name, so a rename never orphans an
|
||||
entity's history. The design table is the visual contract; this
|
||||
module spells names so HA's default slugging produces it.
|
||||
|
||||
Every sensor reads ONLY ``coordinator.data`` (one
|
||||
:class:`~.coordinator.ChildDaySnapshot` per active child); no entity
|
||||
queries the database, so one refresh updates the whole board
|
||||
coherently. The due-today sensor additionally carries the child's
|
||||
day payload as attributes: ``instances`` is the panel shape of
|
||||
ENTITIES-AND-SERVICES.md §1 (state ``open``/``completed``) OMITTING
|
||||
missed instances, and ``admin_instances`` includes them (D-009).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestCoordinator,
|
||||
QuestInstanceView,
|
||||
)
|
||||
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
||||
#: sensor can never crash the state write, and later string-state
|
||||
#: sensors reuse the same helper.
|
||||
MAX_STATE_LENGTH = 255
|
||||
|
||||
|
||||
def _state_within_limit(state: Any) -> Any:
|
||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
||||
|
||||
Numeric states pass through untouched; a string state longer than
|
||||
:data:`MAX_STATE_LENGTH` fails fast with ValueError at the property
|
||||
read — before HA's state machine rejects the write.
|
||||
"""
|
||||
if isinstance(state, str) and len(state) > MAX_STATE_LENGTH:
|
||||
raise ValueError(
|
||||
f"state exceeds Home Assistant's {MAX_STATE_LENGTH}-character "
|
||||
f"limit ({len(state)} characters)"
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
``state`` maps the coordinator's derived ``done`` to the panel's
|
||||
``completed`` spelling; ``missed`` instances are omitted unless
|
||||
``include_missed`` (the admin payload, D-009, keeps them with
|
||||
their ``missed`` state).
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
Registry identity and the child's NestQuest device grouping are
|
||||
built from the child's database id; the day's numbers are read
|
||||
from the child's CURRENT snapshot so every refresh lands
|
||||
coherently. A child absent from the latest snapshot (a
|
||||
deactivation; removal is a later task's scope) yields ``None``
|
||||
states rather than stale counts.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._child_id = child.child_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"child_{child.child_id}")},
|
||||
name=f"NestQuest {child.child_name}",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildDaySnapshot | None:
|
||||
"""Return the child's current day snapshot, or None."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
for child in data.children:
|
||||
if child.child_id == self._child_id:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many quests the child has due today, with the day payload."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_name = f"NestQuest {child.child_name} quests due today"
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_due_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.due_today)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"instances": _instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
"admin_instances": _instance_payload(
|
||||
child.instances, include_missed=True
|
||||
),
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
"present": child.present,
|
||||
}
|
||||
|
||||
|
||||
class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many of the child's quests were completed today."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_name = (
|
||||
f"NestQuest {child.child_name} quests completed today"
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_completed_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.completed_today)
|
||||
|
||||
|
||||
class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many of the child's quests are still owed today."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_name = (
|
||||
f"NestQuest {child.child_name} quests remaining today"
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_remaining_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.remaining_today)
|
||||
|
||||
|
||||
class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
|
||||
"""The child's completion percentage for today (100 on a zero-quest day)."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_device_class = SensorDeviceClass.PERCENTAGE
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_name = (
|
||||
f"NestQuest {child.child_name} completion pct today"
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_completion_pct_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.completion_pct)
|
||||
|
||||
|
||||
def _child_day_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildDaySensor, ...]:
|
||||
"""Build the four documented day sensors for one active child."""
|
||||
return (
|
||||
NestQuestQuestsDueTodaySensor(coordinator, child),
|
||||
NestQuestQuestsCompletedTodaySensor(coordinator, child),
|
||||
NestQuestQuestsRemainingTodaySensor(coordinator, child),
|
||||
NestQuestCompletionPctTodaySensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the four day sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that
|
||||
first appear on a later refresh. Deactivated children are NOT
|
||||
removed here — their sensors hold ``None`` states until the
|
||||
entity-removal task lands.
|
||||
"""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
known_child_ids: set[int] = set()
|
||||
|
||||
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
|
||||
new_children = [
|
||||
child
|
||||
for child in children
|
||||
if child.child_id not in known_child_ids
|
||||
]
|
||||
if not new_children:
|
||||
return
|
||||
known_child_ids.update(child.child_id for child in new_children)
|
||||
async_add_entities(
|
||||
[
|
||||
sensor
|
||||
for child in new_children
|
||||
for sensor in _child_day_sensors(coordinator, child)
|
||||
]
|
||||
)
|
||||
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
def _handle_coordinator_update() -> None:
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
coordinator.async_add_listener(_handle_coordinator_update)
|
||||
@@ -27,6 +27,7 @@ import inspect
|
||||
import sys
|
||||
import tempfile
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -44,8 +45,12 @@ _HA_MODULES = (
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -85,8 +90,12 @@ for _parent, _child in (
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -200,6 +209,154 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
|
||||
The kwargs-only shape the integration builds per-child devices
|
||||
with: ``identifiers``, ``name``, ``manufacturer``, ``model``.
|
||||
"""
|
||||
|
||||
identifiers: object = None
|
||||
name: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class Entity:
|
||||
"""Stand-in for homeassistant.helpers.entity.Entity.
|
||||
|
||||
Mirrors the ``_attr_*`` attribute contract through the ``name``,
|
||||
``unique_id``, ``device_info`` and ``extra_state_attributes``
|
||||
properties, and ``async_write_ha_state`` registering the entity
|
||||
into the hass entity registry (``hass.entities``, a dict keyed by
|
||||
``unique_id`` storing the live object) — the harness's stand-in
|
||||
for HA's state machine, so tests enumerate entities and read
|
||||
their state directly off the registered object.
|
||||
"""
|
||||
|
||||
hass = None
|
||||
_attr_name: str | None = None
|
||||
_attr_unique_id: str | None = None
|
||||
_attr_device_info: DeviceInfo | None = None
|
||||
_attr_extra_state_attributes: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return self._attr_name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
return self._attr_device_info
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
return self._attr_extra_state_attributes
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Register the entity under its unique_id (the mock state write)."""
|
||||
if self.hass is not None and self.unique_id is not None:
|
||||
self.hass.entities[self.unique_id] = self
|
||||
|
||||
|
||||
class CoordinatorEntity(Entity):
|
||||
"""Stand-in for homeassistant.helpers.update_coordinator.CoordinatorEntity.
|
||||
|
||||
Real HA subscribes the entity to coordinator notifications in the
|
||||
platform-scheduler-driven ``async_added_to_hass`` lifecycle hook;
|
||||
the stand-in subscribes eagerly in ``__init__`` so the synchronous
|
||||
``AddEntitiesCallback`` surface stays synchronous (the mock
|
||||
harness has no lifecycle scheduler). ``_handle_coordinator_update``
|
||||
mirrors HA: one state write per coordinator notification.
|
||||
"""
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class SensorStateClass:
|
||||
"""Stand-in namespace for homeassistant.components.sensor.SensorStateClass.
|
||||
|
||||
Only the members the integration uses (plus TOTAL for the
|
||||
long-history sensors later tasks add).
|
||||
"""
|
||||
|
||||
MEASUREMENT = "measurement"
|
||||
TOTAL = "total"
|
||||
|
||||
|
||||
class SensorDeviceClass:
|
||||
"""Stand-in namespace for homeassistant.components.sensor.SensorDeviceClass.
|
||||
|
||||
Only the members the integration uses.
|
||||
"""
|
||||
|
||||
PERCENTAGE = "percentage"
|
||||
|
||||
|
||||
class SensorEntity(Entity):
|
||||
"""Stand-in for homeassistant.components.sensor.SensorEntity.
|
||||
|
||||
The ``_attr_*`` passthrough for ``native_value``,
|
||||
``native_unit_of_measurement``, ``state_class``, ``device_class``
|
||||
and ``suggested_display_precision``.
|
||||
"""
|
||||
|
||||
_attr_native_value: object = None
|
||||
_attr_native_unit_of_measurement: str | None = None
|
||||
_attr_state_class: str | None = None
|
||||
_attr_device_class: str | None = None
|
||||
_attr_suggested_display_precision: int | None = None
|
||||
|
||||
@property
|
||||
def native_value(self) -> object:
|
||||
return self._attr_native_value
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def state_class(self) -> str | None:
|
||||
return self._attr_state_class
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
return self._attr_device_class
|
||||
|
||||
@property
|
||||
def suggested_display_precision(self) -> int | None:
|
||||
return self._attr_suggested_display_precision
|
||||
|
||||
|
||||
class BinarySensorEntity(Entity):
|
||||
"""Stand-in for homeassistant.components.binary_sensor.BinarySensorEntity.
|
||||
|
||||
Wired now so PLATFORMS can grow (task 4's all-done / present
|
||||
sensors) without more harness surgery; only the surface the
|
||||
integration uses.
|
||||
"""
|
||||
|
||||
_attr_is_on: bool | None = None
|
||||
_attr_device_class: str | None = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
return self._attr_is_on
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
@@ -258,6 +415,19 @@ class DataUpdateCoordinator:
|
||||
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
_entity_mock.Entity = Entity
|
||||
|
||||
_sensor_mock = _ha_mock("homeassistant.components.sensor")
|
||||
_sensor_mock.SensorEntity = SensorEntity
|
||||
_sensor_mock.SensorDeviceClass = SensorDeviceClass
|
||||
_sensor_mock.SensorStateClass = SensorStateClass
|
||||
|
||||
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
|
||||
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -505,6 +675,62 @@ def make_hass() -> tuple:
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
# The minimal entity registry: entities self-register through
|
||||
# Entity.async_write_ha_state keyed by unique_id, storing the live
|
||||
# object; tests enumerate created entities and read their state
|
||||
# directly.
|
||||
hass.entities: dict[str, Entity] = {}
|
||||
|
||||
def _async_add_entities(new_entities, update_before_add=False):
|
||||
"""Mirror HA's AddEntitiesCallback: bind hass and register entities.
|
||||
|
||||
Accepts a single entity or an iterable, mirroring HA's
|
||||
permissive call shape; registration goes through each entity's
|
||||
async_write_ha_state (the mock state write).
|
||||
"""
|
||||
if isinstance(new_entities, Entity):
|
||||
new_entities = [new_entities]
|
||||
for entity in new_entities:
|
||||
entity.hass = hass
|
||||
entity.async_write_ha_state()
|
||||
|
||||
async def _async_forward_entry_setups(entry, platforms):
|
||||
"""Mirror hass.config_entries.async_forward_entry_setups.
|
||||
|
||||
HA's contract, loosely: import each platform module of the
|
||||
entry's integration and drive its ``async_setup_entry`` with
|
||||
the AddEntitiesCallback above. A stub entry without a domain
|
||||
defaults to this harness's single integration (NestQuest).
|
||||
"""
|
||||
for platform in list(platforms):
|
||||
domain = getattr(entry, "domain", "nestquest")
|
||||
module = importlib.import_module(
|
||||
f"custom_components.{domain}.{platform}"
|
||||
)
|
||||
await module.async_setup_entry(hass, entry, _async_add_entities)
|
||||
hass.config_entries.forwarded_platforms.append(platform)
|
||||
|
||||
async def _async_unload_platforms(entry, platforms):
|
||||
"""Mirror hass.config_entries.async_unload_platforms.
|
||||
|
||||
Runs each platform module's ``async_unload_entry`` when it
|
||||
defines one (per-platform unload is optional, like HA's).
|
||||
"""
|
||||
for platform in list(platforms):
|
||||
domain = getattr(entry, "domain", "nestquest")
|
||||
module = importlib.import_module(
|
||||
f"custom_components.{domain}.{platform}"
|
||||
)
|
||||
unload = getattr(module, "async_unload_entry", None)
|
||||
if unload is not None:
|
||||
await unload(hass, entry)
|
||||
hass.config_entries.unloaded_platforms.append(platform)
|
||||
|
||||
hass.config_entries.forwarded_platforms: list[str] = []
|
||||
hass.config_entries.unloaded_platforms: list[str] = []
|
||||
hass.config_entries.async_forward_entry_setups = _async_forward_entry_setups
|
||||
hass.config_entries.async_unload_platforms = _async_unload_platforms
|
||||
|
||||
async def _async_reload(entry_id: str) -> None:
|
||||
registry.reloaded.append(entry_id)
|
||||
|
||||
|
||||
+2
-2
@@ -28,10 +28,10 @@ def test_logger() -> None:
|
||||
|
||||
|
||||
def test_platforms() -> None:
|
||||
"""Verify PLATFORMS is defined as an empty list."""
|
||||
"""Verify PLATFORMS is defined as the sensor platform list."""
|
||||
assert hasattr(const, "PLATFORMS")
|
||||
assert isinstance(const.PLATFORMS, list)
|
||||
assert const.PLATFORMS == []
|
||||
assert const.PLATFORMS == ["sensor"]
|
||||
|
||||
|
||||
def test_sqlite_db_filename() -> None:
|
||||
|
||||
@@ -31,6 +31,23 @@ def test_mock_only_harness_contract(monkeypatch):
|
||||
assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator
|
||||
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
|
||||
|
||||
# Entity-platform stand-ins are wired with the shaped classes, and
|
||||
# parent-package attributes resolve to the mocked submodules.
|
||||
entity_module = sys.modules["homeassistant.helpers.entity"]
|
||||
assert entity_module.DeviceInfo is conftest.DeviceInfo
|
||||
assert entity_module.Entity is conftest.Entity
|
||||
sensor_module = sys.modules["homeassistant.components.sensor"]
|
||||
assert sensor_module.SensorEntity is conftest.SensorEntity
|
||||
assert sensor_module.SensorStateClass.MEASUREMENT == "measurement"
|
||||
assert sensor_module.SensorDeviceClass.PERCENTAGE == "percentage"
|
||||
assert sys.modules[
|
||||
"homeassistant.components.binary_sensor"
|
||||
].BinarySensorEntity is conftest.BinarySensorEntity
|
||||
assert sys.modules[
|
||||
"homeassistant.helpers.update_coordinator"
|
||||
].CoordinatorEntity is conftest.CoordinatorEntity
|
||||
assert sys.modules["homeassistant"].components.sensor is sensor_module
|
||||
|
||||
# No homeassistant distribution is installed in this suite's own venv.
|
||||
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
||||
importlib.metadata.version("homeassistant")
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""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_four_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"]
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
# Two quest children plus the zero-quest child: three children x
|
||||
# four sensors, no duplicates.
|
||||
assert len(hass.entities) == 12
|
||||
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")
|
||||
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 == "percentage"
|
||||
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_for_panel_includes_for_admin(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""D-009: the panel payload omits missed instances, the admin one keeps them.
|
||||
|
||||
A missed instance is derived (``due_date`` before the snapshot's
|
||||
"today"), never stored. The coordinator's day listing is keyed on
|
||||
``_local_now``'s date, so pinning the clock forward would move the
|
||||
listing window off today's rows entirely; the pinned FUTURE clock
|
||||
is therefore applied at the ``derive_state`` boundary instead —
|
||||
today's open instances then read as missed through the same
|
||||
derivation rule, without back-dating instance rows past the DAO's
|
||||
no-past guard or touching append-only completion events.
|
||||
"""
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
tomorrow = _today() + datetime.timedelta(days=1)
|
||||
real_derive_state = coordinator_module.derive_state
|
||||
|
||||
def _derive_against_pinned_future(instance, latest, today):
|
||||
return real_derive_state(instance, latest, tomorrow)
|
||||
|
||||
monkeypatch.setattr(
|
||||
coordinator_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"
|
||||
)
|
||||
[missed] = attributes["admin_instances"]
|
||||
assert missed["state"] == "missed"
|
||||
assert missed["completed_at"] is None
|
||||
# Counts still roll the missed instance up as not-completed.
|
||||
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
|
||||
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
||||
|
||||
|
||||
@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) == 12
|
||||
|
||||
|
||||
async def test_setup_with_no_children_registers_no_entities(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.entities == {}
|
||||
|
||||
|
||||
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"]
|
||||
Reference in New Issue
Block a user