From 2d30f0ce772679f208a721255a2899d54113223e Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Fri, 18 Sep 2026 15:59:02 -0400 Subject: [PATCH] Register canonical NestQuest HA services with schemas Domain-global handlers for the ENTITIES-AND-SERVICES inventory plus regenerate. No permission decorator yet. --- custom_components/nestquest/__init__.py | 55 ++- custom_components/nestquest/const.py | 27 +- custom_components/nestquest/services.py | 557 ++++++++++++++++++++++ custom_components/nestquest/services.yaml | 316 +++++++++++- tests/conftest.py | 32 +- tests/test_services.py | 249 +++++++++- 6 files changed, 1194 insertions(+), 42 deletions(-) create mode 100644 custom_components/nestquest/services.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 8cc9b79..b0edf5b 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -24,11 +24,11 @@ from .const import ( DEFAULT_HORIZON_DAYS, DOMAIN, LOGGER, - SERVICE_REGENERATE, ) from .db import NestQuestDatabase from .materialize import materialize as _materialize_run from .migrations import apply_migrations +from .services import async_deregister_services, async_register_services from .store import async_get_db_path from .admin_allowlist import seed_setup_admin @@ -356,21 +356,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: return None -def _register_regenerate_service(hass: HomeAssistant) -> None: - """Register ``nestquest.regenerate`` once at domain scope. - - The service triggers a full on-demand regeneration over the rolling - horizon, running the SAME idempotent materialize path - (:func:`_run_horizon_materialization`) as the startup backfill and the - daily rollover listener. It is DOMAIN-global — registered exactly once - (guarded by ``has_service``) and NOT bound to any config entry — so the - handler resolves the currently-live database at call time and a name - collision or duplicate setup is impossible. Removal is handled by the - unload path only once the LAST entry is removed (see - :func:`_deregister_regenerate_service`). - """ - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): - return +def _make_regenerate_handler(hass: HomeAssistant): + """Return the Feature 07 ``regenerate`` handler closed over ``hass``.""" async def _regenerate(_call: Any) -> None: runtime_data = _find_live_runtime_data(hass) @@ -385,13 +372,29 @@ def _register_regenerate_service(hass: HomeAssistant) -> None: hass, runtime_data.database, horizon_days ) - hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) + return _regenerate -def _deregister_regenerate_service(hass: HomeAssistant) -> None: - """Remove ``nestquest.regenerate`` when the last entry unloads.""" - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): - hass.services.async_remove(DOMAIN, SERVICE_REGENERATE) +def _register_services(hass: HomeAssistant) -> None: + """Register domain-global NestQuest services once. + + Services are DOMAIN-global — registered exactly once (guarded by + ``has_service`` on regenerate) and NOT bound to any config entry — + so each handler resolves the currently-live database at call time + and a name collision or duplicate setup is impossible. Removal is + handled by the unload path only once the LAST entry is removed + (see :func:`_deregister_services`). + """ + async_register_services( + hass, + find_runtime=_find_live_runtime_data, + regenerate_handler=_make_regenerate_handler(hass), + ) + + +def _deregister_services(hass: HomeAssistant) -> None: + """Remove domain-global NestQuest services when the last entry unloads.""" + async_deregister_services(hass) async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: @@ -489,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _run_horizon_materialization( hass, database, _configured_horizon_days(dict(entry.options)) ) - _register_regenerate_service(hass) + _register_services(hass) except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -562,11 +565,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = getattr(runtime_data, "database", None) if database is not None: await database.close() - # The ``regenerate`` service is DOMAIN-global: it is torn down only - # once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty), - # never when a sibling entry is still loaded. + # Domain-global services are torn down only once the FINAL entry + # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling + # entry is still loaded. if is_last: - _deregister_regenerate_service(hass) + _deregister_services(hass) if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None if unload_error is not None: diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index ce21568..6238252 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME) PLATFORMS: list[str] = [] -#: Service name for the on-demand regeneration service (``nestquest.regenerate``). +#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 +#: plus the existing Feature 07 regenerate service). +SERVICE_COMPLETE_QUEST = "complete_quest" +SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest" +SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition" +SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition" +SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active" +SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern" +SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override" +SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override" +SERVICE_EXPORT_HISTORY_CSV = "export_history_csv" +SERVICE_MANAGE_CHILD = "manage_child" SERVICE_REGENERATE = "regenerate" +DOMAIN_SERVICES: tuple[str, ...] = ( + SERVICE_COMPLETE_QUEST, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_UPDATE_QUEST_DEFINITION, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, +) + SQLITE_DB_FILENAME = "nestquest.db" # Configuration keys and default values diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py new file mode 100644 index 0000000..c6d9fb2 --- /dev/null +++ b/custom_components/nestquest/services.py @@ -0,0 +1,557 @@ +"""Domain-global NestQuest service schemas and handlers (Feature 09). + +Registration is domain-scoped and happens once (see +:func:`async_register_services`); handlers resolve a live database at +call time through the injected ``find_runtime`` callable. Permission +checks are Feature 09 later tasks — this module only validates payload +shape and forwards to the Feature 03/06/08 business layers (and the +presence DAOs). ``export_history_csv`` is registered with a schema but +raises until Feature 13. ``regenerate`` is supplied by the caller so +the Feature 07 handler stays the one in ``__init__``. +""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +import voluptuous as vol +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import children as children_layer +from . import completion as completion_layer +from . import quest_definitions as quest_definitions_layer +from .const import ( + DOMAIN, + DOMAIN_SERVICES, + QUEST_WINDOWS, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) +from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .presence import PresenceSchedule +from .recurrence import ScheduleRule + +FindRuntime = Callable[[HomeAssistant], Any] +ServiceHandler = Callable[[Any], Awaitable[None]] + +ACTOR_PANEL = "panel" +ACTOR_USER = "user" +MANAGE_CHILD_CREATE = "create" +MANAGE_CHILD_EDIT = "edit" +MANAGE_CHILD_SET_ACTIVE = "set_active" +MANAGE_CHILD_REORDER = "reorder" + + +def _strict_int(value: object) -> int: + """Reject bools and non-ints; voluptuous ``int`` would accept True.""" + if isinstance(value, bool) or not isinstance(value, int): + raise vol.Invalid(f"expected int, got {value!r}") + return value + + +def _strict_bool(value: object) -> bool: + """Reject ints and other truthy values; require a real bool.""" + if type(value) is not bool: + raise vol.Invalid(f"expected bool, got {value!r}") + return value + + +def _weekday_list(value: object) -> list[int]: + if type(value) is not list: + raise vol.Invalid(f"expected a list of weekday ints, got {value!r}") + return [_strict_int(item) for item in value] + + +def _presence_pattern(value: object) -> dict[int, list[int]]: + if not isinstance(value, dict): + raise vol.Invalid(f"pattern must be a mapping, got {value!r}") + pattern: dict[int, list[int]] = {} + for week, days in value.items(): + if isinstance(week, str): + try: + week_index = int(week) + except ValueError as err: + raise vol.Invalid( + f"pattern week index must be an integer, got {week!r}" + ) from err + if str(week_index) != week: + raise vol.Invalid( + f"pattern week index must be an integer, got {week!r}" + ) + else: + week_index = _strict_int(week) + pattern[week_index] = _weekday_list(days) + return pattern + + +def _window_entry(value: object) -> str | tuple[str, str | None]: + if isinstance(value, str): + if value not in QUEST_WINDOWS: + raise vol.Invalid(f"unknown window {value!r}") + return value + if isinstance(value, dict): + parsed = vol.Schema( + { + vol.Required("window"): vol.In(QUEST_WINDOWS), + vol.Optional("due_time"): str, + } + )(value) + due_time = parsed.get("due_time") + if due_time is None: + return parsed["window"] + return (parsed["window"], due_time) + raise vol.Invalid( + f"windows entry must be a window name or object, got {value!r}" + ) + + +def _windows(value: object) -> list[str | tuple[str, str | None]]: + if type(value) is not list: + raise vol.Invalid(f"windows must be a list, got {value!r}") + if not value: + raise vol.Invalid("windows must not be empty") + return [_window_entry(entry) for entry in value] + + +def _int_list(value: object) -> list[int]: + if type(value) is not list: + raise vol.Invalid(f"expected a list of ints, got {value!r}") + return [_strict_int(item) for item in value] + + +def _rule_dict(value: object) -> dict: + if not isinstance(value, dict): + raise vol.Invalid(f"rule must be a dict, got {value!r}") + return value + + +SCHEMA_COMPLETE_QUEST = vol.Schema( + { + vol.Required("instance_id"): _strict_int, + vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]), + vol.Optional("actor_child_id"): _strict_int, + vol.Optional("actor_user_id"): str, + } +) + +SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST + +SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema( + { + vol.Required("title"): str, + vol.Required("rule"): _rule_dict, + vol.Required("assignee_child_ids"): vol.All( + _int_list, vol.Length(min=1) + ), + vol.Required("windows"): _windows, + vol.Optional("description"): str, + vol.Optional("icon"): str, + } +) + +SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema( + { + vol.Required("definition_id"): _strict_int, + vol.Optional("title"): str, + vol.Optional("description"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("rule"): _rule_dict, + vol.Optional("windows"): _windows, + } +) + +SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema( + { + vol.Required("definition_id"): _strict_int, + vol.Required("is_active"): _strict_bool, + } +) + +SCHEMA_SET_PRESENCE_PATTERN = vol.Schema( + { + vol.Required("child_id"): _strict_int, + vol.Required("cycle_length_weeks"): _strict_int, + vol.Required("anchor_date"): str, + vol.Required("pattern"): _presence_pattern, + } +) + +SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema( + { + vol.Required("child_id"): _strict_int, + vol.Required("start_date"): str, + vol.Required("end_date"): str, + vol.Required("is_present"): _strict_bool, + vol.Optional("note"): str, + } +) + +SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema( + { + vol.Required("override_id"): _strict_int, + } +) + +SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({}) + +SCHEMA_MANAGE_CHILD = vol.Schema( + { + vol.Required("action"): vol.In( + [ + MANAGE_CHILD_CREATE, + MANAGE_CHILD_EDIT, + MANAGE_CHILD_SET_ACTIVE, + MANAGE_CHILD_REORDER, + ] + ), + vol.Optional("child_id"): _strict_int, + vol.Optional("display_name"): str, + vol.Optional("colour"): str, + vol.Optional("avatar_ref"): str, + vol.Optional("sort_order"): _strict_int, + vol.Optional("is_active"): _strict_bool, + vol.Optional("ordered_ids"): _int_list, + } +) + + +def _require_database(hass: HomeAssistant, find_runtime: FindRuntime): + runtime = find_runtime(hass) + if runtime is None: + raise HomeAssistantError( + "NestQuest has no live database; cannot run this service" + ) + return runtime.database + + +def _actor_kwargs(call: Any) -> dict[str, Any]: + actor = call.data["actor"] + if actor == ACTOR_PANEL: + actor_child_id = call.data.get("actor_child_id") + if actor_child_id is None: + raise HomeAssistantError( + "actor_child_id is required when actor is panel" + ) + return { + "actor_source": ACTOR_PANEL, + "actor_child_id": actor_child_id, + "actor_user_id": None, + } + context = getattr(call, "context", None) + user_id = getattr(context, "user_id", None) if context is not None else None + if not user_id: + user_id = call.data.get("actor_user_id") + return { + "actor_source": ACTOR_USER, + "actor_user_id": user_id, + "actor_child_id": None, + } + + +def _with_errors(handler: ServiceHandler) -> ServiceHandler: + async def _wrapped(call: Any) -> None: + try: + await handler(call) + except HomeAssistantError: + raise + except ValueError as err: + raise HomeAssistantError(str(err)) from err + + return _wrapped + + +def _complete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + await completion_layer.complete_instance( + database, + call.data["instance_id"], + **_actor_kwargs(call), + ) + + return _with_errors(_handler) + + +def _uncomplete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + await completion_layer.uncomplete_instance( + database, + call.data["instance_id"], + **_actor_kwargs(call), + ) + + return _with_errors(_handler) + + +def _create_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + await quest_definitions_layer.create_quest_definition( + database, + data["title"], + ScheduleRule.from_dict(data["rule"]), + data["assignee_child_ids"], + data["windows"], + description=data.get("description"), + icon=data.get("icon"), + ) + + return _with_errors(_handler) + + +def _update_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + kwargs: dict[str, Any] = {} + if "title" in data: + kwargs["title"] = data["title"] + if "description" in data: + kwargs["description"] = data["description"] + if "icon" in data: + kwargs["icon"] = data["icon"] + if "rule" in data: + kwargs["rule"] = ScheduleRule.from_dict(data["rule"]) + if "windows" in data: + kwargs["windows"] = data["windows"] + await quest_definitions_layer.edit_quest_definition( + database, data["definition_id"], **kwargs + ) + + return _with_errors(_handler) + + +def _set_quest_definition_active( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + await quest_definitions_layer.set_quest_definition_active( + database, + call.data["definition_id"], + call.data["is_active"], + ) + + return _with_errors(_handler) + + +def _set_presence_pattern( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + schedule = PresenceSchedule( + data["child_id"], + data["cycle_length_weeks"], + data["anchor_date"], + data["pattern"], + ) + await PresenceSchedulesDao(database).upsert_by_child( + schedule.child_id, + schedule.cycle_length_weeks, + schedule.anchor_date.isoformat(), + schedule.encode(), + ) + + return _with_errors(_handler) + + +def _create_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + await PresenceOverridesDao(database).create( + data["child_id"], + data["start_date"], + data["end_date"], + data["is_present"], + note=data.get("note"), + ) + + return _with_errors(_handler) + + +def _delete_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + await PresenceOverridesDao(database).delete(call.data["override_id"]) + + return _with_errors(_handler) + + +async def _export_history_csv(_call: Any) -> None: + raise HomeAssistantError( + "CSV history export is not implemented until Feature 13" + ) + + +def _manage_child( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + action = data["action"] + if action == MANAGE_CHILD_CREATE: + display_name = data.get("display_name") + if display_name is None: + raise HomeAssistantError( + "display_name is required when action is create" + ) + kwargs: dict[str, Any] = {} + if "colour" in data: + kwargs["colour"] = data["colour"] + if "avatar_ref" in data: + kwargs["avatar_ref"] = data["avatar_ref"] + if "sort_order" in data: + kwargs["sort_order"] = data["sort_order"] + await children_layer.create_child( + database, display_name, **kwargs + ) + return + if action == MANAGE_CHILD_EDIT: + child_id = data.get("child_id") + if child_id is None: + raise HomeAssistantError( + "child_id is required when action is edit" + ) + kwargs = {} + if "display_name" in data: + kwargs["display_name"] = data["display_name"] + if "colour" in data: + kwargs["colour"] = data["colour"] + if "avatar_ref" in data: + kwargs["avatar_ref"] = data["avatar_ref"] + if "sort_order" in data: + kwargs["sort_order"] = data["sort_order"] + await children_layer.edit_child(database, child_id, **kwargs) + return + if action == MANAGE_CHILD_SET_ACTIVE: + child_id = data.get("child_id") + is_active = data.get("is_active") + if child_id is None: + raise HomeAssistantError( + "child_id is required when action is set_active" + ) + if is_active is None: + raise HomeAssistantError( + "is_active is required when action is set_active" + ) + await children_layer.set_child_active( + database, child_id, is_active + ) + return + ordered_ids = data.get("ordered_ids") + if ordered_ids is None: + raise HomeAssistantError( + "ordered_ids is required when action is reorder" + ) + await children_layer.reorder_children(database, ordered_ids) + + return _with_errors(_handler) + + +def async_register_services( + hass: HomeAssistant, + *, + find_runtime: FindRuntime, + regenerate_handler: ServiceHandler, +) -> None: + """Register every canonical service once at domain scope.""" + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + return + hass.services.async_register( + DOMAIN, SERVICE_REGENERATE, regenerate_handler + ) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_QUEST, + _complete_quest(hass, find_runtime), + schema=SCHEMA_COMPLETE_QUEST, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + _uncomplete_quest(hass, find_runtime), + schema=SCHEMA_UNCOMPLETE_QUEST, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + _create_quest_definition(hass, find_runtime), + schema=SCHEMA_CREATE_QUEST_DEFINITION, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_QUEST_DEFINITION, + _update_quest_definition(hass, find_runtime), + schema=SCHEMA_UPDATE_QUEST_DEFINITION, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + _set_quest_definition_active(hass, find_runtime), + schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PRESENCE_PATTERN, + _set_presence_pattern(hass, find_runtime), + schema=SCHEMA_SET_PRESENCE_PATTERN, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + _create_presence_override(hass, find_runtime), + schema=SCHEMA_CREATE_PRESENCE_OVERRIDE, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_PRESENCE_OVERRIDE, + _delete_presence_override(hass, find_runtime), + schema=SCHEMA_DELETE_PRESENCE_OVERRIDE, + ) + hass.services.async_register( + DOMAIN, + SERVICE_EXPORT_HISTORY_CSV, + _export_history_csv, + schema=SCHEMA_EXPORT_HISTORY_CSV, + ) + hass.services.async_register( + DOMAIN, + SERVICE_MANAGE_CHILD, + _manage_child(hass, find_runtime), + schema=SCHEMA_MANAGE_CHILD, + ) + + +def async_deregister_services(hass: HomeAssistant) -> None: + """Remove every canonical service when the last entry unloads.""" + for service in DOMAIN_SERVICES: + if hass.services.has_service(DOMAIN, service): + hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/nestquest/services.yaml b/custom_components/nestquest/services.yaml index eeae053..892d318 100644 --- a/custom_components/nestquest/services.yaml +++ b/custom_components/nestquest/services.yaml @@ -9,4 +9,318 @@ regenerate: horizon, using the same idempotent materialization as the daily rollover and the startup backfill. Safe to call at any time: a re-run refreshes the same rows instead of duplicating them, and completed instances are never - touched. \ No newline at end of file + touched. + +complete_quest: + name: Complete quest + description: Mark a quest instance as completed. + fields: + instance_id: + name: Instance ID + description: The quest instance to complete. + required: true + selector: + number: + mode: box + actor: + name: Actor + description: Who completed the quest (panel or user). + required: true + selector: + select: + options: + - panel + - user + actor_child_id: + name: Actor child ID + description: The tapped child profile. Required when actor is panel. + required: false + selector: + number: + mode: box + actor_user_id: + name: Actor user ID + description: Home Assistant user id when actor is user and no call context is present. + required: false + selector: + text: + +uncomplete_quest: + name: Uncomplete quest + description: Undo a quest instance completion. + fields: + instance_id: + name: Instance ID + description: The quest instance to uncomplete. + required: true + selector: + number: + mode: box + actor: + name: Actor + description: Who is undoing the completion (panel or user). + required: true + selector: + select: + options: + - panel + - user + actor_child_id: + name: Actor child ID + description: The tapped child profile. Required when actor is panel. + required: false + selector: + number: + mode: box + actor_user_id: + name: Actor user ID + description: Home Assistant user id when actor is user and no call context is present. + required: false + selector: + text: + +create_quest_definition: + name: Create quest definition + description: Create a quest definition with its schedule rule, assignees and windows. + fields: + title: + name: Title + description: Display title for the quest. + required: true + selector: + text: + rule: + name: Rule + description: Recurrence rule as a mapping (rule_type plus shape fields). + required: true + selector: + object: + assignee_child_ids: + name: Assignee child IDs + description: Child ids this definition is assigned to. + required: true + selector: + object: + windows: + name: Windows + description: Day windows (morning, afternoon, evening), optionally with a due time. + required: true + selector: + object: + description: + name: Description + description: Optional longer description. + required: false + selector: + text: + icon: + name: Icon + description: Optional icon name. + required: false + selector: + text: + +update_quest_definition: + name: Update quest definition + description: Edit a quest definition's title, description, icon, rule or windows. + fields: + definition_id: + name: Definition ID + description: The quest definition to update. + required: true + selector: + number: + mode: box + title: + name: Title + description: Replacement display title. + required: false + selector: + text: + description: + name: Description + description: Replacement description, or null to clear. + required: false + selector: + text: + icon: + name: Icon + description: Replacement icon, or null to clear. + required: false + selector: + text: + rule: + name: Rule + description: Replacement recurrence rule as a mapping. + required: false + selector: + object: + windows: + name: Windows + description: Replacement day windows. + required: false + selector: + object: + +set_quest_definition_active: + name: Set quest definition active + description: Deactivate or reactivate a quest definition. + fields: + definition_id: + name: Definition ID + description: The quest definition to update. + required: true + selector: + number: + mode: box + is_active: + name: Active + description: Whether the definition should generate future instances. + required: true + selector: + boolean: + +set_presence_pattern: + name: Set presence pattern + description: Upsert a child's repeating presence schedule. + fields: + child_id: + name: Child ID + description: The child whose schedule is being set. + required: true + selector: + number: + mode: box + cycle_length_weeks: + name: Cycle length weeks + description: Number of weeks in the repeating cycle (1 to 4). + required: true + selector: + number: + mode: box + min: 1 + max: 4 + anchor_date: + name: Anchor date + description: Cycle anchor as a YYYY-MM-DD date. + required: true + selector: + date: + pattern: + name: Pattern + description: Mapping of week index to present weekday ints (Monday is 0). + required: true + selector: + object: + +create_presence_override: + name: Create presence override + description: Create a date-range presence override for a child. + fields: + child_id: + name: Child ID + description: The child the override applies to. + required: true + selector: + number: + mode: box + start_date: + name: Start date + description: Inclusive start date (YYYY-MM-DD). + required: true + selector: + date: + end_date: + name: End date + description: Inclusive end date (YYYY-MM-DD). + required: true + selector: + date: + is_present: + name: Present + description: Whether the child is present for this range. + required: true + selector: + boolean: + note: + name: Note + description: Optional note stored with the override. + required: false + selector: + text: + +delete_presence_override: + name: Delete presence override + description: Delete a presence override by id. + fields: + override_id: + name: Override ID + description: The override to delete. + required: true + selector: + number: + mode: box + +export_history_csv: + name: Export history CSV + description: Export completion history as CSV. Not implemented until Feature 13. + +manage_child: + name: Manage child + description: Create, edit, activate or reorder child profiles. + fields: + action: + name: Action + description: create, edit, set_active, or reorder. + required: true + selector: + select: + options: + - create + - edit + - set_active + - reorder + child_id: + name: Child ID + description: Required for edit and set_active. + required: false + selector: + number: + mode: box + display_name: + name: Display name + description: Required for create; optional for edit. + required: false + selector: + text: + colour: + name: Colour + description: Optional colour string. + required: false + selector: + text: + avatar_ref: + name: Avatar + description: Optional avatar reference. + required: false + selector: + text: + sort_order: + name: Sort order + description: Optional panel sort order. + required: false + selector: + number: + mode: box + is_active: + name: Active + description: Required for set_active. + required: false + selector: + boolean: + ordered_ids: + name: Ordered IDs + description: Complete permutation of child ids. Required for reorder. + required: false + selector: + object: diff --git a/tests/conftest.py b/tests/conftest.py index b6603e8..3fa8637 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" _data_entry_flow_mock.FlowResult = dict -class ConfigEntryNotReady(Exception): +class HomeAssistantError(Exception): + """Stand-in mirroring homeassistant.exceptions.HomeAssistantError.""" + + +class ConfigEntryNotReady(HomeAssistantError): """Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady. HA retries setup when this is raised; the tests assert the corrupt @@ -185,6 +189,7 @@ class ConfigEntryNotReady(Exception): """ +_exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady import voluptuous as vol @@ -325,7 +330,7 @@ class ServiceRegistry: self._services: dict[tuple[str, str], object] = {} def async_register(self, domain, service, func, schema=None, supports_response=None): - self._services[(domain, service)] = func + self._services[(domain, service)] = (func, schema) return None def has_service(self, domain, service): @@ -335,11 +340,26 @@ class ServiceRegistry: self._services.pop((domain, service), None) return None - async def call(self, domain, service, data=None): - func = self._services.get((domain, service)) - if func is None: + async def call(self, domain, service, data=None, context=None): + registered = self._services.get((domain, service)) + if registered is None: raise KeyError(f"service {domain}.{service} not registered") - call = SimpleNamespace(domain=domain, service=service, data=data or {}) + func, schema = registered + payload = dict(data or {}) + if schema is not None: + payload = schema(payload) + if context is None: + context_obj = SimpleNamespace(user_id=None) + elif isinstance(context, dict): + context_obj = SimpleNamespace(**context) + else: + context_obj = context + call = SimpleNamespace( + domain=domain, + service=service, + data=payload, + context=context_obj, + ) result = func(call) if inspect.isawaitable(result): await result diff --git a/tests/test_services.py b/tests/test_services.py index 68f2cde..1f2a2dd 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,9 +1,28 @@ -"""Tests for the NestQuest services.yaml declaration file.""" +"""Tests for NestQuest service declarations and domain-global handlers.""" from __future__ import annotations import re from pathlib import Path +import pytest +import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.const import ( + DOMAIN, + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, +) + SERVICES_PATH = ( Path(__file__).parent.parent / "custom_components" @@ -12,10 +31,37 @@ SERVICES_PATH = ( ) +def _wire(entry, registry): + return wire_entry_to_registry(entry, registry) + + +def _service_section(text: str, name: str) -> str: + match = re.search(rf"^{name}:\s*$", text, re.MULTILINE) + assert match is not None, f"services.yaml must declare {name!r}" + section = text[match.end() :] + next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) + if next_service is not None: + section = section[: next_service.start()] + return section + + def test_services_yaml_exists() -> None: assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}" +def test_services_yaml_declares_every_canonical_service() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE) + assert declared == list(DOMAIN_SERVICES) or set(declared) == set( + DOMAIN_SERVICES + ) + for name in DOMAIN_SERVICES: + section = _service_section(text, name) + assert re.search(r"description:", section), ( + f"{name} must carry a description" + ) + + def test_services_yaml_declares_regenerate() -> None: text = SERVICES_PATH.read_text(encoding="utf-8") assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), ( @@ -27,15 +73,202 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None: """The regenerate service is documented with a clear description and no required fields (there is no ``fields`` block under it).""" text = SERVICES_PATH.read_text(encoding="utf-8") - match = re.search(r"^regenerate:\s*$", text, re.MULTILINE) - assert match is not None - section = text[match.end():] - next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) - if next_service is not None: - section = section[: next_service.start()] + section = _service_section(text, "regenerate") assert re.search(r"description:", section), ( "regenerate must carry a description" ) assert "fields:" not in section, ( "regenerate declares no required fields" - ) \ No newline at end of file + ) + + +def test_services_yaml_other_services_declare_fields() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + for name in DOMAIN_SERVICES: + if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV): + continue + section = _service_section(text, name) + assert "fields:" in section, f"{name} must declare fields" + + +async def test_all_canonical_services_registered_after_setup( + hass, make_entry +) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + for service in DOMAIN_SERVICES: + assert hass.services.has_service(DOMAIN, service), service + + +async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + for service in DOMAIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service + + +async def test_unload_one_of_two_entries_keeps_services( + hass, make_entry +) -> None: + entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) + entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert await async_unload_entry(hass, entry_a) is True + for service in DOMAIN_SERVICES: + assert hass.services.has_service(DOMAIN, service), service + assert await async_unload_entry(hass, entry_b) is True + for service in DOMAIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service + + +async def test_manage_child_create_persists_child(hass, make_entry) -> None: + from custom_components.nestquest.children import list_children + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + children = await list_children(entry.runtime_data.database) + assert [child.display_name for child in children] == ["Ada"] + + +async def test_create_and_set_quest_definition_active( + hass, make_entry +) -> None: + from custom_components.nestquest.children import list_children + from custom_components.nestquest.quest_definitions import ( + list_active_definitions, + ) + from custom_components.nestquest.dao_rules import QuestDefinitionsDao + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + ) + active = await list_active_definitions(entry.runtime_data.database) + assert len(active) == 1 + definition_id = active[0].definition.id + await hass.services.call( + DOMAIN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + {"definition_id": definition_id, "is_active": False}, + ) + assert await list_active_definitions(entry.runtime_data.database) == [] + stored = await QuestDefinitionsDao(entry.runtime_data.database).get( + definition_id + ) + assert stored is not None + assert stored.is_active is False + + +async def test_complete_and_uncomplete_quest_call_business_layer( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.completion import instance_state + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + records = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child.id, today, end) + assert records + instance_id = records[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "done" + ) + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "user", + "actor_user_id": "parent-1", + }, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "open" + ) + + +async def test_export_history_csv_raises_home_assistant_error( + hass, make_entry +) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(HomeAssistantError, match="Feature 13"): + await hass.services.call(DOMAIN, SERVICE_EXPORT_HISTORY_CSV) + + +async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {}) + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": True, "actor": "panel", "actor_child_id": 1}, + ) + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create"}, + )