diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b33e7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*$py.class +.venv/ +.pytest_cache/ +*.metadata.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7eaf5ba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NestQuest Contributors / Maniacs Mansion + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..c69832e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,68 @@ +# NestQuest - House Chaos Coordination + +Home Assistant custom integration for managing chores, custody schedules, and house coordination. + +## Overview + +NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. Version 0.1.0 is the integration foundation: a configuration scaffold providing the package and manifest, shared constants, a single-instance config flow, setup/unload lifecycle handling with an options-reload listener, an options flow (horizon days, day rollover time, panel idle timeout), SQLite storage-path resolution, and HACS metadata. Sensors, calendar events, and service calls are forthcoming in later releases and are not yet part of the integration. + +## Installation + +### HACS Installation (Recommended) + +1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed. +2. In Home Assistant, open **HACS** > **Integrations**. +3. Select the three-dots menu in the top right and click **Custom repositories**. +4. Enter the repository URL: `https://github.com/talon2king/NestQuest` +5. Select category **Integration** and click **Add**. +6. Search for **NestQuest** and click **Download**. +7. Restart Home Assistant. + +> **Note:** Development happens in the private Gitea repository +> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub +> repository is a public mirror maintained for HACS distribution. + +### Manual Installation + +1. Download the latest release from the repository. +2. Copy the `custom_components/nestquest` directory into your Home Assistant installation's `config/custom_components/` folder: + ```bash + cp -R custom_components/nestquest /custom_components/ + ``` +3. Restart Home Assistant. + +## Configuration + +After installation and restart: +1. Navigate to **Settings** > **Devices & Services** in Home Assistant. +2. Click **Add Integration** and search for **NestQuest**. +3. Follow the on-screen configuration flow. + +## Developer Setup and Testing + +This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. + +### Prerequisites + +- Python 3.11 or newer +- [uv](https://docs.astral.sh/uv/) installed on your machine + +### Setup Environment + +To set up the development environment and install dependencies: + +```bash +uv sync +``` + +### Running Tests + +Run the test suite with `pytest`: + +```bash +uv run pytest +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..dd82c4a --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,45 @@ +# NestQuest Release Notes + +## Version 0.1.0 — 2026-09-13 + +### Scope + +First release of NestQuest, a Home Assistant custom integration for managing chores, custody schedules, and house coordination. This release ships the integration foundation/configuration scaffold only (Feature 01): + +- Integration package and manifest, plus HACS metadata (`hacs.json`). +- Shared constants module. +- Single-instance config flow for initial setup. +- Setup/unload lifecycle handling with an options-reload listener. +- Options flow for configuring `horizon_days`, `day_rollover_time`, and `panel_idle_timeout`. +- SQLite storage path resolver — path resolution only; the database itself is not created in this release. +- Test harness (`pytest` via `uv`). + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. + +### Known Limitations + +- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release. +- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance. +- The integration does not yet create its database file; storage is limited to resolved configuration paths. + +### Rollback + +v0.1.0 is the first release — there is no previous NestQuest version to downgrade to, so rollback means complete removal. This release performs no data or schema migrations. + +**HACS installs:** + +1. Remove the NestQuest integration via HACS (HACS > Integrations > NestQuest > Remove). +2. Restart Home Assistant. +3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again. + +**Manual installs:** + +1. Delete the `custom_components/nestquest/` directory from your Home Assistant configuration directory. +2. Restart Home Assistant. +3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again. + +**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal. + +**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above. \ No newline at end of file diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py new file mode 100644 index 0000000..f34c276 --- /dev/null +++ b/custom_components/nestquest/__init__.py @@ -0,0 +1,67 @@ +"""The NestQuest integration.""" +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, LOGGER + +_LOGGER = LOGGER + + +@dataclass +class NestQuestRuntimeData: + """Runtime data stored on a NestQuest config entry.""" + + entry_id: str + options: dict[str, Any] + remove_update_listener: Callable[[], Any] + + +async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: + """Set up the NestQuest integration. YAML configuration is not used, returns True.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NestQuest from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + async def _async_update_listener( + listener_hass: HomeAssistant, listener_entry: ConfigEntry + ) -> None: + """Reload the entry when its options change.""" + await listener_hass.config_entries.async_reload(listener_entry.entry_id) + + runtime_data = NestQuestRuntimeData( + entry_id=entry.entry_id, + options=dict(entry.options), + remove_update_listener=entry.add_update_listener(_async_update_listener), + ) + entry.runtime_data = runtime_data + hass.data[DOMAIN][entry.entry_id] = runtime_data + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + runtime_data = domain_data.pop(entry.entry_id, None) + if not domain_data: + hass.data.pop(DOMAIN, None) + if runtime_data is None: + runtime_data = getattr(entry, "runtime_data", None) + if runtime_data is not None: + remove_update_listener = getattr(runtime_data, "remove_update_listener", None) + if remove_update_listener is not None: + result = remove_update_listener() + if inspect.isawaitable(result): + await result + if getattr(entry, "runtime_data", None) is not None: + entry.runtime_data = None + return True diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py new file mode 100644 index 0000000..a8ccbc2 --- /dev/null +++ b/custom_components/nestquest/config_flow.py @@ -0,0 +1,39 @@ +"""Config flow for NestQuest.""" +from __future__ import annotations + +from typing import Any + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NestQuest.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> Any: + """Create the options flow handler for NestQuest.""" + from .options_flow import NestQuestOptionsFlow + + return NestQuestOptionsFlow(config_entry=config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step of the config flow.""" + await self.async_set_unique_id(DOMAIN, raise_on_progress=False) + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self.async_show_form(step_id="user") + + return self.async_create_entry(title="NestQuest", data=user_input) \ No newline at end of file diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py new file mode 100644 index 0000000..fc45d7c --- /dev/null +++ b/custom_components/nestquest/const.py @@ -0,0 +1,22 @@ +"""Constants for the NestQuest integration.""" +from __future__ import annotations + +import logging + +DOMAIN = "nestquest" +LOGGER_NAME = "custom_components.nestquest" +LOGGER = logging.getLogger(LOGGER_NAME) + +PLATFORMS: list[str] = [] + +SQLITE_DB_FILENAME = "nestquest.db" + +# Configuration keys and default values +CONF_HORIZON_DAYS = "horizon_days" +DEFAULT_HORIZON_DAYS = 14 + +CONF_DAY_ROLLOVER_TIME = "day_rollover_time" +DEFAULT_DAY_ROLLOVER_TIME = "00:00" + +CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout" +DEFAULT_PANEL_IDLE_TIMEOUT = 300 diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json new file mode 100644 index 0000000..8e75b31 --- /dev/null +++ b/custom_components/nestquest/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "nestquest", + "name": "NestQuest", + "codeowners": ["@overseer"], + "config_flow": true, + "documentation": "https://github.com/talon2king/NestQuest", + "integration_type": "hub", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/talon2king/NestQuest/issues", + "requirements": [], + "version": "0.1.0" +} diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py new file mode 100644 index 0000000..eeacafb --- /dev/null +++ b/custom_components/nestquest/options_flow.py @@ -0,0 +1,120 @@ +"""Options flow for NestQuest post-setup settings.""" +from __future__ import annotations + +import re +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, + CONF_PANEL_IDLE_TIMEOUT, + DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_HORIZON_DAYS, + DEFAULT_PANEL_IDLE_TIMEOUT, +) + +MIN_HORIZON_DAYS = 1 +MIN_PANEL_IDLE_TIMEOUT = 30 + +_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d") + + +def _parse_time(value: str) -> bool: + """Return True when value is a valid 24-hour HH:MM string.""" + return _TIME_PATTERN.fullmatch(value) is not None + + +def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: + """Validate user input and return field-level errors instead of raising.""" + errors: dict[str, str] = {} + + horizon_days = user_input.get(CONF_HORIZON_DAYS) + if ( + isinstance(horizon_days, bool) + or not isinstance(horizon_days, int) + or horizon_days < MIN_HORIZON_DAYS + ): + errors[CONF_HORIZON_DAYS] = "invalid" + + day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME) + if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time): + errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time" + + panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT) + if ( + isinstance(panel_idle_timeout, bool) + or not isinstance(panel_idle_timeout, int) + or panel_idle_timeout < MIN_PANEL_IDLE_TIMEOUT + ): + errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid" + + return errors + + +def _build_schema(current: dict[str, Any]) -> vol.Schema: + """Build the form schema, pre-filled with the current values.""" + return vol.Schema( + { + vol.Required( + CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] + ): int, + vol.Required( + CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] + ): str, + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] + ): int, + } + ) + + +class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): + """Handle the options flow for NestQuest.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the NestQuest options.""" + if user_input is None: + return self.async_show_form( + step_id="init", data_schema=_build_schema(self._current_values()) + ) + + errors = _async_validate(user_input) + if errors: + return self.async_show_form( + step_id="init", + data_schema=_build_schema(self._current_values()), + errors=errors, + ) + + return self.async_create_entry( + title="NestQuest", + data={ + CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], + CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], + CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + }, + ) + + def _current_values(self) -> dict[str, Any]: + """Resolve current values from entry options, then entry data, then defaults.""" + options = self.options + entry_data = getattr(self.config_entry, "data", None) or {} + values: dict[str, Any] = {} + for key, default in ( + (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), + (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), + (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), + ): + if key in options: + values[key] = options[key] + elif key in entry_data: + values[key] = entry_data[key] + else: + values[key] = default + return values \ No newline at end of file diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py new file mode 100644 index 0000000..8198d79 --- /dev/null +++ b/custom_components/nestquest/store.py @@ -0,0 +1,20 @@ +"""SQLite storage path resolution for the NestQuest integration.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.core import HomeAssistant + +from .const import SQLITE_DB_FILENAME + + +def _ensure_dir(path: Path) -> None: + """Create the given directory and any missing parents.""" + path.mkdir(parents=True, exist_ok=True) + + +async def async_get_db_path(hass: HomeAssistant) -> Path: + """Return the absolute path to the NestQuest SQLite database file.""" + db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute() + await hass.async_add_executor_job(_ensure_dir, db_path.parent) + return db_path \ No newline at end of file diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json new file mode 100644 index 0000000..277492d --- /dev/null +++ b/custom_components/nestquest/strings.json @@ -0,0 +1,34 @@ +{ + "title": "NestQuest", + "config": { + "step": { + "user": { + "title": "Add NestQuest", + "description": "Set up NestQuest to start coordinating the household. No additional configuration is required." + } + }, + "abort": { + "single_instance_allowed": "NestQuest is already configured. Only a single instance is allowed." + }, + "create_entry": { + "default": "NestQuest was successfully configured." + } + }, + "options": { + "step": { + "init": { + "title": "NestQuest Settings", + "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "data": { + "horizon_days": "Generation horizon (days)", + "day_rollover_time": "Day rollover time (HH:MM)", + "panel_idle_timeout": "Panel idle timeout (seconds)" + } + } + }, + "errors": { + "invalid": "Invalid value.", + "invalid_time": "Time must use 24-hour HH:MM format." + } + } +} \ No newline at end of file diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json new file mode 100644 index 0000000..277492d --- /dev/null +++ b/custom_components/nestquest/translations/en.json @@ -0,0 +1,34 @@ +{ + "title": "NestQuest", + "config": { + "step": { + "user": { + "title": "Add NestQuest", + "description": "Set up NestQuest to start coordinating the household. No additional configuration is required." + } + }, + "abort": { + "single_instance_allowed": "NestQuest is already configured. Only a single instance is allowed." + }, + "create_entry": { + "default": "NestQuest was successfully configured." + } + }, + "options": { + "step": { + "init": { + "title": "NestQuest Settings", + "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "data": { + "horizon_days": "Generation horizon (days)", + "day_rollover_time": "Day rollover time (HH:MM)", + "panel_idle_timeout": "Panel idle timeout (seconds)" + } + } + }, + "errors": { + "invalid": "Invalid value.", + "invalid_time": "Time must use 24-hour HH:MM format." + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..722e594 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "NestQuest", + "homeassistant": "2024.6.0", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6d77648 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "nestquest" +version = "0.1.0" +description = "NestQuest Home Assistant Integration" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24", + "voluptuous>=0.14", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e702c10 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,308 @@ +"""Shared pytest fixtures for the NestQuest test suite. + +pytest-homeassistant-custom-component could not be adopted: it pins +``pytest==9.0.3`` and ``voluptuous==0.13.1``, which conflicts with the dev +group's ``voluptuous>=0.14`` (used directly by the options flow's schema and +its tests), and its back-releases raise the floor to Python >=3.12 while this +project supports >=3.11. The documented fallback is therefore used: +``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below. + +conftest.py is the single place where ``homeassistant`` modules are provided: +this harness is explicitly MOCK-ONLY. It unconditionally installs a coherent +set of MagicMock stand-ins via ``sys.modules`` and wires the parent-package +attributes. All of that happens here, before any test module imports the +integration, so no test module needs its own ``sys.modules`` setup. It also +provides the ``hass`` fixture plus shared helpers used by the behavior tests +(config-entry factory, listener registry). + +Run this suite in its own venv *without* homeassistant installed (``uv run +pytest -q``); it is never meant to run against a real HA install. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import inspect +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Homeassistant module provisioning (explicitly mock-only). +# --------------------------------------------------------------------------- +_HA_MODULES = ( + "homeassistant", + "homeassistant.core", + "homeassistant.config_entries", + "homeassistant.data_entry_flow", +) + +def _find_spec(name: str): + """find_spec that returns None when the module (or its parent) is absent.""" + try: + return importlib.util.find_spec(name) + except (ModuleNotFoundError, ValueError): + return None + + +def _ensure_mock_only(): + """Fail fast if real homeassistant is importable: this harness is mock-only.""" + for _mod in _HA_MODULES: + # Anything already in sys.modules at load time is real HA; our mocks + # are only installed after this check. + if _mod in sys.modules or _find_spec(_mod) is not None: + raise pytest.UsageError( + "The NestQuest test harness is mock-only: real homeassistant is " + f"importable ({_mod!r} found), and mixing real HA modules with the " + "shaped mocks is not supported. Run the suite in its own venv " + "without homeassistant installed, e.g. `uv run pytest -q`." + ) + + +_ensure_mock_only() + +for _mod in _HA_MODULES: + sys.modules.setdefault(_mod, MagicMock()) + +# Wire parent package attributes so "from homeassistant import +# config_entries" resolves to the mocked submodule, mirroring real HA's +# package layout. +for _parent, _child in ( + ("homeassistant", "config_entries"), + ("homeassistant", "data_entry_flow"), + ("homeassistant", "core"), +): + setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) + + +def _ha_mock(name: str): + """Return the shaped mock module object for a homeassistant module.""" + return sys.modules[name] + + +def options_flow_base(): + """Return the mocked OptionsFlowWithConfigEntry class.""" + return getattr(sys.modules["homeassistant.config_entries"], "OptionsFlowWithConfigEntry") + + +class _ConfigFlowBase: + """Stand-in for homeassistant.config_entries.ConfigFlow. + + A superset covering both the config-flow and options-flow tests: it must + satisfy every consumer of the mocked surface. + """ + + VERSION = 1 + + def __init_subclass__(cls, domain=None, **kwargs): + super().__init_subclass__(**kwargs) + cls._domain = domain + + def __init__(self): + self.context = {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_abort(self, *, reason, **kwargs): + return {"type": "abort", "reason": reason} + + def async_create_entry(self, *, title, data, **kwargs): + """Return the flow result only; the flow manager persists the entry.""" + return {"type": "create_entry", "title": title, "data": data} + + async def async_set_unique_id(self, unique_id, raise_on_progress=True): + self.context["unique_id"] = unique_id + return None + + def _async_current_entries(self): + return self.hass.config_entries.async_entries(self._domain) + + def _async_in_progress(self): + return [] + + @staticmethod + def async_get_options_flow(config_entry): + raise NotImplementedError + + +class _OptionsFlowWithConfigEntryBase: + """Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry.""" + + def __init__(self, config_entry=None): + self.config_entry = config_entry + self.hass = None + self.options = dict(config_entry.options) if config_entry is not None else {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_create_entry(self, *, title, data, **kwargs): + return {"type": "create_entry", "title": title, "data": data} + + +def _callback_decorator(fn): + """Mirror homeassistant.core.callback: flags the fn and returns it unchanged.""" + fn.__ha_callback__ = True + return fn + + +# The concrete 2024.6-shaped surface the integration is written against +# (mirrors the shapes the tests previously installed per module). +_config_entries_mock = _ha_mock("homeassistant.config_entries") +_core_mock = _ha_mock("homeassistant.core") +_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow") + +_config_entries_mock.ConfigFlow = _ConfigFlowBase +_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase +_core_mock.callback = _callback_decorator +_data_entry_flow_mock.RESULT_TYPE_FORM = "form" +_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" +_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" +_data_entry_flow_mock.FlowResult = dict + + +# --------------------------------------------------------------------------- +# Shared helpers for the behavior tests. +# --------------------------------------------------------------------------- +class ListenerRegistry: + """Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers.""" + + def __init__(self, hass=None): + self._hass = hass + self._listeners: dict[str, list[object]] = {} + self.reloaded: list[str] = [] + + def add(self, entry_id: str, listener) -> object: + def _remove(): + self._listeners.get(entry_id, []).remove(listener) + + self._listeners.setdefault(entry_id, []).append(listener) + return _remove + + async def dispatch_options_update(self, entry) -> None: + """Dispatch on the running loop; sync listeners run inline, async ones awaited.""" + for listener in list(self._listeners.get(entry.entry_id, [])): + result = listener(self._hass, entry) + if inspect.isawaitable(result): + await result + + async_dispatch_options_update = dispatch_options_update + + @property + def size(self) -> int: + return sum(len(listeners) for listeners in self._listeners.values()) + + +def make_config_entry( + entry_id: str = "test_entry", + options: dict | None = None, + data: dict | None = None, +): + """Create a stub config entry shaped like HA's ConfigEntry for tests.""" + entry = SimpleNamespace( + entry_id=entry_id, + options=dict(options or {}), + data=dict(data or {}), + ) + entry.add_update_listener = None + return entry + + +def wire_entry_to_registry(entry, registry: ListenerRegistry): + """Attach an update-listener registration function to the entry.""" + entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) + return entry + + +def make_hass() -> tuple: + """Create a hass stub plus its listener registry, mirroring HA's shape.""" + hass = SimpleNamespace( + data={}, + config=MagicMock(), + config_entries=SimpleNamespace(async_reload=None), + ) + registry = ListenerRegistry(hass) + + async def _async_reload(entry_id: str) -> None: + registry.reloaded.append(entry_id) + + hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) + hass.config_entries._entries = [] + hass.config_entries.async_add = ( + lambda entry: hass.config_entries._entries.append(entry) + ) + + def _async_entries(domain: str): + return [e for e in hass.config_entries._entries if e.domain == domain] + + hass.config_entries.async_entries = _async_entries + return hass, registry + + +@pytest.fixture +async def hass(): + """Provide a standard-shaped hass fixture bound to the running test loop. + + Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config`` + namespace, a ``config_entries`` manager wired to a listener registry, a + ``data`` dict for integration state, and a ``loop`` bound to the + pytest-asyncio loop the test itself runs on (HA's running-loop contract), + so code scheduling on ``hass.loop`` runs on the actual test loop. + """ + _hass, registry = make_hass() + _hass.registry = registry + _hass.loop = asyncio.get_running_loop() + yield _hass + + +@pytest.fixture +def make_entry(): + """Provide the config-entry factory as a fixture.""" + + def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): + return make_config_entry(entry_id=entry_id, options=options, data=data) + + return _factory + + +@pytest.fixture +def listener_registry(): + """Provide a fresh listener registry for manual wiring.""" + + def _factory(hass=None): + return ListenerRegistry(hass) + + return _factory + + +@pytest.fixture +def make_flow(): + """Provide a factory for a stubbed NestQuestConfigFlow bound to hass.""" + + def _factory(hass=None, existing_entries=None, in_progress=None): + from custom_components.nestquest import config_flow + + flow = config_flow.NestQuestConfigFlow() + flow.hass = hass if hass is not None else make_hass()[0] + for entry in existing_entries or []: + if getattr(entry, "domain", None) == flow._domain: + flow.hass.config_entries.async_add(entry) + if in_progress: + flow._async_in_progress = lambda: in_progress + return flow + + return _factory + + +@pytest.fixture +def make_config_flow_entry(): + """Provide an entry stub carrying a domain, for current-entries checks.""" + + def _factory(domain: str, entry_id: str = "existing"): + return SimpleNamespace(domain=domain, entry_id=entry_id) + + return _factory \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..b744bf4 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,157 @@ +"""Tests for the config flow of NestQuest.""" +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest + +from custom_components.nestquest import config_flow +from custom_components.nestquest.const import DOMAIN + +PKG_DIR = Path(config_flow.__file__).parent +STRINGS_PATH = PKG_DIR / "strings.json" +TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" + + +async def test_flow_shows_form_initially(hass, make_flow) -> None: + """The initial user step shows a form.""" + flow = make_flow(hass) + result = await flow.async_step_user(None) + assert result["type"] == "form" + assert result["step_id"] == "user" + + +async def test_flow_creates_entry_on_submit(hass, make_flow) -> None: + """Submitting the user step returns a create-entry result with the payload. + + Real HA's ConfigFlow.async_create_entry only returns the flow result; the + flow manager persists the entry afterward. The manager surface therefore + stays empty here — persistence is the manager's job, not the flow's. + """ + flow = make_flow(hass) + assert hass.config_entries.async_entries(DOMAIN) == [] + result = await flow.async_step_user({}) + assert result["type"] == "create_entry" + assert result["title"] == "NestQuest" + assert result["data"] == {} + assert hass.config_entries.async_entries(DOMAIN) == [] + + +async def test_second_flow_aborts_single_instance( + hass, make_flow, make_config_flow_entry +) -> None: + """A second flow aborts with single_instance_allowed.""" + existing = make_config_flow_entry(DOMAIN) + flow = make_flow(hass, existing_entries=[existing]) + assert hass.config_entries.async_entries(DOMAIN) == [existing] + result = await flow.async_step_user(None) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + assert hass.config_entries.async_entries(DOMAIN) == [existing] + + +async def test_flow_aborts_when_in_progress(hass, make_flow) -> None: + """A concurrent flow in progress aborts with single_instance_allowed.""" + flow = make_flow(hass, in_progress=[{"flow_id": "other"}]) + result = await flow.async_step_user(None) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_flow_sets_stable_unique_id(hass, make_flow) -> None: + """The flow assigns a stable unique id equal to the domain.""" + flow = make_flow(hass) + assert flow.context.get("unique_id") is None + await flow.async_step_user(None) + assert flow.context["unique_id"] == DOMAIN + + +async def test_flow_ignores_other_domain_entries(hass, make_flow, make_config_flow_entry) -> None: + """Entries of other domains do not trigger the abort.""" + flow = make_flow(hass, existing_entries=[make_config_flow_entry("other_domain")]) + result = await flow.async_step_user(None) + assert result["type"] == "form" + assert result["step_id"] == "user" + + +@pytest.fixture +def strings() -> dict: + """Load and parse strings.json.""" + assert STRINGS_PATH.exists(), f"strings.json does not exist at {STRINGS_PATH}" + with open(STRINGS_PATH, encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def translations_en() -> dict: + """Load and parse translations/en.json.""" + assert TRANSLATIONS_EN_PATH.exists(), ( + f"translations/en.json does not exist at {TRANSLATIONS_EN_PATH}" + ) + with open(TRANSLATIONS_EN_PATH, encoding="utf-8") as f: + return json.load(f) + + +@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"]) +def test_flow_strings_valid_json(fixture_name: str, request: pytest.FixtureRequest) -> None: + """strings.json and translations/en.json are valid JSON objects.""" + content = request.getfixturevalue(fixture_name) + assert isinstance(content, dict) + + +@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"]) +def test_flow_strings_structure(fixture_name: str, request: pytest.FixtureRequest) -> None: + """Config flow strings contain title, user step, and abort reasons.""" + content = request.getfixturevalue(fixture_name) + assert content.get("title") + config = content.get("config", {}) + assert config.get("step", {}).get("user", {}).get("title") + assert config.get("step", {}).get("user", {}).get("description") + abort = config.get("abort", {}) + assert "single_instance_allowed" in abort + assert abort["single_instance_allowed"] + + +@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"]) +def test_flow_strings_no_raw_keys(fixture_name: str, request: pytest.FixtureRequest) -> None: + """No placeholder/raw keys appear in the flow strings.""" + content = request.getfixturevalue(fixture_name) + + def check(value) -> None: + if isinstance(value, dict): + for key, val in value.items(): + assert val, f"Empty string value for key '{key}'" + check(val) + elif isinstance(value, str): + assert value.strip() != "" + + check(content) + + +def test_flow_strings_match_translations( + strings: dict, translations_en: dict +) -> None: + """strings.json and translations/en.json carry identical flow strings.""" + assert strings == translations_en + + +def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: + """Ensure no module other than const.py hardcodes the domain or db filename.""" + pkg_dir = Path(config_flow.__file__).parent + py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)] + + assert len(py_files) > 0, "No python modules found in package to check" + + for py_file in py_files: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if node.value == "nestquest": + raise AssertionError( + f"Found hard-coded domain string in {py_file.name}:{node.lineno}" + ) + assert node.value != "nestquest.db", ( + f"Found hard-coded database filename in {py_file.name}:{node.lineno}" + ) \ No newline at end of file diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 0000000..aea1bd9 --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,95 @@ +"""Tests for const.py of NestQuest.""" +from __future__ import annotations + +import ast +from datetime import datetime +import logging +from pathlib import Path + +from custom_components.nestquest import const + + +def test_domain() -> None: + """Verify DOMAIN is defined and equals 'nestquest'.""" + assert hasattr(const, "DOMAIN") + assert const.DOMAIN == "nestquest" + assert isinstance(const.DOMAIN, str) + + +def test_logger() -> None: + """Verify LOGGER_NAME and LOGGER are defined with correct types and values.""" + assert hasattr(const, "LOGGER_NAME") + assert const.LOGGER_NAME == "custom_components.nestquest" + assert isinstance(const.LOGGER_NAME, str) + + assert hasattr(const, "LOGGER") + assert isinstance(const.LOGGER, logging.Logger) + assert const.LOGGER.name == "custom_components.nestquest" + + +def test_platforms() -> None: + """Verify PLATFORMS is defined as an empty list.""" + assert hasattr(const, "PLATFORMS") + assert isinstance(const.PLATFORMS, list) + assert const.PLATFORMS == [] + + +def test_sqlite_db_filename() -> None: + """Verify SQLITE_DB_FILENAME is defined and equals 'nestquest.db'.""" + assert hasattr(const, "SQLITE_DB_FILENAME") + assert const.SQLITE_DB_FILENAME == "nestquest.db" + assert isinstance(const.SQLITE_DB_FILENAME, str) + + +def test_horizon_days_config() -> None: + """Verify CONF_HORIZON_DAYS and DEFAULT_HORIZON_DAYS are valid.""" + assert hasattr(const, "CONF_HORIZON_DAYS") + assert const.CONF_HORIZON_DAYS == "horizon_days" + assert hasattr(const, "DEFAULT_HORIZON_DAYS") + assert isinstance(const.DEFAULT_HORIZON_DAYS, int) + assert const.DEFAULT_HORIZON_DAYS == 14 + assert const.DEFAULT_HORIZON_DAYS > 0 + + +def test_day_rollover_time_config() -> None: + """Verify CONF_DAY_ROLLOVER_TIME and DEFAULT_DAY_ROLLOVER_TIME are valid.""" + assert hasattr(const, "CONF_DAY_ROLLOVER_TIME") + assert const.CONF_DAY_ROLLOVER_TIME == "day_rollover_time" + assert hasattr(const, "DEFAULT_DAY_ROLLOVER_TIME") + assert isinstance(const.DEFAULT_DAY_ROLLOVER_TIME, str) + assert const.DEFAULT_DAY_ROLLOVER_TIME == "00:00" + # Validate time format HH:MM + parsed = datetime.strptime(const.DEFAULT_DAY_ROLLOVER_TIME, "%H:%M") + assert parsed.hour == 0 and parsed.minute == 0 + + +def test_panel_idle_timeout_config() -> None: + """Verify CONF_PANEL_IDLE_TIMEOUT and DEFAULT_PANEL_IDLE_TIMEOUT are valid.""" + assert hasattr(const, "CONF_PANEL_IDLE_TIMEOUT") + assert const.CONF_PANEL_IDLE_TIMEOUT == "panel_idle_timeout" + assert hasattr(const, "DEFAULT_PANEL_IDLE_TIMEOUT") + assert isinstance(const.DEFAULT_PANEL_IDLE_TIMEOUT, int) + assert const.DEFAULT_PANEL_IDLE_TIMEOUT == 300 + assert const.DEFAULT_PANEL_IDLE_TIMEOUT > 0 + + +def test_no_hardcoded_domain_or_db_filename_in_modules() -> None: + """Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME.""" + pkg_dir = Path(const.__file__).parent + py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"] + + assert len(py_files) > 0, "No python modules found in package to check" + + for py_file in py_files: + content = py_file.read_text(encoding="utf-8") + tree = ast.parse(content, filename=str(py_file)) + + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + assert node.value != "nestquest.db", ( + f"Found hard-coded database filename '{node.value}' in {py_file.name}:{node.lineno}" + ) + if node.value == "nestquest": + raise AssertionError( + f"Found hard-coded domain string '{node.value}' in {py_file.name}:{node.lineno}" + ) diff --git a/tests/test_hacs.py b/tests/test_hacs.py new file mode 100644 index 0000000..275d7ee --- /dev/null +++ b/tests/test_hacs.py @@ -0,0 +1,82 @@ +"""Tests verifying HACS repository metadata, documentation, and licensing.""" +from __future__ import annotations + +import json +from pathlib import Path +import pytest + +REPO_ROOT = Path(__file__).parent.parent +HACS_JSON_PATH = REPO_ROOT / "hacs.json" +LICENSE_PATH = REPO_ROOT / "LICENSE" +README_PATH = REPO_ROOT / "README.md" +MANIFEST_PATH = REPO_ROOT / "custom_components" / "nestquest" / "manifest.json" + + +@pytest.fixture +def hacs_config() -> dict: + """Load and parse hacs.json.""" + assert HACS_JSON_PATH.exists(), f"hacs.json does not exist at {HACS_JSON_PATH}" + with open(HACS_JSON_PATH, encoding="utf-8") as f: + data = json.load(f) + return data + + +def test_hacs_json_exists_and_is_valid(hacs_config: dict) -> None: + """Verify hacs.json exists and is valid JSON.""" + assert isinstance(hacs_config, dict) + assert hacs_config.get("name") == "NestQuest" + assert hacs_config.get("homeassistant") == "2024.6.0" + assert hacs_config.get("render_readme") is True + + +def test_hacs_json_minimum_version_supports_runtime_data(hacs_config: dict) -> None: + """Advertised HA floor must support ConfigEntry.runtime_data (added in 2024.6).""" + advertised = tuple(int(part) for part in hacs_config.get("homeassistant", "").split(".")) + assert advertised >= (2024, 6, 0) + + +def test_license_exists_and_non_empty() -> None: + """Verify LICENSE file exists and is non-empty.""" + assert LICENSE_PATH.exists(), f"LICENSE file does not exist at {LICENSE_PATH}" + content = LICENSE_PATH.read_text(encoding="utf-8").strip() + assert len(content) > 0 + assert "MIT License" in content + assert "NestQuest Contributors / Maniacs Mansion" in content + + +def test_readme_exists_and_contains_required_sections() -> None: + """Verify README.md exists and documents HACS installation, manual installation, and test commands.""" + assert README_PATH.exists(), f"README.md does not exist at {README_PATH}" + content = README_PATH.read_text(encoding="utf-8") + assert len(content.strip()) > 0 + + # Title and overview + assert "NestQuest - House Chaos Coordination" in content + assert "Home Assistant custom integration" in content + + # Installation methods + assert "HACS" in content + assert "Manual Installation" in content or "manual installation" in content.lower() + assert "custom_components/nestquest" in content + + # Developer Setup and Testing commands + assert "uv sync" in content + assert "uv run pytest" in content + + +def test_readme_hacs_points_at_public_github_mirror() -> None: + """Verify README directs HACS users to the public GitHub mirror, not the private Gitea repo.""" + content = README_PATH.read_text(encoding="utf-8") + assert "https://github.com/talon2king/NestQuest" in content + assert "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest" in content + assert content.index("https://github.com/talon2king/NestQuest") < content.index( + "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest" + ) + + +def test_hacs_directory_structure() -> None: + """Verify directory structure matches HACS integration expectations.""" + assert MANIFEST_PATH.exists(), f"Integration manifest does not exist at {MANIFEST_PATH}" + with open(MANIFEST_PATH, encoding="utf-8") as f: + manifest = json.load(f) + assert manifest.get("domain") == "nestquest" diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..b947990 --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,86 @@ +"""Contract test for the mock-only homeassistant harness in conftest.py.""" + +from __future__ import annotations + +import asyncio +import importlib.metadata +import inspect +import sys +from unittest.mock import MagicMock + +import pytest + +import conftest + +from conftest import _HA_MODULES, _ensure_mock_only + + +def test_mock_only_harness_contract(monkeypatch): + """The harness is mock-only: shaped mocks everywhere, fail-fast on real HA.""" + # Every required homeassistant module in sys.modules is a mock stand-in + # with the shaped surface, and parent-package attributes point at them. + for name in _HA_MODULES: + assert isinstance(sys.modules[name], MagicMock), f"{name} is not a mock" + + config_entries = sys.modules["homeassistant.config_entries"] + assert config_entries.ConfigFlow is conftest._ConfigFlowBase + assert config_entries.OptionsFlowWithConfigEntry is ( + conftest._OptionsFlowWithConfigEntryBase + ) + assert sys.modules["homeassistant"].config_entries is config_entries + assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator + assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form" + + # No homeassistant distribution is installed in this suite's own venv. + with pytest.raises(importlib.metadata.PackageNotFoundError): + importlib.metadata.version("homeassistant") + + # Fail-fast paths: a findable real HA module, or one preloaded in + # sys.modules, both abort collection with a UsageError. + real_spec = MagicMock(origin="/fake/site-packages/homeassistant/__init__.py") + monkeypatch.setattr(conftest, "_find_spec", lambda name: real_spec) + with pytest.raises(pytest.UsageError, match="mock-only"): + _ensure_mock_only() + + monkeypatch.setattr(conftest, "_find_spec", lambda name: None) + monkeypatch.setitem(sys.modules, "homeassistant", MagicMock(name="real-module")) + with pytest.raises(pytest.UsageError, match="mock-only"): + _ensure_mock_only() + + +async def test_hass_and_registry_run_on_the_running_test_loop(hass, make_entry): + """hass.loop is the test's own loop and the registry spins no private loop.""" + # hass.loop is the very loop the async test runs on (HA running-loop + # contract): code scheduling on hass.loop lands on the test loop itself. + assert hass.loop is asyncio.get_running_loop() + ran = [] + + async def _scheduled(): + ran.append(True) + + await hass.loop.create_task(_scheduled()) + assert ran == [True] + + # ListenerRegistry never spins its own event loop (no resource leak) and + # dispatch is a plain coroutine: sync listeners run inline, async awaited. + registry = hass.registry + assert not hasattr(registry, "_loop") + assert inspect.iscoroutinefunction(registry.dispatch_options_update) + + calls = [] + + def _sync_listener(h, entry): + calls.append(("sync", entry.entry_id, h is hass)) + + entry = make_entry(entry_id="disp") + registry.add("disp", _sync_listener) + await registry.dispatch_options_update(entry) + assert calls == [("sync", "disp", True)] + + async def _async_listener(h, entry): + calls.append(("async", entry.entry_id)) + + registry.add("disp", _async_listener) + calls.clear() + await registry.async_dispatch_options_update(entry) + assert calls == [("sync", "disp", True), ("async", "disp")] \ No newline at end of file diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..b12ed36 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,199 @@ +"""Tests for the setup/unload lifecycle of NestQuest.""" +from __future__ import annotations + +import asyncio + +from conftest import make_config_entry, make_hass, wire_entry_to_registry + +from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry +from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST + + +def _wire(entry, registry): + return wire_entry_to_registry(entry, registry) + + +async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert entry.entry_id in hass.data[DOMAIN] + assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data + + +async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None: + entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry) + await async_setup_entry(hass, entry) + runtime = entry.runtime_data + assert runtime.entry_id == entry.entry_id + assert runtime.options == {"horizon_days": 7} + assert callable(runtime.remove_update_listener) + + +async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + assert await async_setup_entry(hass, entry) is True + assert registry.size == 1 + assert await async_unload_entry(hass, entry) is True + assert hass.data == {} + assert DOMAIN not in hass.data + assert entry.runtime_data is None + assert registry.size == 0 + + +async def test_unload_of_one_entry_keeps_domain_while_other_remains( + hass, make_entry +) -> None: + """Unloading one of two entries keeps DOMAIN alive with the other entry.""" + registry = hass.registry + entry_a = _wire(make_entry(entry_id="entry_a"), registry) + entry_b = _wire(make_entry(entry_id="entry_b"), registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert set(hass.data[DOMAIN]) == {"entry_a", "entry_b"} + + assert await async_unload_entry(hass, entry_a) is True + assert DOMAIN in hass.data + assert set(hass.data[DOMAIN]) == {"entry_b"} + assert hass.data[DOMAIN]["entry_b"] is entry_b.runtime_data + assert entry_a.runtime_data is None + assert entry_b.runtime_data is not None + assert registry.size == 1 + + assert await async_unload_entry(hass, entry_b) is True + assert DOMAIN not in hass.data + assert hass.data == {} + assert entry_b.runtime_data is None + assert registry.size == 0 + + +async def test_unload_is_idempotent(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 + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + + +async def test_unload_without_setup_returns_true(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + + +async def test_unload_clears_entry_runtime_data(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + assert entry.runtime_data is None + + +async def test_unload_removes_update_listener(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + assert registry.size == 1 + await async_unload_entry(hass, entry) + assert registry.size == 0 + + +async def test_options_update_triggers_reload_once(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + entry.options = {"horizon_days": 30} + await registry.async_dispatch_options_update(entry) + assert hass.config_entries.async_reload.call_count == 1 + assert registry.reloaded == [entry.entry_id] + + +async def test_no_reload_after_unload(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + await registry.async_dispatch_options_update(entry) + assert hass.config_entries.async_reload.call_count == 0 + + +async def test_three_reload_cycles_keep_single_listener(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + for _ in range(3): + assert await async_setup_entry(hass, entry) is True + assert registry.size == 1 + assert await async_unload_entry(hass, entry) is True + assert registry.size == 0 + assert hass.config_entries.async_reload.call_count == 0 + + +async def test_three_setup_unload_cycles_leave_no_listeners(hass, make_entry) -> None: + """Listeners accumulate per entry (HA semantics); unload must remove them all.""" + registry = hass.registry + entry = _wire(make_entry(), registry) + for _ in range(3): + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + assert registry._listeners.get(entry.entry_id, []) == [] + assert registry.size == 0 + + +async def test_options_update_after_each_setup_invokes_exactly_one_listener( + hass, make_entry +) -> None: + """After each setup, an options update dispatches to exactly one listener.""" + registry = hass.registry + entry = _wire(make_entry(), registry) + for _ in range(3): + await async_setup_entry(hass, entry) + await registry.async_dispatch_options_update(entry) + assert hass.config_entries.async_reload.call_count == 1 + assert registry.reloaded == [entry.entry_id] + await async_unload_entry(hass, entry) + hass.config_entries.async_reload.reset_mock() + registry.reloaded.clear() + + +async def test_reload_setup_cycles_dispatch_reload_each_time(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + for _ in range(3): + await async_setup_entry(hass, entry) + await registry.async_dispatch_options_update(entry) + await async_unload_entry(hass, entry) + assert hass.config_entries.async_reload.call_count == 3 + assert registry.size == 0 + + +async def test_no_orphaned_tasks_after_unload(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + assert pending == [] + + +async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + assert set(hass.data) == {DOMAIN_CONST} + + +async def test_setup_awaitable_listener_removal_supported() -> None: + """A coroutine-returning remover is awaited during unload.""" + removed = [] + + async def _remover(): + removed.append(True) + + entry = make_config_entry(entry_id="awaitable") + entry.add_update_listener = lambda listener: _remover + hass, _registry = make_hass() + await async_setup_entry(hass, entry) + result = await async_unload_entry(hass, entry) + assert result is True + assert removed == [True] + assert hass.data == {} + assert entry.runtime_data is None \ No newline at end of file diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..7e74f4b --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,86 @@ +"""Tests for the manifest.json file of NestQuest.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from custom_components.nestquest.const import DOMAIN + +MANIFEST_PATH = Path(__file__).parent.parent / "custom_components" / "nestquest" / "manifest.json" + + +@pytest.fixture +def manifest() -> dict: + """Load and parse manifest.json.""" + assert MANIFEST_PATH.exists(), f"manifest.json does not exist at {MANIFEST_PATH}" + with open(MANIFEST_PATH, encoding="utf-8") as f: + data = json.load(f) + return data + + +def test_manifest_is_valid_json(manifest: dict) -> None: + """Verify manifest.json is valid JSON and parsed into a dictionary.""" + assert isinstance(manifest, dict) + + +def test_manifest_domain(manifest: dict) -> None: + """Verify domain is nestquest.""" + assert manifest.get("domain") == "nestquest" + + +def test_manifest_domain_matches_const(manifest: dict) -> None: + """Verify domain matches DOMAIN constant from const.py.""" + assert manifest.get("domain") == DOMAIN + + +def test_manifest_version(manifest: dict) -> None: + """Verify version is a non-empty string.""" + assert isinstance(manifest.get("version"), str) + assert len(manifest["version"]) > 0 + + +def test_manifest_config_flow(manifest: dict) -> None: + """Verify config_flow is True.""" + assert manifest.get("config_flow") is True + + +def test_manifest_iot_class(manifest: dict) -> None: + """Verify iot_class is local_polling.""" + assert manifest.get("iot_class") == "local_polling" + + +def test_manifest_requirements(manifest: dict) -> None: + """Verify requirements is an empty list.""" + assert manifest.get("requirements") == [] + + +def test_manifest_documentation_url_is_public_github_mirror(manifest: dict) -> None: + """Verify documentation URL points at the public GitHub mirror.""" + assert manifest.get("documentation") == "https://github.com/talon2king/NestQuest" + + +def test_manifest_issue_tracker_url_is_public_github_mirror(manifest: dict) -> None: + """Verify issue_tracker URL points at the public GitHub mirror.""" + assert manifest.get("issue_tracker") == "https://github.com/talon2king/NestQuest/issues" + + +@pytest.mark.parametrize("key", ["documentation", "issue_tracker"]) +def test_manifest_urls_use_github_com(manifest: dict, key: str) -> None: + """Verify manifest URLs use github.com rather than the private Gitea host.""" + url = manifest.get(key, "") + assert url.startswith("https://github.com/talon2king/NestQuest") + assert "cubecraftlabs" not in url + + +@pytest.mark.parametrize( + "key", + ["name", "version", "documentation", "issue_tracker", "codeowners"], +) +def test_manifest_required_keys_exist_and_non_empty(manifest: dict, key: str) -> None: + """Verify required keys exist and are non-empty.""" + assert key in manifest, f"Key '{key}' missing from manifest.json" + val = manifest[key] + assert val is not None, f"Key '{key}' is None" + assert len(val) > 0, f"Key '{key}' is empty" diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py new file mode 100644 index 0000000..9b8dad8 --- /dev/null +++ b/tests/test_options_flow.py @@ -0,0 +1,376 @@ +"""Tests for the options flow of NestQuest.""" +from __future__ import annotations + +import ast +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import make_config_entry, make_hass, options_flow_base as _options_flow_base + +from custom_components.nestquest import config_flow +from custom_components.nestquest import options_flow +from custom_components.nestquest.const import ( + CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, + CONF_PANEL_IDLE_TIMEOUT, + DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_HORIZON_DAYS, + DEFAULT_PANEL_IDLE_TIMEOUT, + DOMAIN, +) + +PKG_DIR = Path(options_flow.__file__).parent +STRINGS_PATH = PKG_DIR / "strings.json" +TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" + +VALID_INPUT = { + CONF_HORIZON_DAYS: 7, + CONF_DAY_ROLLOVER_TIME: "03:30", + CONF_PANEL_IDLE_TIMEOUT: 60, +} + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass(): + return make_hass() + + +def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): + return make_config_entry(entry_id=entry_id, options=options, data=data) + + +def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: + """Create an options flow instance bound to a stubbed config entry.""" + flow = options_flow.NestQuestOptionsFlow(config_entry=entry) + flow.hass = SimpleNamespace() + return flow + + +def _schema_values(schema) -> dict: + """Extract the per-field default values from a real voluptuous schema.""" + return { + marker.schema: marker.default() + for marker in schema.schema + if hasattr(marker, "schema") + } + + +def test_options_flow_reachable_via_configure_button() -> None: + """The config flow exposes async_get_options_flow returning the options flow.""" + entry = _make_entry() + handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry) + assert isinstance(handler, options_flow.NestQuestOptionsFlow) + assert handler.config_entry is entry + + +def test_options_flow_get_options_flow_is_ha_callback() -> None: + """async_get_options_flow is wrapped by the homeassistant.core.callback decorator.""" + assert getattr( + config_flow.NestQuestConfigFlow.async_get_options_flow, "__ha_callback__", False + ) is True + + +def test_options_flow_constructs_through_2024_6_surface() -> None: + """The handler constructs against the mocked 2024.6 base and inherits its contract.""" + entry = _make_entry(options={CONF_HORIZON_DAYS: 21}) + flow = options_flow.NestQuestOptionsFlow(config_entry=entry) + assert isinstance(flow, _options_flow_base()) + assert flow.config_entry is entry + assert flow.options == {CONF_HORIZON_DAYS: 21} + + +def test_options_flow_submits_through_2024_6_surface() -> None: + """The handler can be submitted through the mocked 2024.6-shaped surface.""" + entry = _make_entry() + handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry) + result = _run(handler.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["title"] == "NestQuest" + assert result["data"] == VALID_INPUT + + +def test_options_flow_shows_form_initially() -> None: + """The init step shows a form.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + assert result["type"] == "form" + assert result["step_id"] == "init" + + +def test_options_flow_schema_is_voluptuous() -> None: + """The form's data_schema is a real voluptuous Schema (no dict fallback).""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + import voluptuous as vol + + assert isinstance(result["data_schema"], vol.Schema) + + +def test_options_flow_prefills_from_defaults() -> None: + """Without stored options, the form pre-fills with the constant defaults.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + values = _schema_values(result["data_schema"]) + assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS + assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME + assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT + + +def test_options_flow_prefills_from_entry_options() -> None: + """Stored entry options win over entry data and defaults.""" + entry = _make_entry( + options={ + CONF_HORIZON_DAYS: 21, + CONF_DAY_ROLLOVER_TIME: "05:45", + CONF_PANEL_IDLE_TIMEOUT: 120, + }, + data={ + CONF_HORIZON_DAYS: 99, + CONF_DAY_ROLLOVER_TIME: "23:59", + CONF_PANEL_IDLE_TIMEOUT: 999, + }, + ) + result = _run(_make_flow(entry).async_step_init(None)) + values = _schema_values(result["data_schema"]) + assert values[CONF_HORIZON_DAYS] == 21 + assert values[CONF_DAY_ROLLOVER_TIME] == "05:45" + assert values[CONF_PANEL_IDLE_TIMEOUT] == 120 + + +def test_options_flow_falls_back_to_entry_data() -> None: + """Pre-upgrade entries without options fall back to entry.data.""" + entry = _make_entry( + data={ + CONF_HORIZON_DAYS: 10, + CONF_DAY_ROLLOVER_TIME: "01:15", + CONF_PANEL_IDLE_TIMEOUT: 90, + } + ) + result = _run(_make_flow(entry).async_step_init(None)) + values = _schema_values(result["data_schema"]) + assert values[CONF_HORIZON_DAYS] == 10 + assert values[CONF_DAY_ROLLOVER_TIME] == "01:15" + assert values[CONF_PANEL_IDLE_TIMEOUT] == 90 + + +def test_options_flow_schema_applies_stored_defaults() -> None: + """A real voluptuous validate({}) fills every field from the stored defaults.""" + entry = _make_entry( + options={ + CONF_HORIZON_DAYS: 21, + CONF_DAY_ROLLOVER_TIME: "05:45", + CONF_PANEL_IDLE_TIMEOUT: 120, + } + ) + result = _run(_make_flow(entry).async_step_init(None)) + assert result["data_schema"]({}) == { + CONF_HORIZON_DAYS: 21, + CONF_DAY_ROLLOVER_TIME: "05:45", + CONF_PANEL_IDLE_TIMEOUT: 120, + } + + +def test_options_flow_schema_coerces_field_types() -> None: + """The schema validates the expected field types via voluptuous.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + schema = result["data_schema"] + validators = {marker.schema: validator for marker, validator in schema.schema.items()} + assert validators[CONF_HORIZON_DAYS] is int + assert validators[CONF_DAY_ROLLOVER_TIME] is str + assert validators[CONF_PANEL_IDLE_TIMEOUT] is int + + +@pytest.mark.parametrize("bad_horizon", [0, -5]) +def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None: + """Zero or negative horizon values yield a field-level error, no exception.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"} + + +def test_options_flow_rejects_malformed_time_not_a_time() -> None: + """Non-HH:MM strings are rejected with invalid_time.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} + + +def test_options_flow_rejects_malformed_time_out_of_range() -> None: + """Out-of-range HH:MM values are rejected with invalid_time.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} + + +def test_options_flow_rejects_pasted_unicode_superscript_time() -> None: + """Pasted Unicode digits like '²²:²²' yield a field-level error, no exception.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} + + +@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10]) +def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: + """Non-numeric or too-low idle timeouts yield a field-level error.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_PANEL_IDLE_TIMEOUT: bad_timeout}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} + + +def test_options_flow_creates_entry_with_three_keys() -> None: + """A valid submit creates an entry with exactly the three validated keys.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["title"] == "NestQuest" + assert set(result["data"]) == { + CONF_HORIZON_DAYS, + CONF_DAY_ROLLOVER_TIME, + CONF_PANEL_IDLE_TIMEOUT, + } + assert result["data"] == VALID_INPUT + + +def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: + """The flow's created data is written to entry.options and reloads exactly once.""" + hass, registry = _make_hass() + entry = _make_entry() + entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) + + # Simulate setup registering the merged lifecycle update listener. + from custom_components.nestquest import async_setup_entry + + _run(async_setup_entry(hass, entry)) + + # Run the options flow and apply its created data to entry.options like HA does. + flow = _make_flow(entry) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + entry.options = dict(result["data"]) + + _run(registry.dispatch_options_update(entry)) + assert hass.config_entries.async_reload.call_count == 1 + assert registry.reloaded == [entry.entry_id] + assert entry.options == VALID_INPUT + + +def test_options_flow_validation_never_raises() -> None: + """Validation returns errors instead of raising for arbitrary junk input.""" + junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None} + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(junk)) + assert result["type"] == "form" + assert result["errors"] == { + CONF_HORIZON_DAYS: "invalid", + CONF_DAY_ROLLOVER_TIME: "invalid_time", + CONF_PANEL_IDLE_TIMEOUT: "invalid", + } + + +@pytest.fixture +def strings() -> dict: + """Load and parse strings.json.""" + assert STRINGS_PATH.exists(), f"strings.json does not exist at {STRINGS_PATH}" + with open(STRINGS_PATH, encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def translations_en() -> dict: + """Load and parse translations/en.json.""" + assert TRANSLATIONS_EN_PATH.exists(), ( + f"translations/en.json does not exist at {TRANSLATIONS_EN_PATH}" + ) + with open(TRANSLATIONS_EN_PATH, encoding="utf-8") as f: + return json.load(f) + + +def test_options_strings_present_and_matching(strings: dict, translations_en: dict) -> None: + """Both string files carry identical options step/error strings.""" + assert strings.get("options") == translations_en.get("options") + options = strings["options"] + init = options["step"]["init"] + assert init["title"] + assert init["description"] + data = init["data"] + for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT): + assert data.get(key), f"Missing field name for '{key}'" + errors = options["errors"] + for key in ("invalid", "invalid_time"): + assert errors.get(key), f"Missing error string for '{key}'" + + +def test_options_strings_no_raw_keys(strings: dict, translations_en: dict) -> None: + """No placeholder/raw keys appear in the options strings.""" + + def check(value) -> None: + if isinstance(value, dict): + for key, val in value.items(): + assert val, f"Empty string value for key '{key}'" + check(val) + elif isinstance(value, str): + assert value.strip() != "" + + check(strings["options"]) + check(translations_en["options"]) + + +def test_options_flow_no_plain_dict_schema_fallback() -> None: + """No try/except ImportError fallback for voluptuous in the options flow module.""" + py_file = Path(options_flow.__file__) + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Try): + handlers = node.handlers or [] + has_import_error_handler = any( + getattr(handler.type, "id", getattr(handler.type, "attr", None)) + == "ImportError" + for handler in handlers + ) + assert not has_import_error_handler, ( + f"Plain-dict schema fallback (try/except ImportError) found in {py_file.name}:{node.lineno}" + ) + + +def test_options_flow_subclasses_options_flow_with_config_entry() -> None: + """NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry.""" + base = _options_flow_base() + assert issubclass(options_flow.NestQuestOptionsFlow, base) + assert options_flow.NestQuestOptionsFlow.__bases__ == (base,) + + +def test_options_flow_no_hardcoded_domain_or_db_filename() -> None: + """The options flow module does not hardcode the domain or db filename.""" + py_file = Path(options_flow.__file__) + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + assert node.value != DOMAIN, ( + f"Hard-coded domain string in {py_file.name}:{node.lineno}" + ) + assert node.value != "nestquest.db", ( + f"Hard-coded database filename in {py_file.name}:{node.lineno}" + ) \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..95866b4 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,151 @@ +"""Tests for store.py of NestQuest.""" +from __future__ import annotations + +import ast +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +from custom_components.nestquest import store +from custom_components.nestquest.const import SQLITE_DB_FILENAME + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass(config_dir: Path, execute: bool = True): + hass = MagicMock() + hass.config.path = MagicMock(return_value=str(config_dir / SQLITE_DB_FILENAME)) + hass.async_add_executor_job = AsyncMock( + side_effect=(lambda fn, *a: fn(*a)) if execute else None + ) + return hass + + +def test_returns_absolute_path_from_config_path() -> None: + hass = _make_hass(Path("/config"), execute=False) + db_path = _run(store.async_get_db_path(hass)) + hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME) + assert db_path == Path("/config") / SQLITE_DB_FILENAME + assert db_path.is_absolute() + + +def test_relative_config_path_is_normalized_to_absolute(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + hass = MagicMock() + hass.config.path = MagicMock(return_value="relative-config/" + SQLITE_DB_FILENAME) + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + db_path = _run(store.async_get_db_path(hass)) + assert db_path.is_absolute() + assert db_path.name == SQLITE_DB_FILENAME + assert str(db_path).endswith("relative-config/" + SQLITE_DB_FILENAME) + assert db_path.parent.is_dir() + assert (tmp_path / "relative-config").is_dir() + assert not db_path.exists() + + +def test_creates_missing_parent_directory(tmp_path) -> None: + nested = tmp_path / "homeassistant" / "config" + hass = _make_hass(nested) + db_path = _run(store.async_get_db_path(hass)) + assert db_path.parent == nested + assert nested.is_dir() + assert db_path.parent.is_dir() + assert not db_path.exists() + + +def test_two_calls_return_same_path(tmp_path) -> None: + hass = _make_hass(tmp_path) + first = _run(store.async_get_db_path(hass)) + second = _run(store.async_get_db_path(hass)) + assert first == second + assert first.parent.is_dir() + assert not first.exists() + assert not second.exists() + + +def test_existing_directory_does_not_fail(tmp_path) -> None: + nested = tmp_path / "config" + nested.mkdir() + hass = _make_hass(nested) + db_path = _run(store.async_get_db_path(hass)) + assert db_path == nested / SQLITE_DB_FILENAME + assert db_path.parent.is_dir() + assert not db_path.exists() + + +def test_mkdir_runs_via_executor(tmp_path) -> None: + nested = tmp_path / "deep" / "nested" + hass = _make_hass(nested) + db_path = _run(store.async_get_db_path(hass)) + assert nested.is_dir() + hass.async_add_executor_job.assert_called_once() + args, kwargs = hass.async_add_executor_job.call_args + fn, fn_args = args[0], args[1:] + assert callable(fn) + assert fn is store._ensure_dir + assert fn_args == (nested,) + assert kwargs == {} + assert db_path.parent == nested + assert db_path.parent.is_dir() + assert not db_path.exists() + + +def test_executor_helper_creates_directory(tmp_path) -> None: + nested = tmp_path / "executor" / "made" + store._ensure_dir(nested) + assert nested.is_dir() + store._ensure_dir(nested) + assert nested.is_dir() + + +def test_no_sqlite3_import_in_package() -> None: + pkg_dir = Path(store.__file__).parent + py_files = list(pkg_dir.glob("*.py")) + assert py_files + for py_file in py_files: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert alias.name != "sqlite3", ( + f"Found sqlite3 import in {py_file.name}:{node.lineno}" + ) + elif isinstance(node, ast.ImportFrom) and node.module == "sqlite3": + raise AssertionError( + f"Found sqlite3 import in {py_file.name}:{node.lineno}" + ) + + +def test_no_file_creation_calls_in_store() -> None: + source = Path(store.__file__).read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(store.__file__)) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "open": + raise AssertionError( + f"Found open() call in store.py:{node.lineno}" + ) + if ( + isinstance(func, ast.Attribute) + and func.attr == "touch" + ): + raise AssertionError( + f"Found touch() call in store.py:{node.lineno}" + ) + assert "touch(" not in source + assert "open(" not in source + + +def test_container_style_path_resolves() -> None: + hass = _make_hass(Path("/config"), execute=False) + assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME + + +def test_core_style_path_resolves(tmp_path) -> None: + config_dir = tmp_path / ".homeassistant" + hass = _make_hass(config_dir) + assert _run(store.async_get_db_path(hass)) == config_dir / SQLITE_DB_FILENAME + assert (config_dir / SQLITE_DB_FILENAME).parent.is_dir() \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..185f269 --- /dev/null +++ b/uv.lock @@ -0,0 +1,116 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nestquest" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "voluptuous" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "voluptuous", specifier = ">=0.14" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "voluptuous" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f4/0738e6849858deae22218be3bbb8207ba83a96e9d0ec7e8e8cd67b30e5ca/voluptuous-0.16.0.tar.gz", hash = "sha256:006535e22fed944aec17bef6e8725472476194743c87bd233e912eb463f8ff05", size = 54238, upload-time = "2025-12-18T23:18:46.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/00/0e0da784245c93cf346150ab67634177bf277f93b7a162bb56c928c39c04/voluptuous-0.16.0-py3-none-any.whl", hash = "sha256:ee342095263e1b5afbd4d418cb5adc92810eebfd07696bb033a261210df33db4", size = 31931, upload-time = "2025-12-18T23:18:44.694Z" }, +]