From 1f7a7ba46cd0674f465e33ccc54f70400e5cc3e6 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:54:19 -0400 Subject: [PATCH] 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. --- custom_components/nestquest/sensor.py | 93 ++++++++++++++++++++++++++- tests/test_sensors.py | 73 +++++++++++++++++++-- 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 08fbd0c..d3ae546 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -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 diff --git a/tests/test_sensors.py b/tests/test_sensors.py index c054740..cf0478b 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -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 \ No newline at end of file + 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 -- 2.55.0