Add the shared NestQuest data update coordinator #105
@@ -20,11 +20,14 @@ from .const import (
|
|||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
)
|
)
|
||||||
|
from .coordinator import NestQuestCoordinator
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
@@ -238,6 +241,7 @@ class NestQuestRuntimeData:
|
|||||||
database: NestQuestDatabase
|
database: NestQuestDatabase
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
remove_time_change_listener: Callable[[], Any]
|
remove_time_change_listener: Callable[[], Any]
|
||||||
|
coordinator: Any = None
|
||||||
|
|
||||||
|
|
||||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||||
@@ -456,6 +460,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||||
remove_update_listener: Callable[[], Any] | None = None
|
remove_update_listener: Callable[[], Any] | None = None
|
||||||
remove_time_change_listener: Callable[[], Any] | None = None
|
remove_time_change_listener: Callable[[], Any] | None = None
|
||||||
|
coordinator: Any = None
|
||||||
try:
|
try:
|
||||||
# Seed the admin allowlist on first setup so the owner is never
|
# Seed the admin allowlist on first setup so the owner is never
|
||||||
# locked out: an empty database takes the persisted admin copy
|
# locked out: an empty database takes the persisted admin copy
|
||||||
@@ -493,6 +498,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
)
|
)
|
||||||
_register_services(hass)
|
_register_services(hass)
|
||||||
|
# The shared Feature 10 coordinator: one refresh cycle every
|
||||||
|
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
||||||
|
# five minutes). Created AFTER services so a failure below
|
||||||
|
# unwinds them through the except path's listener handling.
|
||||||
|
coordinator = NestQuestCoordinator(
|
||||||
|
hass,
|
||||||
|
entry_id=entry.entry_id,
|
||||||
|
database=database,
|
||||||
|
update_interval_seconds=entry.options.get(
|
||||||
|
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
# listeners already registered against that (now-closed) database:
|
# listeners already registered against that (now-closed) database:
|
||||||
@@ -511,6 +529,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await result
|
await result
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
|
# A coordinator created before the failure holds entity
|
||||||
|
# listeners pointed at the database being closed below —
|
||||||
|
# shut it down FIRST so no scheduled refresh can ever run
|
||||||
|
# against a closed connection.
|
||||||
|
if coordinator is not None:
|
||||||
|
try:
|
||||||
|
await coordinator.async_shutdown()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
await database.close()
|
await database.close()
|
||||||
raise
|
raise
|
||||||
assert remove_update_listener is not None
|
assert remove_update_listener is not None
|
||||||
@@ -521,6 +548,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database=database,
|
database=database,
|
||||||
remove_update_listener=remove_update_listener,
|
remove_update_listener=remove_update_listener,
|
||||||
remove_time_change_listener=remove_time_change_listener,
|
remove_time_change_listener=remove_time_change_listener,
|
||||||
|
coordinator=coordinator,
|
||||||
)
|
)
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
@@ -565,6 +593,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None:
|
if database is not None:
|
||||||
await database.close()
|
await database.close()
|
||||||
|
# Tear the shared coordinator down after the DB close: no
|
||||||
|
# listener may outlive the connection it reads.
|
||||||
|
coordinator = getattr(runtime_data, "coordinator", None)
|
||||||
|
if coordinator is not None:
|
||||||
|
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||||
|
if shutdown is not None:
|
||||||
|
await shutdown()
|
||||||
# Domain-global services are torn down only once the FINAL entry
|
# Domain-global services are torn down only once the FINAL entry
|
||||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||||
# entry is still loaded.
|
# entry is still loaded.
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ def _derived_state(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_state(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
latest: CompletionEventRecord | None,
|
||||||
|
today: datetime.date,
|
||||||
|
) -> str:
|
||||||
|
"""Public state derivation for the Feature 10 coordinator.
|
||||||
|
|
||||||
|
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||||
|
``missed`` from the latest event plus the due date (``missed`` is
|
||||||
|
derived, never an event_type). ``today`` is the caller-resolved
|
||||||
|
HA-local date.
|
||||||
|
"""
|
||||||
|
return _derived_state(instance, latest, today)
|
||||||
|
|
||||||
|
|
||||||
def _was_on_time(
|
def _was_on_time(
|
||||||
instance: QuestInstanceRecord,
|
instance: QuestInstanceRecord,
|
||||||
now: datetime.datetime | None,
|
now: datetime.datetime | None,
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
|||||||
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||||
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||||
|
|
||||||
|
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
|
||||||
|
# entity reads one snapshot per refresh; completions push an immediate
|
||||||
|
# refresh so this poll is only the fallback cadence.
|
||||||
|
CONF_UPDATE_INTERVAL = "update_interval"
|
||||||
|
DEFAULT_UPDATE_INTERVAL = 300
|
||||||
|
MIN_UPDATE_INTERVAL = 30
|
||||||
|
|
||||||
# Quest windows (D-008): a definition may declare several day windows;
|
# Quest windows (D-008): a definition may declare several day windows;
|
||||||
# each produces its own instance per day and groups the panel's Quest
|
# each produces its own instance per day and groups the panel's Quest
|
||||||
# Log into three columns. The clock ranges are stored here so they stay
|
# Log into three columns. The clock ranges are stored here so they stay
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
||||||
|
|
||||||
|
ONE refresh pass builds the whole entity snapshot: active children,
|
||||||
|
today's instances with their derived states (open / done / missed via
|
||||||
|
:func:`~.completion.derive_state`), each child's presence today, and
|
||||||
|
per-child rollup counts. Every Feature 10 entity reads
|
||||||
|
``coordinator.data`` — no entity queries the database itself, so one
|
||||||
|
refresh updates the whole board coherently.
|
||||||
|
|
||||||
|
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||||
|
host clock and never UTC, matching the materialization and completion
|
||||||
|
layers.
|
||||||
|
|
||||||
|
Presence is resolved through the SAME engine the materializer uses
|
||||||
|
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||||
|
with the public DAO methods and the engine's anchor-date arithmetic
|
||||||
|
answers ``is this child at this house today`` (D-004 — never ISO week
|
||||||
|
parity).
|
||||||
|
|
||||||
|
``cycle_day`` reports where today falls in the custody cycle of the
|
||||||
|
first active child (display order) that HAS a schedule — 1-based day
|
||||||
|
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
||||||
|
anchor date. ``0`` means no active child has a schedule at all, so
|
||||||
|
the admin header degrades to a harmless zero instead of guessing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
from .completion import derive_state
|
||||||
|
from .const import (
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
MIN_UPDATE_INTERVAL,
|
||||||
|
)
|
||||||
|
from .dao_children import ChildrenDao
|
||||||
|
from .dao_instances import (
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from .dao_rules import QuestDefinitionsDao
|
||||||
|
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .presence import (
|
||||||
|
PresenceEngine,
|
||||||
|
PresenceOverride,
|
||||||
|
PresenceSchedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuestInstanceView:
|
||||||
|
"""One instance plus everything the entities render from it."""
|
||||||
|
|
||||||
|
instance_id: int
|
||||||
|
definition_id: int
|
||||||
|
child_id: int
|
||||||
|
title: str
|
||||||
|
icon: str | None
|
||||||
|
window: str
|
||||||
|
due_date: str
|
||||||
|
due_time: str | None
|
||||||
|
state: str # open | done | missed
|
||||||
|
overdue: bool
|
||||||
|
completed_at: str | None
|
||||||
|
was_on_time: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChildDaySnapshot:
|
||||||
|
"""One active child's day: instances, presence, and rollup counts."""
|
||||||
|
|
||||||
|
child_id: int
|
||||||
|
child_name: str
|
||||||
|
instances: tuple[QuestInstanceView, ...]
|
||||||
|
present: bool
|
||||||
|
due_today: int
|
||||||
|
completed_today: int
|
||||||
|
remaining_today: int
|
||||||
|
completion_pct: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NestQuestSnapshot:
|
||||||
|
"""The coordinator's whole-entity data payload."""
|
||||||
|
|
||||||
|
today_iso: str
|
||||||
|
children: tuple[ChildDaySnapshot, ...]
|
||||||
|
cycle_day: int
|
||||||
|
|
||||||
|
|
||||||
|
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Shared refresh cycle for every NestQuest entity."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
|
entry_id: str,
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
update_interval_seconds: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
interval = update_interval_seconds
|
||||||
|
if (
|
||||||
|
not isinstance(interval, int)
|
||||||
|
or isinstance(interval, bool)
|
||||||
|
or interval < MIN_UPDATE_INTERVAL
|
||||||
|
):
|
||||||
|
interval = DEFAULT_UPDATE_INTERVAL
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=datetime.timedelta(seconds=interval),
|
||||||
|
)
|
||||||
|
self.entry_id = entry_id
|
||||||
|
self.database = database
|
||||||
|
|
||||||
|
def _local_now(self) -> datetime.datetime:
|
||||||
|
time_zone = ZoneInfo(self.hass.config.time_zone)
|
||||||
|
return datetime.datetime.now(time_zone)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||||
|
now = self._local_now()
|
||||||
|
today = now.date()
|
||||||
|
today_iso = today.isoformat()
|
||||||
|
|
||||||
|
children = await ChildrenDao(self.database).list_active()
|
||||||
|
|
||||||
|
schedules: dict[int, PresenceSchedule] = {}
|
||||||
|
overrides: dict[int, list[PresenceOverride]] = {}
|
||||||
|
for child in children:
|
||||||
|
schedule = await PresenceSchedulesDao(self.database).get_by_child(
|
||||||
|
child.id
|
||||||
|
)
|
||||||
|
if schedule is not None:
|
||||||
|
schedules[child.id] = PresenceSchedule.decode(
|
||||||
|
child.id, schedule.anchor_date, schedule.pattern
|
||||||
|
)
|
||||||
|
todays = await PresenceOverridesDao(
|
||||||
|
self.database
|
||||||
|
).list_by_child_and_range(child.id, today_iso, today_iso)
|
||||||
|
if todays:
|
||||||
|
# The engine validates MODEL objects, not DAO records;
|
||||||
|
# each record converts losslessly (the schema guarantees
|
||||||
|
# end >= start and a real is_present).
|
||||||
|
overrides[child.id] = [
|
||||||
|
PresenceOverride(
|
||||||
|
record.child_id,
|
||||||
|
record.start_date,
|
||||||
|
record.end_date,
|
||||||
|
record.is_present,
|
||||||
|
record.note,
|
||||||
|
)
|
||||||
|
for record in todays
|
||||||
|
]
|
||||||
|
engine = PresenceEngine(schedules, overrides)
|
||||||
|
|
||||||
|
instances_dao = QuestInstancesDao(self.database)
|
||||||
|
events_dao = CompletionEventsDao(self.database)
|
||||||
|
definitions_dao = QuestDefinitionsDao(self.database)
|
||||||
|
|
||||||
|
snapshots: list[ChildDaySnapshot] = []
|
||||||
|
for child in children:
|
||||||
|
instances = await instances_dao.list_by_date_range(
|
||||||
|
child.id, today_iso, today_iso
|
||||||
|
)
|
||||||
|
views: list[QuestInstanceView] = []
|
||||||
|
for instance in instances:
|
||||||
|
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||||
|
definition = await definitions_dao.get(instance.definition_id)
|
||||||
|
state = derive_state(instance, latest, today)
|
||||||
|
overdue = False
|
||||||
|
if state == "open" and instance.due_date == today_iso:
|
||||||
|
if instance.due_time is not None:
|
||||||
|
due_moment = datetime.datetime.strptime(
|
||||||
|
instance.due_time, "%H:%M"
|
||||||
|
).time()
|
||||||
|
overdue = now.time() > due_moment
|
||||||
|
views.append(
|
||||||
|
QuestInstanceView(
|
||||||
|
instance_id=instance.id,
|
||||||
|
definition_id=instance.definition_id,
|
||||||
|
child_id=instance.child_id,
|
||||||
|
title=definition.title if definition else "Quest",
|
||||||
|
icon=definition.icon if definition else None,
|
||||||
|
window=instance.window,
|
||||||
|
due_date=instance.due_date,
|
||||||
|
due_time=instance.due_time,
|
||||||
|
state=state,
|
||||||
|
overdue=overdue,
|
||||||
|
completed_at=(
|
||||||
|
latest.occurred_at
|
||||||
|
if latest is not None
|
||||||
|
and latest.event_type == "completed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
was_on_time=(
|
||||||
|
latest.was_on_time
|
||||||
|
if latest is not None
|
||||||
|
and latest.event_type == "completed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
due = len(views)
|
||||||
|
completed = sum(1 for view in views if view.state == "done")
|
||||||
|
remaining = due - completed
|
||||||
|
snapshots.append(
|
||||||
|
ChildDaySnapshot(
|
||||||
|
child_id=child.id,
|
||||||
|
child_name=child.display_name,
|
||||||
|
instances=tuple(views),
|
||||||
|
present=engine.is_present(child.id, today),
|
||||||
|
due_today=due,
|
||||||
|
completed_today=completed,
|
||||||
|
remaining_today=remaining,
|
||||||
|
completion_pct=(
|
||||||
|
round(completed * 100 / due) if due else 100
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return NestQuestSnapshot(
|
||||||
|
today_iso=today_iso,
|
||||||
|
children=tuple(snapshots),
|
||||||
|
cycle_day=_cycle_day(schedules, children, today),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_day(
|
||||||
|
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||||
|
) -> int:
|
||||||
|
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||||
|
|
||||||
|
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||||
|
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||||
|
53-week year cannot silently invert the cycle.
|
||||||
|
"""
|
||||||
|
for child in children:
|
||||||
|
schedule = schedules.get(child.id)
|
||||||
|
if schedule is None:
|
||||||
|
continue
|
||||||
|
days = (today - schedule.anchor_date).days
|
||||||
|
cycle_length = schedule.cycle_length_weeks * 7
|
||||||
|
return (days % cycle_length) + 1
|
||||||
|
return 0
|
||||||
@@ -14,10 +14,13 @@ from .const import (
|
|||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
MIN_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||||
|
|
||||||
@@ -56,6 +59,18 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
|||||||
):
|
):
|
||||||
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
|
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
|
||||||
|
|
||||||
|
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
|
||||||
|
# submits: a missing key keeps the current value, so pre-Feature-10
|
||||||
|
# form payloads (and API callers) are unaffected. Schema-validated
|
||||||
|
# submits always carry it.
|
||||||
|
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
|
||||||
|
if update_interval is not None and (
|
||||||
|
isinstance(update_interval, bool)
|
||||||
|
or not isinstance(update_interval, int)
|
||||||
|
or update_interval < MIN_UPDATE_INTERVAL
|
||||||
|
):
|
||||||
|
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +97,9 @@ def _build_schema(
|
|||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||||
): int,
|
): int,
|
||||||
|
vol.Required(
|
||||||
|
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||||
|
): int,
|
||||||
}
|
}
|
||||||
if admin_choices is not None:
|
if admin_choices is not None:
|
||||||
# HA's supported multi-select validator: the frontend can
|
# HA's supported multi-select validator: the frontend can
|
||||||
@@ -210,6 +228,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
||||||
|
CONF_UPDATE_INTERVAL: user_input.get(
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if admin_ids_present:
|
if admin_ids_present:
|
||||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||||
@@ -231,6 +253,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||||
|
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||||
):
|
):
|
||||||
if key in options:
|
if key in options:
|
||||||
values[key] = options[key]
|
values[key] = options[key]
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ _HA_MODULES = (
|
|||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.config_validation",
|
"homeassistant.helpers.config_validation",
|
||||||
"homeassistant.helpers.event",
|
"homeassistant.helpers.event",
|
||||||
|
"homeassistant.helpers.update_coordinator",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_spec(name: str):
|
def _find_spec(name: str):
|
||||||
@@ -85,6 +86,7 @@ for _parent, _child in (
|
|||||||
("homeassistant", "helpers"),
|
("homeassistant", "helpers"),
|
||||||
("homeassistant.helpers", "config_validation"),
|
("homeassistant.helpers", "config_validation"),
|
||||||
("homeassistant.helpers", "event"),
|
("homeassistant.helpers", "event"),
|
||||||
|
("homeassistant.helpers", "update_coordinator"),
|
||||||
):
|
):
|
||||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||||
|
|
||||||
@@ -197,6 +199,66 @@ _exceptions_mock.HomeAssistantError = HomeAssistantError
|
|||||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||||
_exceptions_mock.Unauthorized = Unauthorized
|
_exceptions_mock.Unauthorized = Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
class DataUpdateCoordinator:
|
||||||
|
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||||
|
|
||||||
|
Mirrors the surface NestQuest's coordinator subclass uses:
|
||||||
|
``__init__(hass, logger, name=..., update_interval=...)``,
|
||||||
|
``async_add_listener``/``async_update_listeners`` for entity
|
||||||
|
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||||
|
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||||
|
and ``data``. Interval scheduling is NOT simulated (no real clock
|
||||||
|
ticks in tests) — tests drive refreshes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||||
|
self.hass = hass
|
||||||
|
self.logger = logger
|
||||||
|
self.name = name
|
||||||
|
self.update_interval = update_interval
|
||||||
|
self.data = None
|
||||||
|
self.last_update_success = False
|
||||||
|
self._listeners = []
|
||||||
|
|
||||||
|
def async_add_listener(self, update_callback, context=None):
|
||||||
|
"""Subscribe; returns an unsubscribe callable like HA's."""
|
||||||
|
entry = (update_callback, context)
|
||||||
|
self._listeners.append(entry)
|
||||||
|
|
||||||
|
def _remove():
|
||||||
|
if entry in self._listeners:
|
||||||
|
self._listeners.remove(entry)
|
||||||
|
|
||||||
|
return _remove
|
||||||
|
|
||||||
|
def async_update_listeners(self):
|
||||||
|
"""Notify every listener (entities schedule their writes)."""
|
||||||
|
for callback, _context in list(self._listeners):
|
||||||
|
callback()
|
||||||
|
|
||||||
|
async def async_refresh(self):
|
||||||
|
"""Run one update pass and notify listeners."""
|
||||||
|
try:
|
||||||
|
self.data = await self._async_update_data()
|
||||||
|
self.last_update_success = True
|
||||||
|
except BaseException:
|
||||||
|
self.last_update_success = False
|
||||||
|
raise
|
||||||
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
async def async_config_entry_first_refresh(self):
|
||||||
|
"""First refresh at config-entry setup (alias of refresh)."""
|
||||||
|
await self.async_refresh()
|
||||||
|
|
||||||
|
async def async_shutdown(self):
|
||||||
|
"""Release listeners on teardown."""
|
||||||
|
self._listeners.clear()
|
||||||
|
|
||||||
|
|
||||||
|
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||||
|
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Tests for the shared NestQuest coordinator (Feature 10, task 1)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
from custom_components.nestquest.children import (
|
||||||
|
create_child,
|
||||||
|
list_children,
|
||||||
|
set_child_active,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.completion import complete_instance
|
||||||
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.coordinator import NestQuestCoordinator
|
||||||
|
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||||
|
from custom_components.nestquest.materialize import materialize
|
||||||
|
from custom_components.nestquest.quest_definitions import (
|
||||||
|
create_quest_definition,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.recurrence import ScheduleRule
|
||||||
|
|
||||||
|
ADMIN_CTX = {"user_id": "admin-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def _today_iso() -> str:
|
||||||
|
return datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_day(database, *, completed: bool = False) -> None:
|
||||||
|
"""Two active children + one inactive, one daily definition each."""
|
||||||
|
await create_child(database, "Ada")
|
||||||
|
await create_child(database, "Bo")
|
||||||
|
inactive = await create_child(database, "Ghost")
|
||||||
|
await set_child_active(database, inactive.id, False)
|
||||||
|
today = _today_iso()
|
||||||
|
await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Brush teeth",
|
||||||
|
ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}),
|
||||||
|
[child.id for child in await list_children(database, active_only=True)],
|
||||||
|
["morning"],
|
||||||
|
)
|
||||||
|
end = (
|
||||||
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
await materialize(database, today, end, today=datetime.date.today())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
assert coordinator is not None
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
await _seed_day(database)
|
||||||
|
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
assert snapshot.today_iso == _today_iso()
|
||||||
|
assert len(snapshot.children) == 2, "inactive child must be absent"
|
||||||
|
by_name = {child.child_name: child for child in snapshot.children}
|
||||||
|
ada = by_name["Ada"]
|
||||||
|
assert ada.due_today == 1
|
||||||
|
assert ada.completed_today == 0
|
||||||
|
assert ada.remaining_today == 1
|
||||||
|
assert ada.completion_pct == 0
|
||||||
|
instance = ada.instances[0]
|
||||||
|
assert instance.state == "open"
|
||||||
|
assert instance.overdue is False
|
||||||
|
assert instance.title == "Brush teeth"
|
||||||
|
assert instance.window == "morning"
|
||||||
|
assert ada.present is True, "no schedule means present every day"
|
||||||
|
assert snapshot.cycle_day == 0, "no schedule anywhere"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_reflects_completion_and_uncompletion(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
await _seed_day(database)
|
||||||
|
ada = (
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
).children[0]
|
||||||
|
instance_id = ada.instances[0].instance_id
|
||||||
|
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance_id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=ada.child_id,
|
||||||
|
)
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
child = snapshot.children[0]
|
||||||
|
assert child.completed_today == 1
|
||||||
|
assert child.remaining_today == 0
|
||||||
|
assert child.completion_pct == 100
|
||||||
|
view = child.instances[0]
|
||||||
|
assert view.state == "done"
|
||||||
|
assert view.completed_at is not None
|
||||||
|
assert view.was_on_time is not None
|
||||||
|
|
||||||
|
from custom_components.nestquest.completion import uncomplete_instance
|
||||||
|
|
||||||
|
await uncomplete_instance(
|
||||||
|
database, instance_id, actor_source="user", actor_user_id="admin-1"
|
||||||
|
)
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
child = snapshot.children[0]
|
||||||
|
assert child.completed_today == 0
|
||||||
|
assert child.remaining_today == 1
|
||||||
|
assert child.instances[0].state == "open"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_zero_quests_reports_full_percentage(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
await create_child(entry.runtime_data.database, "Ada")
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
child = snapshot.children[0]
|
||||||
|
assert child.due_today == 0
|
||||||
|
assert child.completion_pct == 100, "zero quests is a perfect day"
|
||||||
|
assert child.instances == ()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_cycle_day_from_first_scheduled_child(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
await create_child(database, "Ada")
|
||||||
|
ada = (await list_children(database))[0]
|
||||||
|
today = datetime.date.today()
|
||||||
|
# A 2-week cycle anchored 3 days ago: today is day 4.
|
||||||
|
anchor = today - datetime.timedelta(days=3)
|
||||||
|
await PresenceSchedulesDao(database).upsert_by_child(
|
||||||
|
ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|"
|
||||||
|
)
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
assert snapshot.cycle_day == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_presence_resolves_schedule_and_override(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
await create_child(database, "Ada")
|
||||||
|
ada = (await list_children(database))[0]
|
||||||
|
today = datetime.date.today()
|
||||||
|
# Scheduled absent today (empty weekday set for this cycle week).
|
||||||
|
await PresenceSchedulesDao(database).upsert_by_child(
|
||||||
|
ada.id, 1, today.isoformat(), ""
|
||||||
|
)
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
assert snapshot.children[0].present is False
|
||||||
|
|
||||||
|
# An override beats the pattern.
|
||||||
|
from custom_components.nestquest.dao_presence import (
|
||||||
|
PresenceOverridesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
await PresenceOverridesDao(database).create(
|
||||||
|
ada.id, today.isoformat(), today.isoformat(), True
|
||||||
|
)
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
assert snapshot.children[0].present is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_derives_missed_for_past_due_open(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
snapshot = await coordinator._async_update_data()
|
||||||
|
# No children: empty but valid snapshot.
|
||||||
|
assert snapshot.children == ()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_creates_coordinator_with_configured_interval(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(
|
||||||
|
make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry
|
||||||
|
)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
assert isinstance(coordinator, NestQuestCoordinator)
|
||||||
|
assert coordinator.update_interval == datetime.timedelta(seconds=60)
|
||||||
|
assert coordinator.data is not None, "first refresh ran at setup"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_defaults_to_five_minute_interval(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
assert coordinator.update_interval == datetime.timedelta(
|
||||||
|
seconds=DEFAULT_UPDATE_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
torn_down = []
|
||||||
|
|
||||||
|
async def _shutdown():
|
||||||
|
torn_down.append(coordinator)
|
||||||
|
|
||||||
|
coordinator.async_shutdown = _shutdown
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
assert torn_down == [coordinator]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_notifies_listeners_on_refresh(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
called = []
|
||||||
|
coordinator.async_add_listener(lambda: called.append("entity"))
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
assert called == ["entity"]
|
||||||
|
assert coordinator.last_update_success is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failed_first_refresh_shuts_coordinator_down(
|
||||||
|
hass, make_entry, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A setup failure after coordinator creation must not leak its
|
||||||
|
listeners against the database the except path closes."""
|
||||||
|
from custom_components.nestquest import async_setup_entry
|
||||||
|
import custom_components.nestquest.coordinator as coordinator_module
|
||||||
|
|
||||||
|
shutdown_calls = []
|
||||||
|
|
||||||
|
async def _record_shutdown(self):
|
||||||
|
shutdown_calls.append(self.entry_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
coordinator_module.NestQuestCoordinator,
|
||||||
|
"async_shutdown",
|
||||||
|
_record_shutdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _boom(self):
|
||||||
|
raise RuntimeError("refresh failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
coordinator_module.NestQuestCoordinator,
|
||||||
|
"_async_update_data",
|
||||||
|
_boom,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
with pytest.raises(RuntimeError, match="refresh failed"):
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
assert shutdown_calls == [entry.entry_id]
|
||||||
|
assert getattr(entry, "runtime_data", None) is None
|
||||||
@@ -18,6 +18,8 @@ from custom_components.nestquest.const import (
|
|||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
@@ -129,7 +131,10 @@ def test_options_flow_submits_through_2024_6_surface() -> None:
|
|||||||
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert result["title"] == "NestQuest"
|
assert result["title"] == "NestQuest"
|
||||||
assert result["data"] == VALID_INPUT
|
assert result["data"] == {
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_shows_form_initially() -> None:
|
def test_options_flow_shows_form_initially() -> None:
|
||||||
@@ -208,6 +213,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
|||||||
CONF_HORIZON_DAYS: 21,
|
CONF_HORIZON_DAYS: 21,
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
@@ -215,6 +221,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
|||||||
CONF_HORIZON_DAYS: 21,
|
CONF_HORIZON_DAYS: 21,
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
CONF_ADMIN_USER_IDS: [],
|
CONF_ADMIN_USER_IDS: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +289,8 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
|||||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_creates_entry_with_three_keys() -> None:
|
def test_options_flow_creates_entry_with_four_keys() -> None:
|
||||||
"""A valid submit creates an entry with exactly the three validated keys."""
|
"""A valid submit creates an entry with exactly the four validated keys."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
@@ -292,8 +299,12 @@ def test_options_flow_creates_entry_with_three_keys() -> None:
|
|||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
}
|
||||||
|
assert result["data"] == {
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||||
}
|
}
|
||||||
assert result["data"] == VALID_INPUT
|
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
|
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
|
||||||
@@ -316,7 +327,10 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
|||||||
_run(registry.dispatch_options_update(entry))
|
_run(registry.dispatch_options_update(entry))
|
||||||
assert hass.config_entries.async_reload.call_count == 1
|
assert hass.config_entries.async_reload.call_count == 1
|
||||||
assert registry.reloaded == [entry.entry_id]
|
assert registry.reloaded == [entry.entry_id]
|
||||||
assert entry.options == VALID_INPUT
|
assert entry.options == {
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_validation_never_raises() -> None:
|
def test_options_flow_validation_never_raises() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user