Remove the integration's database, migrations, DAOs and business modules (#172)
This commit was merged in pull request #172.
This commit is contained in:
+213
-153
@@ -2,74 +2,156 @@
|
||||
|
||||
One fixture drives every assertion the done-condition names: correct
|
||||
values for every sensor and binary sensor (including the zero-quest
|
||||
child and the 255-character state limit), the three events firing
|
||||
exactly once with schema-valid payloads, the instances payload
|
||||
omitting missed instances while admin_instances includes them, and no
|
||||
entities for an inactive child. The per-task test files cover each
|
||||
behavior in depth; this file is the single pass that proves them all
|
||||
against ONE fixture, so a regression in any contract fails here too.
|
||||
child and the 255-character state limit), the completed/uncompleted/
|
||||
day-complete events re-fired exactly once from the API's SSE frames,
|
||||
the panel payload omitting missed instances while admin_instances
|
||||
equals it, and no entities for a child the API does not report. The
|
||||
per-task test files cover each behavior in depth; this file is the
|
||||
single pass that proves them all against ONE fixture, so a regression
|
||||
in any contract fails here too.
|
||||
|
||||
Since the DB removal the household state and every transition come
|
||||
from the API service, so the fixture scripts the panel route's
|
||||
payloads and completion surface (:class:`conftest.ScriptedPanelClient`)
|
||||
and delivers the transition frames through the SSE subscription — the
|
||||
production event path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from conftest import refire_api_transitions, wire_entry_to_registry
|
||||
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.completion import derive_state, uncomplete_instance
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_quest_uncompleted_events,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY = 3
|
||||
|
||||
_OCCURRED = "2026-09-23T07:55:00+00:00"
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, window="morning", state="open"):
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": window,
|
||||
"due_time": "08:00" if window == "morning" else "19:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else _OCCURRED,
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _child(child_id: int, name: str, instances=()):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
def _payload(children, cycle_day=0) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": cycle_day,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
def _completed_frame(instance_id: int, child_id: int, child_name: str, window: str):
|
||||
"""The completed-event frame the API service publishes for one
|
||||
panel completion (the day-complete rule is the API's; its payload
|
||||
shape is pinned by tests/test_api_sse.py on the production
|
||||
plane)."""
|
||||
return (
|
||||
EVENT_QUEST_COMPLETED,
|
||||
{
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"instance_id": instance_id,
|
||||
"quest_title": "Brush teeth",
|
||||
"window": window,
|
||||
"due_date": "2026-09-23",
|
||||
"due_time": "08:00" if window == "morning" else "19:00",
|
||||
"occurred_at": _OCCURRED,
|
||||
"was_on_time": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _day_complete_frame(child_id: int, child_name: str):
|
||||
"""The day-complete frame the API publishes once a child's day
|
||||
clears (after the LAST completion of the day)."""
|
||||
return (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
{
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 2,
|
||||
"quests_completed": 2,
|
||||
"occurred_at": _OCCURRED,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _fixture(hass, make_entry):
|
||||
"""Two quest children, one zero-quest child, one inactive child."""
|
||||
"""Two quest children, one zero-quest child; the inactive child is
|
||||
simply ABSENT from the API's snapshot (the API only reports active
|
||||
children, so the integration can never create its entities)."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
cory = await create_child(database, "Cory")
|
||||
ghost = await create_child(database, "Ghost")
|
||||
await set_child_active(database, ghost.id, False)
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning", "evening"],
|
||||
hass.registry,
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, (ada, bo, cory, ghost)
|
||||
client = ScriptedPanelClient(
|
||||
_payload(
|
||||
[
|
||||
_child(ADA, "Ada", [_instance(7, ADA), _instance(8, ADA, window="evening")]),
|
||||
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
[
|
||||
_instance(7, ADA, state="completed"),
|
||||
_instance(8, ADA, window="evening", state="completed"),
|
||||
],
|
||||
),
|
||||
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
)
|
||||
set_coordinator_client(entry, client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, client
|
||||
|
||||
|
||||
def _sensor(hass, child_id, kind):
|
||||
@@ -77,11 +159,11 @@ def _sensor(hass, child_id, kind):
|
||||
|
||||
|
||||
async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
|
||||
entry, client = await _fixture(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
# --- Every active child has the full entity set; the inactive
|
||||
# child has none.
|
||||
# --- Every reported child has the full entity set; the child the
|
||||
# API does not report has none.
|
||||
kinds = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
@@ -91,36 +173,37 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
"all_done",
|
||||
"present_today",
|
||||
)
|
||||
for child in (ada, bo, cory):
|
||||
ghost_id = 99
|
||||
for child in (ADA, BO, CORY):
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
|
||||
assert f"{DOMAIN}_child_{child}_{kind}" in hass.entities, kind
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
|
||||
"inactive children must have no entities"
|
||||
assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, (
|
||||
"children absent from the API snapshot must have no entities"
|
||||
)
|
||||
|
||||
# --- Correct values against the fixture: Ada and Bo owe two
|
||||
# quests (morning + evening), Cory owes none and is still 100%.
|
||||
for child in (ada, bo):
|
||||
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
|
||||
for child in (ADA, BO):
|
||||
assert _sensor(hass, child, "quests_due_today").native_value == 2
|
||||
assert (
|
||||
_sensor(hass, child.id, "quests_completed_today").native_value == 0
|
||||
_sensor(hass, child, "quests_completed_today").native_value == 0
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
|
||||
_sensor(hass, child, "quests_remaining_today").native_value == 2
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child.id, "completion_pct_today").native_value == 0
|
||||
_sensor(hass, child, "completion_pct_today").native_value == 0
|
||||
)
|
||||
assert _sensor(hass, child.id, "next_quest").native_value == (
|
||||
assert _sensor(hass, child, "next_quest").native_value == (
|
||||
"Brush teeth"
|
||||
)
|
||||
assert _sensor(hass, child.id, "all_done").is_on is False
|
||||
assert _sensor(hass, child.id, "present_today").is_on is True
|
||||
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, cory.id, "all_done").is_on is False
|
||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
||||
assert _sensor(hass, child, "all_done").is_on is False
|
||||
assert _sensor(hass, child, "present_today").is_on is True
|
||||
assert _sensor(hass, CORY, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, CORY, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, CORY, "all_done").is_on is False
|
||||
assert _sensor(hass, CORY, "next_quest").native_value == "none"
|
||||
|
||||
# --- Household rollups equal the per-child sums.
|
||||
assert hass.entities[
|
||||
@@ -140,27 +223,31 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
), unique_id
|
||||
|
||||
# --- Completing both of Ada's quests fires the events exactly
|
||||
# once each and flips her entities immediately.
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
# once each and flips her entities immediately. The transition
|
||||
# events arrive over the API's SSE stream (the subscription
|
||||
# re-fires them), never from the service handler.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
# The transition events arrive over the API's SSE stream (the
|
||||
# subscription re-fires them), never from the service handler.
|
||||
await refire_api_transitions(
|
||||
hass, entry, entry.runtime_data.coordinator.api_client
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 8, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
client.published_frames.extend(
|
||||
[
|
||||
_completed_frame(7, ADA, "Ada", "morning"),
|
||||
_completed_frame(8, ADA, "Ada", "evening"),
|
||||
# The API publishes the day-complete frame only when the
|
||||
# day clears — after the LAST completion of the day.
|
||||
_day_complete_frame(ADA, "Ada"),
|
||||
]
|
||||
)
|
||||
from conftest import refire_api_transitions
|
||||
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
@@ -169,71 +256,53 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert isinstance(payload["instance_id"], int)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is True
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
|
||||
# The next scripted snapshot reflects both completions.
|
||||
await coordinator.async_refresh()
|
||||
assert _sensor(hass, ADA, "all_done").is_on is True
|
||||
assert _sensor(hass, ADA, "quests_remaining_today").native_value == 0
|
||||
assert (
|
||||
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
|
||||
_sensor(hass, ADA, "completion_pct_today").native_value == 100
|
||||
)
|
||||
|
||||
# --- Reversing one quest fires the uncompleted event exactly once
|
||||
# and restores the owed counts. The HA ``uncomplete_quest``
|
||||
# service is gone (the API service's admin route is the reversal
|
||||
# path): drive the core completion layer it calls, publish the
|
||||
# uncompleted transition the way the API does, and deliver it
|
||||
# through the SSE subscription.
|
||||
reversal = await uncomplete_instance(
|
||||
entry.runtime_data.database,
|
||||
instances[0].id,
|
||||
actor_source="user",
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
assert reversal.appended is True
|
||||
client = coordinator.api_client
|
||||
client.published_frames.extend(
|
||||
await build_quest_uncompleted_events(
|
||||
entry.runtime_data.database, instances[0].id
|
||||
# --- Reversing one quest fires the uncompleted event exactly once.
|
||||
# The HA ``uncomplete_quest`` service is gone (the API service's
|
||||
# admin route is the reversal path), so the reversal is the API's:
|
||||
# it publishes the uncompleted frame and the integration re-fires
|
||||
# it; the coordinator's next poll picks the restored counts up.
|
||||
client.published_frames.append(
|
||||
(
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
{
|
||||
"child_id": ADA,
|
||||
"child_name": "Ada",
|
||||
"instance_id": 7,
|
||||
"quest_title": "Brush teeth",
|
||||
"window": "morning",
|
||||
"due_date": "2026-09-23",
|
||||
"due_time": "08:00",
|
||||
"occurred_at": _OCCURRED,
|
||||
},
|
||||
)
|
||||
)
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||
# The reversal no longer carries an immediate local refresh: the
|
||||
# coordinator's next poll picks the restored counts up.
|
||||
|
||||
# --- The panel payload omits missed instances (D-009) — the API
|
||||
# panel route omits missed rows ENTIRELY, so admin_instances
|
||||
# cannot carry them either (the admin surface for missed quests is
|
||||
# the PWA, reading the API directly). A snapshot built from the
|
||||
# payload can only ever hold open and completed rows: pin that
|
||||
# the payload round-trips verbatim.
|
||||
await coordinator.async_refresh()
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
||||
|
||||
# --- The instances payload omits missed instances (D-009) — and
|
||||
# since Feature 18 the snapshot comes from the API panel route,
|
||||
# which omits missed rows ENTIRELY, so admin_instances cannot
|
||||
# carry them either (the admin surface for missed quests is the
|
||||
# PWA, reading the API directly): pin the derive clock forward in
|
||||
# the TEST ONLY so today's open instances read missed.
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
|
||||
real_derive = snapshot_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
snapshot_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
snapshot_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
await coordinator.async_refresh()
|
||||
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [], "panel payload omits missed"
|
||||
assert attributes["admin_instances"] == [], (
|
||||
attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [
|
||||
dict(_instance(9, BO)),
|
||||
dict(_instance(10, BO, window="evening")),
|
||||
], "the panel payload round-trips the API's open rows verbatim"
|
||||
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)"
|
||||
)
|
||||
coordinator._async_update_data = original_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
async def test_payload_schema_matches_the_documented_contract(
|
||||
@@ -241,25 +310,16 @@ async def test_payload_schema_matches_the_documented_contract(
|
||||
) -> None:
|
||||
"""Every fired payload carries the documented fields with the
|
||||
documented types."""
|
||||
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
)
|
||||
entry, client = await _fixture(hass, make_entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
# The completed event arrives over the API's SSE stream.
|
||||
await refire_api_transitions(
|
||||
hass, entry, entry.runtime_data.coordinator.api_client
|
||||
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
client.published_frames.append(_completed_frame(7, ADA, "Ada", "morning"))
|
||||
from conftest import refire_api_transitions
|
||||
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||
for key, check in (
|
||||
("child_id", lambda v: isinstance(v, int)),
|
||||
|
||||
Reference in New Issue
Block a user