Add per-child daily count and percentage sensors #106
@@ -26,6 +26,7 @@ from .const import (
|
|||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .coordinator import NestQuestCoordinator
|
from .coordinator import NestQuestCoordinator
|
||||||
from .db import NestQuestDatabase
|
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()
|
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:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
# listeners already registered against that (now-closed) database:
|
# 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()
|
await coordinator.async_shutdown()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
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()
|
await database.close()
|
||||||
raise
|
raise
|
||||||
assert remove_update_listener is not None
|
assert remove_update_listener is not None
|
||||||
assert remove_time_change_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
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -564,6 +575,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.pop(DOMAIN, None)
|
hass.data.pop(DOMAIN, None)
|
||||||
if runtime_data is None:
|
if runtime_data is None:
|
||||||
runtime_data = getattr(entry, "runtime_data", 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.
|
||||||
|
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||||
|
entry, PLATFORMS
|
||||||
|
)
|
||||||
|
if platforms_unloaded is False and runtime_data is not None:
|
||||||
|
# Home Assistant KEPT one or more platforms loaded — their
|
||||||
|
# entities still live and must keep their database. Restore
|
||||||
|
# the runtime record popped above and report failure so HA
|
||||||
|
# retries the unload later instead of leaving live entities
|
||||||
|
# backed by a closed database.
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||||
|
return False
|
||||||
unload_error: BaseException | None = None
|
unload_error: BaseException | None = None
|
||||||
if runtime_data is not None:
|
if runtime_data is not None:
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ DOMAIN = "nestquest"
|
|||||||
LOGGER_NAME = "custom_components.nestquest"
|
LOGGER_NAME = "custom_components.nestquest"
|
||||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
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
|
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||||
#: plus the existing Feature 07 regenerate service).
|
#: plus the existing Feature 07 regenerate service).
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""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 is built from the child's database id and never
|
||||||
|
changes; the DISPLAYED name and device label are derived from the
|
||||||
|
child's CURRENT snapshot on every read, so a rename through
|
||||||
|
``manage_child`` propagates on the next refresh without orphaning
|
||||||
|
the entity's history. A child absent from the latest snapshot (a
|
||||||
|
deactivation; removal is a later task's scope) yields ``None``
|
||||||
|
states rather than stale counts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Per-subclass label appended to the name (e.g. ``quests due
|
||||||
|
#: today``), set as a class attribute so ``name`` composes from the
|
||||||
|
#: current snapshot.
|
||||||
|
_name_suffix: str = ""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: NestQuestCoordinator,
|
||||||
|
child: ChildDaySnapshot,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._child_id = child.child_id
|
||||||
|
self._fallback_name = child.child_name
|
||||||
|
self._attr_unique_id = None # set by the subclass
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
def _displayed_child_name(self) -> str:
|
||||||
|
child = self._child
|
||||||
|
return child.child_name if child is not None else self._fallback_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str | None:
|
||||||
|
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, f"child_{self._child_id}")},
|
||||||
|
name=f"NestQuest {self._displayed_child_name()}",
|
||||||
|
manufacturer="NestQuest",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||||
|
"""How many quests the child has due today, with the day payload."""
|
||||||
|
|
||||||
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
|
_name_suffix = "quests due today"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: NestQuestCoordinator,
|
||||||
|
child: ChildDaySnapshot,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, child)
|
||||||
|
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
|
||||||
|
_name_suffix = "quests completed today"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: NestQuestCoordinator,
|
||||||
|
child: ChildDaySnapshot,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, child)
|
||||||
|
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
|
||||||
|
_name_suffix = "quests remaining today"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: NestQuestCoordinator,
|
||||||
|
child: ChildDaySnapshot,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, child)
|
||||||
|
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 = "%"
|
||||||
|
_name_suffix = "completion pct today"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: NestQuestCoordinator,
|
||||||
|
child: ChildDaySnapshot,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, child)
|
||||||
|
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 sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import weakref
|
import weakref
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -44,8 +45,12 @@ _HA_MODULES = (
|
|||||||
"homeassistant.exceptions",
|
"homeassistant.exceptions",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.config_validation",
|
"homeassistant.helpers.config_validation",
|
||||||
|
"homeassistant.helpers.entity",
|
||||||
"homeassistant.helpers.event",
|
"homeassistant.helpers.event",
|
||||||
"homeassistant.helpers.update_coordinator",
|
"homeassistant.helpers.update_coordinator",
|
||||||
|
"homeassistant.components",
|
||||||
|
"homeassistant.components.sensor",
|
||||||
|
"homeassistant.components.binary_sensor",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_spec(name: str):
|
def _find_spec(name: str):
|
||||||
@@ -85,8 +90,12 @@ for _parent, _child in (
|
|||||||
("homeassistant", "exceptions"),
|
("homeassistant", "exceptions"),
|
||||||
("homeassistant", "helpers"),
|
("homeassistant", "helpers"),
|
||||||
("homeassistant.helpers", "config_validation"),
|
("homeassistant.helpers", "config_validation"),
|
||||||
|
("homeassistant.helpers", "entity"),
|
||||||
("homeassistant.helpers", "event"),
|
("homeassistant.helpers", "event"),
|
||||||
("homeassistant.helpers", "update_coordinator"),
|
("homeassistant.helpers", "update_coordinator"),
|
||||||
|
("homeassistant", "components"),
|
||||||
|
("homeassistant.components", "sensor"),
|
||||||
|
("homeassistant.components", "binary_sensor"),
|
||||||
):
|
):
|
||||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||||
|
|
||||||
@@ -200,6 +209,154 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
|||||||
_exceptions_mock.Unauthorized = Unauthorized
|
_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:
|
class DataUpdateCoordinator:
|
||||||
"""Functional stand-in for HA's 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 = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
_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
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -505,6 +675,62 @@ def make_hass() -> tuple:
|
|||||||
# compute "today" in HA local time).
|
# compute "today" in HA local time).
|
||||||
hass.config.time_zone = "UTC"
|
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:
|
async def _async_reload(entry_id: str) -> None:
|
||||||
registry.reloaded.append(entry_id)
|
registry.reloaded.append(entry_id)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -28,10 +28,10 @@ def test_logger() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_platforms() -> 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 hasattr(const, "PLATFORMS")
|
||||||
assert isinstance(const.PLATFORMS, list)
|
assert isinstance(const.PLATFORMS, list)
|
||||||
assert const.PLATFORMS == []
|
assert const.PLATFORMS == ["sensor"]
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_db_filename() -> None:
|
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.core"].callback is conftest._callback_decorator
|
||||||
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
|
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.
|
# No homeassistant distribution is installed in this suite's own venv.
|
||||||
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
||||||
importlib.metadata.version("homeassistant")
|
importlib.metadata.version("homeassistant")
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""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"]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user