Remove the integration's database, migrations, DAOs and business modules (#172)

This commit was merged in pull request #172.
This commit is contained in:
2026-09-23 09:11:29 -04:00
parent 2c4113ed61
commit a3f2adcf43
64 changed files with 1394 additions and 5635 deletions
+230 -211
View File
@@ -1,75 +1,132 @@
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2)."""
"""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 conftest import wire_entry_to_registry
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.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_iso() -> str:
return datetime.date.today().isoformat()
def _today() -> datetime.date:
return datetime.date.today()
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
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
def _child(child_id: int, name: str, instances=(), **overrides) -> dict:
"""One child in the API route's payload shape, counts precomputed.
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.
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.
"""
entry = wire_entry_to_registry(make_entry(), hass.registry)
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,
"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
database = entry.runtime_data.database
ada, bo, cory = await _seed_children(database)
await coordinator.async_refresh()
return entry, coordinator, (ada, bo, cory)
assert coordinator.api_client is client
return entry, coordinator, client
def _snapshot_child(coordinator, child_id):
@@ -85,47 +142,31 @@ def _sensor(hass, child_id, 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_five_sensors_with_documented_identity(
hass, make_entry
) -> None:
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), _completed_payload()
)
database = entry.runtime_data.database
assert hass.config_entries.forwarded_platforms == [
"sensor",
"binary_sensor",
]
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
# Two quest children plus the zero-quest child: three children x
# (five sensors + two binary sensors) + three household sensors,
# no duplicates.
# Three children x (five sensors + two binary sensors) + three
# household sensors, no duplicates.
assert len(hass.entities) == 24
expected = {
ada.id: ("Ada", 1, 1, 0, 100),
bo.id: ("Bo", 1, 0, 1, 0),
cory.id: ("Cory", 0, 0, 0, 100),
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")
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"
@@ -151,11 +192,11 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
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
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
)
due_entity = _sensor(hass, cory.id, "quests_due_today")
pct_entity = _sensor(hass, cory.id, "completion_pct_today")
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"
@@ -168,14 +209,11 @@ async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
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
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _completed_payload()
)
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")
ada_due = _sensor(hass, ADA, "quests_due_today")
attributes = ada_due.extra_state_attributes
assert set(attributes) == {
"instances",
@@ -184,7 +222,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
"child_name",
"present",
}
assert attributes["child_id"] == ada.id
assert attributes["child_id"] == ADA
assert attributes["child_name"] == "Ada"
assert attributes["present"] is True
[instance] = attributes["instances"]
@@ -201,7 +239,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
"completed_at",
"on_time",
}
assert instance["child_id"] == ada.id
assert instance["child_id"] == ADA
assert instance["title"] == "Brush teeth"
assert instance["window"] == "morning"
assert instance["state"] == "completed", (
@@ -214,7 +252,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
"with nothing missed, admin and panel payloads are identical"
)
bo_due = _sensor(hass, bo.id, "quests_due_today")
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
@@ -222,64 +260,64 @@ async def test_due_today_attributes_carry_the_panel_payload(
async def test_due_today_payload_omits_missed_admin_equals_panel(
hass, make_entry, monkeypatch
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 entity payload
therefore cannot carry missed rows any more: ``admin_instances``
equals ``instances`` — open and completed rows only. A missed
instance is still derived locally (``due_date`` before the
snapshot's "today"); the pinned-FUTURE clock at the
``derive_state`` boundary makes today's open instances read
missed through the same derivation rule, and this test pins the
change: neither payload carries them, and the count rollups
(precomputed server-side from the full day) are unaffected here
because the API counts arrive per the route's own build.
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).
"""
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
payload = _payload(
[
_child(
BO,
"Bo",
[
_instance(
8,
BO,
due_time="00:01",
overdue=True,
)
],
)
]
)
import custom_components.nestquest.core.snapshot as snapshot_module
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
tomorrow = _today() + datetime.timedelta(days=1)
real_derive_state = snapshot_module.derive_state
def _derive_against_pinned_future(instance, latest, today):
return real_derive_state(instance, latest, tomorrow)
monkeypatch.setattr(
snapshot_module, "derive_state", _derive_against_pinned_future
)
await coordinator.async_refresh()
bo_due = _sensor(hass, bo.id, "quests_due_today")
bo_due = _sensor(hass, BO, "quests_due_today")
attributes = bo_due.extra_state_attributes
assert attributes["instances"] == [], (
"missed instances are omitted from the panel payload"
)
assert attributes["admin_instances"] == [], (
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)"
)
# Counts still roll the missed instance up as not-completed: the
# counts arrive precomputed from the route's own build, which rolls
# the FULL day (the missed row included) into the numbers.
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
# 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", SENSOR_KINDS)
@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, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
)
for child in (ada, bo, cory):
entity = _sensor(hass, child.id, kind)
for child_id in (ADA, BO, CORY):
entity = _sensor(hass, child_id, kind)
state = entity.native_value
assert len(str(state)) <= MAX_STATE_LENGTH
@@ -295,14 +333,13 @@ def test_state_within_limit_refuses_overlong_strings() -> None:
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
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), _completed_payload()
)
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")
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
@@ -318,7 +355,6 @@ async def test_sensors_update_on_refresh_after_a_completion(
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
@@ -330,8 +366,8 @@ async def test_sensors_update_on_refresh_after_a_completion(
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
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
)
await coordinator.async_refresh()
await coordinator.async_refresh()
@@ -341,8 +377,9 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
async def test_setup_with_no_children_registers_only_household(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
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) == {
@@ -354,8 +391,7 @@ async def test_setup_with_no_children_registers_only_household(
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
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",
@@ -366,20 +402,23 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None:
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
"""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"),
]
)
database = entry.runtime_data.database
due_sensor = _sensor(hass, ada.id, "quests_due_today")
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 edit_child(database, ada.id, display_name="Mirren")
await coordinator.async_refresh()
assert due_sensor.name.startswith("NestQuest Mirren ")
@@ -391,12 +430,11 @@ 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
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()
)
database = entry.runtime_data.database
async def _refuse_unload(entry_arg, platforms):
return False
@@ -404,26 +442,27 @@ 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 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, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
)
next_entity = _sensor(hass, ada.id, "next_quest")
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.id
coordinator, ADA
).instances[0].instance_id
assert attributes["child_id"] == ada.id
assert attributes["child_id"] == ADA
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
@@ -431,14 +470,11 @@ async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
) -> 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
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _completed_payload()
)
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"
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(
@@ -446,23 +482,15 @@ async def test_next_quest_state_truncates_over_the_limit(
) -> 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
long_title = "A very long quest title. " * 30
payload = _payload(
[_child(ADA, "Ada", [_instance(7, ADA, title=long_title)])]
)
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
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
@@ -473,19 +501,21 @@ 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."""
import datetime as _dt
from custom_components.nestquest.dao_presence import (
PresenceSchedulesDao,
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"),
]
)
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()
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[
@@ -493,12 +523,12 @@ async def test_household_rollups_sum_the_active_children(
]
cycle_day_entity = hass.entities["nestquest_cycle_day"]
per_child_due = [
_sensor(hass, child.id, "quests_due_today").native_value
for child in (ada, bo, cory)
_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 in (ada, bo, cory)
_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)
@@ -507,16 +537,5 @@ async def test_household_rollups_sum_the_active_children(
"NestQuest household quests completed today"
)
assert cycle_day_entity.name == "NestQuest cycle day"
assert cycle_day_entity.native_value == 0, "no schedule anywhere"
# A schedule on the FIRST active child drives cycle day.
today = _dt.date.today()
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(),
"0,1,2,3,4,5,6|",
)
await coordinator.async_refresh()
assert cycle_day_entity.native_value == 4
assert cycle_day_entity.extra_state_attributes["today"] == (
today.isoformat()
)
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()