Add the per-child next-quest sensor
State carries the earliest open quest's title truncated under the 255-character limit with the full shape in attributes; the documented "none" sentinel is reported when the day is clear.
This commit is contained in:
@@ -98,6 +98,40 @@ def _instance_payload(
|
||||
return payload
|
||||
|
||||
|
||||
#: State reported by the next-quest sensor when the child owes nothing
|
||||
#: else today. A documented sentinel, never an empty string (an empty
|
||||
#: state reads as "sensor went unavailable" in HA).
|
||||
NEXT_QUEST_NONE_STATE = "none"
|
||||
|
||||
|
||||
def _next_open_quest(
|
||||
child: ChildDaySnapshot,
|
||||
) -> QuestInstanceView | None:
|
||||
"""Return the child's earliest open quest from today's snapshot.
|
||||
|
||||
The snapshot carries TODAY's instances, so "next" is the earliest
|
||||
open instance by (due_date, due_time), with ``due_time`` ordering
|
||||
ahead of ``None`` (a window with no due time comes first only
|
||||
within the same date — the materializer snapshots each window's
|
||||
due_time, so a missing one means the window is unscheduled).
|
||||
Missed and completed instances never qualify.
|
||||
"""
|
||||
open_views = [
|
||||
view
|
||||
for view in child.instances
|
||||
if view.state == "open"
|
||||
]
|
||||
if not open_views:
|
||||
return None
|
||||
return min(
|
||||
open_views,
|
||||
key=lambda view: (
|
||||
view.due_date,
|
||||
view.due_time if view.due_time is not None else "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
@@ -268,15 +302,70 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
|
||||
return _state_within_limit(child.completion_pct)
|
||||
|
||||
|
||||
class NestQuestNextQuestSensor(_NestQuestChildDaySensor):
|
||||
"""The child's next open quest today (title in the state).
|
||||
|
||||
State is the quest TITLE, truncated to HA's 255-character state
|
||||
limit (attributes always carry the full title); when the child
|
||||
owes nothing else today the state is the documented sentinel
|
||||
``none`` (NEXT_QUEST_NONE_STATE), never an empty string.
|
||||
"""
|
||||
|
||||
_attr_state_class = None # a label, not a measurement
|
||||
_name_suffix = "next quest"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_next_quest"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
quest = _next_open_quest(child)
|
||||
if quest is None:
|
||||
return NEXT_QUEST_NONE_STATE
|
||||
return quest.title[:MAX_STATE_LENGTH]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
quest = _next_open_quest(child)
|
||||
if quest is None:
|
||||
return {"child_id": child.child_id, "child_name": child.child_name}
|
||||
return {
|
||||
"instance_id": quest.instance_id,
|
||||
"definition_id": quest.definition_id,
|
||||
"child_id": quest.child_id,
|
||||
"child_name": child.child_name,
|
||||
"title": quest.title,
|
||||
"icon": quest.icon,
|
||||
"window": quest.window,
|
||||
"due_date": quest.due_date,
|
||||
"due_time": quest.due_time,
|
||||
"overdue": quest.overdue,
|
||||
}
|
||||
|
||||
|
||||
def _child_day_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildDaySensor, ...]:
|
||||
"""Build the four documented day sensors for one active child."""
|
||||
"""Build the per-child day sensors for one active child."""
|
||||
return (
|
||||
NestQuestQuestsDueTodaySensor(coordinator, child),
|
||||
NestQuestQuestsCompletedTodaySensor(coordinator, child),
|
||||
NestQuestQuestsRemainingTodaySensor(coordinator, child),
|
||||
NestQuestCompletionPctTodaySensor(coordinator, child),
|
||||
NestQuestNextQuestSensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
@@ -285,7 +374,7 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the four day sensors per active child.
|
||||
"""Create the five day sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that
|
||||
|
||||
+68
-5
@@ -97,7 +97,7 @@ async def _complete_first_instance(database, coordinator, child) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_every_active_child_gets_four_sensors_with_documented_identity(
|
||||
async def test_every_active_child_gets_five_sensors_with_documented_identity(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
@@ -109,8 +109,8 @@ async def test_every_active_child_gets_four_sensors_with_documented_identity(
|
||||
await coordinator.async_refresh()
|
||||
|
||||
# Two quest children plus the zero-quest child: three children x
|
||||
# four sensors, no duplicates.
|
||||
assert len(hass.entities) == 12
|
||||
# five sensors, no duplicates.
|
||||
assert len(hass.entities) == 15
|
||||
expected = {
|
||||
ada.id: ("Ada", 1, 1, 0, 100),
|
||||
bo.id: ("Bo", 1, 0, 1, 0),
|
||||
@@ -121,6 +121,7 @@ async def test_every_active_child_gets_four_sensors_with_documented_identity(
|
||||
completed_entity = _sensor(hass, child_id, "quests_completed_today")
|
||||
remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
|
||||
pct_entity = _sensor(hass, child_id, "completion_pct_today")
|
||||
next_entity = _sensor(hass, child_id, "next_quest")
|
||||
assert due_entity.name == f"NestQuest {name} quests due today"
|
||||
assert completed_entity.name == (
|
||||
f"NestQuest {name} quests completed today"
|
||||
@@ -323,7 +324,7 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
await coordinator.async_refresh()
|
||||
assert len(hass.entities) == 12
|
||||
assert len(hass.entities) == 15
|
||||
|
||||
|
||||
async def test_setup_with_no_children_registers_no_entities(
|
||||
@@ -382,4 +383,66 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime(
|
||||
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
|
||||
assert database.connected is True
|
||||
|
||||
async def test_next_quest_sensor_reports_earliest_open_quest(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The next-quest sensor carries the earliest open quest's title in
|
||||
state with the full shape in attributes."""
|
||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
next_entity = _sensor(hass, ada.id, "next_quest")
|
||||
assert next_entity.name == f"NestQuest Ada next quest"
|
||||
assert next_entity.native_value == "Brush teeth"
|
||||
attributes = next_entity.extra_state_attributes
|
||||
assert attributes["title"] == "Brush teeth"
|
||||
assert attributes["window"] == "morning"
|
||||
assert attributes["instance_id"] == _snapshot_child(
|
||||
coordinator, ada.id
|
||||
).instances[0].instance_id
|
||||
assert attributes["child_id"] == ada.id
|
||||
|
||||
|
||||
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child with every quest done (and a zero-quest child) reports
|
||||
the documented sentinel, never an empty string."""
|
||||
entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
assert _sensor(hass, ada.id, "next_quest").native_value == "none"
|
||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
||||
|
||||
|
||||
async def test_next_quest_state_truncates_over_the_limit(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A quest title longer than the state limit is truncated; the
|
||||
attribute keeps the full title."""
|
||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
edit_quest_definition,
|
||||
)
|
||||
|
||||
snapshot_child = _snapshot_child(coordinator, ada.id)
|
||||
await edit_quest_definition(
|
||||
database,
|
||||
snapshot_child.instances[0].definition_id,
|
||||
title="A very long quest title. " * 30,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
next_entity = _sensor(hass, ada.id, "next_quest")
|
||||
full_title = _snapshot_child(coordinator, ada.id).instances[0].title
|
||||
assert len(full_title) > 255
|
||||
state = next_entity.native_value
|
||||
assert len(state) <= 255
|
||||
assert next_entity.extra_state_attributes["title"] == full_title
|
||||
|
||||
Reference in New Issue
Block a user