diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 78441f0..33f1449 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -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,20 @@ 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. + 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 if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 69b55ae..82ef273 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -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). diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py new file mode 100644 index 0000000..08fbd0c --- /dev/null +++ b/custom_components/nestquest/sensor.py @@ -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__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) diff --git a/tests/conftest.py b/tests/conftest.py index cbc5c24..d753166 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_const.py b/tests/test_const.py index 85ff105..dc9e255 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -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: diff --git a/tests/test_harness.py b/tests/test_harness.py index b947990..fa2e88b 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -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") diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..c054740 --- /dev/null +++ b/tests/test_sensors.py @@ -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 \ No newline at end of file