The panel snapshot now carries the admin-saved `timezone` ("" when unset);
the coordinator threads it onto the household due-today sensor, and the
Party Board and Quest Log cards format their kicker, header and dock
date and clock in it, falling back to Home Assistant's zone only when
none is stored. The admin app loads the stored zone once
(AppTimezoneProvider) and uses it for completion clocks, History day
grouping, clocks and lateness, and "today" in Schedule, History and new
task defaults; Settings publishes a saved zone app-wide at once.
Resolves review finding TZ-001 (task 7e173881).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
723 lines
26 KiB
Python
723 lines
26 KiB
Python
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2).
|
|
|
|
Since the DB removal the snapshot comes from the API service's panel
|
|
route, so these tests script the route's payload (the documented
|
|
Feature 16 shape ``test_coordinator.py`` also uses) through
|
|
:class:`conftest.StubSnapshotClient` instead of seeding a local
|
|
database: the coordinator rebuilds the same core dataclasses the
|
|
sensors read, and every assertion below is on entity state.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import pytest
|
|
from homeassistant.util import slugify
|
|
|
|
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
|
|
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
|
from custom_components.nestquest.const import DOMAIN
|
|
from custom_components.nestquest.sensor import (
|
|
MAX_STATE_LENGTH,
|
|
_slugify,
|
|
_state_within_limit,
|
|
)
|
|
|
|
|
|
def _today_iso() -> str:
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
def _instance(instance_id: int, child_id: int, **overrides) -> dict:
|
|
"""One panel instance in the API route's payload shape."""
|
|
payload = {
|
|
"id": instance_id,
|
|
"definition_id": 3,
|
|
"child_id": child_id,
|
|
"title": "Brush teeth",
|
|
"icon": "🦷",
|
|
"window": "morning",
|
|
"due_time": "08:00",
|
|
"state": "open",
|
|
"overdue": False,
|
|
"completed_at": None,
|
|
"on_time": None,
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def _child(child_id: int, name: str, instances=(), **overrides) -> dict:
|
|
"""One child in the API route's payload shape, counts precomputed.
|
|
|
|
The API precomputes the per-child rollups from the full day; the
|
|
builder here derives them from the instance list so a payload can
|
|
never drift from its own counts.
|
|
"""
|
|
completed = sum(1 for i in instances if i["state"] == "completed")
|
|
due = max(len(instances), 0)
|
|
payload = {
|
|
"child_id": child_id,
|
|
"child_name": name,
|
|
"present": True,
|
|
"next_present": None,
|
|
"due_today": due,
|
|
"completed_today": completed,
|
|
"remaining_today": due - completed,
|
|
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
|
"instances": list(instances),
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def _payload(children) -> dict:
|
|
return {
|
|
"today_iso": _today_iso(),
|
|
"cycle_day": 0,
|
|
"timezone": "",
|
|
"children": list(children),
|
|
}
|
|
|
|
|
|
#: The seeded household: Ada and Bo share one daily quest, Cory has none.
|
|
ADA = 1
|
|
BO = 2
|
|
CORY = 3
|
|
|
|
|
|
def _seeded_payload() -> dict:
|
|
return _payload(
|
|
[
|
|
_child(ADA, "Ada", [_instance(7, ADA)]),
|
|
_child(BO, "Bo", [_instance(8, BO)]),
|
|
_child(CORY, "Cory"),
|
|
]
|
|
)
|
|
|
|
|
|
def _completed_payload() -> dict:
|
|
"""The same household after the API recorded Ada's completion."""
|
|
return _payload(
|
|
[
|
|
_child(
|
|
ADA,
|
|
"Ada",
|
|
[_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)],
|
|
),
|
|
_child(BO, "Bo", [_instance(8, BO)]),
|
|
_child(CORY, "Cory"),
|
|
]
|
|
)
|
|
|
|
|
|
async def _setup_entry(hass, make_entry, *payloads):
|
|
"""Set up the integration with a scripted snapshot client.
|
|
|
|
The client serves the payloads in order (repeating the last), so
|
|
setup's first refresh and any later explicit refresh each get the
|
|
scripted state — the same path production takes through the API.
|
|
"""
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
|
),
|
|
hass.registry,
|
|
)
|
|
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert coordinator.api_client is client
|
|
return entry, coordinator, client
|
|
|
|
|
|
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 test_every_active_child_gets_five_sensors_with_documented_identity(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry, coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload(), _completed_payload()
|
|
)
|
|
assert hass.config_entries.forwarded_platforms == [
|
|
"sensor",
|
|
"binary_sensor",
|
|
]
|
|
await coordinator.async_refresh()
|
|
|
|
# Three children x (five sensors + two binary sensors) + three
|
|
# household sensors, no duplicates.
|
|
assert len(hass.entities) == 24
|
|
expected = {
|
|
ADA: ("Ada", 1, 1, 0, 100),
|
|
BO: ("Bo", 1, 0, 1, 0),
|
|
CORY: ("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 is None
|
|
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, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload()
|
|
)
|
|
due_entity = _sensor(hass, CORY, "quests_due_today")
|
|
pct_entity = _sensor(hass, CORY, "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, _client = await _setup_entry(
|
|
hass, make_entry, _completed_payload()
|
|
)
|
|
|
|
ada_due = _sensor(hass, ADA, "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
|
|
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
|
|
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, "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_admin_equals_panel(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""D-009: the panel payload omits missed instances.
|
|
|
|
Since Feature 18 the snapshot comes from the API panel route,
|
|
which omits missed rows entirely (the admin surface for missed
|
|
quests is the PWA, reading the API directly). The integration
|
|
renders exactly what the route sent: a snapshot can never carry a
|
|
``missed`` view, ``admin_instances`` equals ``instances``, and the
|
|
precomputed counts arrive untouched — pinned here with an open
|
|
instance whose derived state reads missed (a past ``due_time``
|
|
with the counts already rolled up the way the route builds them).
|
|
"""
|
|
payload = _payload(
|
|
[
|
|
_child(
|
|
BO,
|
|
"Bo",
|
|
[
|
|
_instance(
|
|
8,
|
|
BO,
|
|
due_time="00:01",
|
|
overdue=True,
|
|
)
|
|
],
|
|
)
|
|
]
|
|
)
|
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
|
|
|
bo_due = _sensor(hass, BO, "quests_due_today")
|
|
attributes = bo_due.extra_state_attributes
|
|
assert attributes["instances"], "an open row renders in the panel payload"
|
|
assert attributes["admin_instances"] == attributes["instances"], (
|
|
"the API payload omits missed rows, so the admin payload "
|
|
"carries none either (the PWA is the admin surface now)"
|
|
)
|
|
# The counts arrive precomputed from the route's own build, which
|
|
# rolls the FULL day (any missed row included) into the numbers.
|
|
assert _sensor(hass, BO, "quests_completed_today").native_value == 0
|
|
assert _sensor(hass, BO, "quests_remaining_today").native_value == 1
|
|
|
|
|
|
@pytest.mark.parametrize("kind", (
|
|
"quests_due_today",
|
|
"quests_completed_today",
|
|
"quests_remaining_today",
|
|
"completion_pct_today",
|
|
))
|
|
async def test_no_sensor_state_exceeds_the_255_character_limit(
|
|
hass, make_entry, kind
|
|
) -> None:
|
|
entry, _coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload()
|
|
)
|
|
for child_id 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, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload(), _completed_payload()
|
|
)
|
|
completed_entity = _sensor(hass, ADA, "quests_completed_today")
|
|
remaining_entity = _sensor(hass, ADA, "quests_remaining_today")
|
|
pct_entity = _sensor(hass, ADA, "completion_pct_today")
|
|
due_entity = _sensor(hass, ADA, "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 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, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload()
|
|
)
|
|
await coordinator.async_refresh()
|
|
await coordinator.async_refresh()
|
|
assert len(hass.entities) == 24
|
|
|
|
|
|
async def test_setup_with_no_children_registers_only_household(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry, _coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _payload([])
|
|
)
|
|
# No children means no per-child entities; the three household
|
|
# rollups still exist (an empty household is a valid 0).
|
|
assert set(hass.entities) == {
|
|
"nestquest_household_quests_due_today",
|
|
"nestquest_household_quests_completed_today",
|
|
"nestquest_cycle_day",
|
|
}
|
|
assert hass.entities["nestquest_household_quests_due_today"].native_value == 0
|
|
|
|
|
|
async def test_unload_unloads_platforms(hass, make_entry) -> None:
|
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry)
|
|
assert await async_unload_entry(hass, entry) is True
|
|
assert hass.config_entries.unloaded_platforms == [
|
|
"sensor",
|
|
"binary_sensor",
|
|
]
|
|
|
|
|
|
async def test_rename_propagates_to_name_and_device_label(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A rename in the API's snapshot shows on the next refresh: the
|
|
name and device label re-derive from the snapshot while the
|
|
unique_id (and therefore history) is untouched."""
|
|
renamed = _payload(
|
|
[
|
|
_child(ADA, "Mirren", [_instance(7, ADA)]),
|
|
_child(BO, "Bo", [_instance(8, BO)]),
|
|
_child(CORY, "Cory"),
|
|
]
|
|
)
|
|
entry, coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload(), renamed
|
|
)
|
|
due_sensor = _sensor(hass, ADA, "quests_due_today")
|
|
original_unique_id = due_sensor.unique_id
|
|
assert due_sensor.name.startswith("NestQuest Ada ")
|
|
|
|
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
|
|
tear down the coordinator the live entities read: it returns False
|
|
and keeps the runtime record for a retry."""
|
|
entry, coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload()
|
|
)
|
|
|
|
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 entry.runtime_data.coordinator is coordinator
|
|
|
|
|
|
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, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload()
|
|
)
|
|
next_entity = _sensor(hass, ADA, "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
|
|
).instances[0].instance_id
|
|
assert attributes["child_id"] == ADA
|
|
|
|
|
|
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, _client = await _setup_entry(
|
|
hass, make_entry, _completed_payload()
|
|
)
|
|
assert _sensor(hass, ADA, "next_quest").native_value == "none"
|
|
assert _sensor(hass, CORY, "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."""
|
|
long_title = "A very long quest title. " * 30
|
|
payload = _payload(
|
|
[_child(ADA, "Ada", [_instance(7, ADA, title=long_title)])]
|
|
)
|
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
|
next_entity = _sensor(hass, ADA, "next_quest")
|
|
full_title = _snapshot_child(
|
|
entry.runtime_data.coordinator, ADA
|
|
).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
|
|
|
|
|
|
async def test_household_rollups_sum_the_active_children(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""Household due/completed equal the sum of the per-child sensors;
|
|
the cycle-day sensor carries the coordinator's answer (precomputed
|
|
by the API, which resolves the schedule engine server-side)."""
|
|
payload = _payload(
|
|
[
|
|
_child(
|
|
ADA,
|
|
"Ada",
|
|
[_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)],
|
|
),
|
|
_child(BO, "Bo", [_instance(8, BO)]),
|
|
_child(CORY, "Cory"),
|
|
]
|
|
)
|
|
payload["cycle_day"] = 4
|
|
entry, coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
|
|
|
household_due = hass.entities["nestquest_household_quests_due_today"]
|
|
household_completed = hass.entities[
|
|
"nestquest_household_quests_completed_today"
|
|
]
|
|
cycle_day_entity = hass.entities["nestquest_cycle_day"]
|
|
per_child_due = [
|
|
_sensor(hass, child_id, "quests_due_today").native_value
|
|
for child_id in (ADA, BO, CORY)
|
|
]
|
|
per_child_completed = [
|
|
_sensor(hass, child_id, "quests_completed_today").native_value
|
|
for child_id in (ADA, BO, CORY)
|
|
]
|
|
assert household_due.native_value == sum(per_child_due)
|
|
assert household_completed.native_value == sum(per_child_completed)
|
|
assert household_due.name == "NestQuest household quests due today"
|
|
assert household_completed.name == (
|
|
"NestQuest household quests completed today"
|
|
)
|
|
assert cycle_day_entity.name == "NestQuest cycle day"
|
|
assert cycle_day_entity.native_value == 4
|
|
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
|
|
|
|
|
|
async def test_household_due_sensor_publishes_the_ordered_child_roster(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""Feature 20: the rollup carries the ordered child roster beside
|
|
the unchanged per-child ``children`` counts, so the panel cards can
|
|
discover the board without a configured child_order. The order is
|
|
the coordinator snapshot's (the API's sort_order, then id)."""
|
|
entry, _coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload()
|
|
)
|
|
attributes = hass.entities[
|
|
"nestquest_household_quests_due_today"
|
|
].extra_state_attributes
|
|
assert attributes["child_roster"] == [
|
|
{"child_id": ADA, "name": "Ada", "slug": "ada"},
|
|
{"child_id": BO, "name": "Bo", "slug": "bo"},
|
|
{"child_id": CORY, "name": "Cory", "slug": "cory"},
|
|
]
|
|
# The pre-existing per-child counts attribute is untouched.
|
|
assert attributes["children"] == {
|
|
str(ADA): 1,
|
|
str(BO): 1,
|
|
str(CORY): 0,
|
|
}
|
|
|
|
|
|
async def test_household_due_sensor_publishes_the_stored_zone(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The rollup carries the snapshot's stored application zone verbatim
|
|
(the panel cards format their date and clock in it); ``""`` — no zone
|
|
stored — passes through unchanged for the cards' documented fallback."""
|
|
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
|
|
entry, coordinator, _client = await _setup_entry(
|
|
hass, make_entry, zoned, _seeded_payload()
|
|
)
|
|
rollup = hass.entities["nestquest_household_quests_due_today"]
|
|
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
|
|
await coordinator.async_refresh()
|
|
assert rollup.extra_state_attributes["timezone"] == ""
|
|
|
|
|
|
async def test_household_roster_tracks_the_snapshot_on_refresh(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The roster re-derives from every snapshot: a reorder and a
|
|
rename in the API payload show up (with the re-derived slug) on the
|
|
next refresh, like the sensor names already do."""
|
|
reordered = _payload(
|
|
[
|
|
_child(CORY, "Cory"),
|
|
_child(ADA, "Ada", [_instance(7, ADA)]),
|
|
_child(BO, "Mirren", [_instance(8, BO)]),
|
|
]
|
|
)
|
|
entry, coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _seeded_payload(), reordered
|
|
)
|
|
roster_entity = hass.entities["nestquest_household_quests_due_today"]
|
|
assert [entry["slug"] for entry in roster_entity.extra_state_attributes["child_roster"]] == [
|
|
"ada",
|
|
"bo",
|
|
"cory",
|
|
]
|
|
|
|
await coordinator.async_refresh()
|
|
|
|
assert roster_entity.extra_state_attributes["child_roster"] == [
|
|
{"child_id": CORY, "name": "Cory", "slug": "cory"},
|
|
{"child_id": ADA, "name": "Ada", "slug": "ada"},
|
|
{"child_id": BO, "name": "Mirren", "slug": "mirren"},
|
|
]
|
|
|
|
|
|
async def test_duplicate_display_names_get_registry_suffix_slugs(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""Two children may share a nickname (the children layer allows
|
|
it); the roster's slugs then mirror the registry's collision
|
|
suffixing (_2, _3, ...) in snapshot order, so both plates resolve."""
|
|
payload = _payload(
|
|
[
|
|
_child(ADA, "Ada", [_instance(7, ADA)]),
|
|
_child(BO, "Ada"),
|
|
]
|
|
)
|
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
|
assert hass.entities[
|
|
"nestquest_household_quests_due_today"
|
|
].extra_state_attributes["child_roster"] == [
|
|
{"child_id": ADA, "name": "Ada", "slug": "ada"},
|
|
{"child_id": BO, "name": "Ada", "slug": "ada_2"},
|
|
]
|
|
|
|
|
|
async def test_empty_household_publishes_an_empty_roster(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""An empty household is a valid 0: the roster is an empty list,
|
|
and the card's discovery of it renders the not-set-up notice."""
|
|
entry, _coordinator, _client = await _setup_entry(
|
|
hass, make_entry, _payload([])
|
|
)
|
|
attributes = hass.entities[
|
|
"nestquest_household_quests_due_today"
|
|
].extra_state_attributes
|
|
assert attributes["child_roster"] == []
|
|
assert attributes["children"] == {}
|
|
|
|
|
|
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
|
|
"""The roster slugs match homeassistant.util.slugify — the real
|
|
slugifier HA derives entity ids from, composed with the entity-id
|
|
length cap the entity registry applies to ``sensor.`` ids."""
|
|
assert _slugify("Ada") == "ada"
|
|
assert _slugify("Mary Jane") == "mary_jane"
|
|
assert _slugify("Mary-Jane") == "mary_jane"
|
|
assert _slugify("O'Brien") == "o_brien"
|
|
assert _slugify("Émilie") == "emilie"
|
|
assert _slugify("Åsa") == "asa"
|
|
assert _slugify(" Bo ") == "bo"
|
|
# HA's wrapper answers "unknown" when a name slugs to nothing.
|
|
assert _slugify(" ") == "unknown"
|
|
assert _slugify("?!?") == "unknown"
|
|
# The registry caps the entity_id at 64 characters, so the slugged
|
|
# object id alone is capped at 64 - len("sensor.").
|
|
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
|
|
|
|
|
|
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
|
|
"""HA's slugifier transliterates every script through unidecode,
|
|
so a non-Latin child name slugs to its romanization — never to the
|
|
``unknown`` sentinel — and the roster publishes the same slug HA's
|
|
entity-id derivation produces for that name."""
|
|
assert _slugify("Дана") == "dana"
|
|
assert _slugify("京子") == "jing_zi"
|
|
assert _slugify("Zoë") == "zoe"
|
|
|
|
|
|
async def test_non_latin_child_names_resolve_auto_discovery(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A non-Latin child name slugs through the real slugifier, so the
|
|
roster slug IS the object id of the per-child sensor's entity_id
|
|
and the cards' auto-discovery resolves that child.
|
|
|
|
HA derives the per-child entity id from the entity NAME ("NestQuest
|
|
京子 quests due today") with the same slugifier the roster uses, so
|
|
the two must agree exactly; the reviewed mirror dropped non-Latin
|
|
characters (slugging "京子" to "unknown"), so the cards built
|
|
"sensor.nestquest_unknown_…" ids that no entity had."""
|
|
payload = _payload(
|
|
[
|
|
_child(ADA, "Дана", [_instance(7, ADA)]),
|
|
_child(BO, "京子", [_instance(8, BO)]),
|
|
]
|
|
)
|
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
|
roster = hass.entities[
|
|
"nestquest_household_quests_due_today"
|
|
].extra_state_attributes["child_roster"]
|
|
assert roster == [
|
|
{"child_id": ADA, "name": "Дана", "slug": "dana"},
|
|
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
|
|
]
|
|
for roster_entry in roster:
|
|
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
|
|
assert due_entity.name == (
|
|
f"NestQuest {roster_entry['name']} quests due today"
|
|
)
|
|
# The entity id HA's registry derives for this entity (from the
|
|
# same slugifier) embeds exactly the slug the roster publishes:
|
|
# the card builds sensor.nestquest_<slug>_quests_due_today.
|
|
assert slugify(due_entity.name) == (
|
|
f"nestquest_{roster_entry['slug']}_quests_due_today"
|
|
)
|