Compare commits

..
18 changed files with 1407 additions and 296 deletions
+5 -1
View File
@@ -13,11 +13,15 @@ NestQuest helps households coordinate daily life, chore tracking, custody schedu
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://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`
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.
+4 -1
View File
@@ -50,7 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
runtime_data = hass.data.setdefault(DOMAIN, {}).pop(entry.entry_id, None)
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:
@@ -4,6 +4,7 @@ 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
@@ -14,6 +15,16 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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:
+120
View File
@@ -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
+20
View File
@@ -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
+17
View File
@@ -13,5 +13,22 @@
"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."
}
}
}
@@ -13,5 +13,22 @@
"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."
}
}
}
+3
View File
@@ -9,8 +9,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"
+308
View File
@@ -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
+31 -99
View File
@@ -4,58 +4,6 @@ from __future__ import annotations
import ast
import json
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
# Mock homeassistant modules if not installed in environment
for _mod in (
"homeassistant",
"homeassistant.core",
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
):
sys.modules.setdefault(_mod, MagicMock())
# Wire parent package attributes to the actual module mocks so that
# "from homeassistant import config_entries" resolves to the mocked module.
sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"]
sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"]
class _ConfigFlowBase:
"""Minimal stand-in for homeassistant ConfigFlow."""
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}
def async_abort(self, *, reason, **kwargs):
return {"type": "abort", "reason": reason}
def async_create_entry(self, *, title, data, **kwargs):
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 []
sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase
import pytest
@@ -67,79 +15,63 @@ STRINGS_PATH = PKG_DIR / "strings.json"
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
def _run(coro):
import asyncio
return asyncio.new_event_loop().run_until_complete(coro)
def _make_flow(existing_entries: list | None = None, in_progress: list | None = None):
"""Create a config flow instance with a stubbed hass."""
flow = config_flow.NestQuestConfigFlow()
flow.hass = SimpleNamespace(
config_entries=SimpleNamespace(
async_entries=lambda domain: [
entry
for entry in (existing_entries or [])
if entry.domain == domain
]
)
)
if in_progress:
flow._async_in_progress = lambda: in_progress
return flow
def _make_entry(domain: str):
"""Create a stub config entry with the given domain."""
return SimpleNamespace(domain=domain)
def test_flow_shows_form_initially() -> None:
async def test_flow_shows_form_initially(hass, make_flow) -> None:
"""The initial user step shows a form."""
flow = _make_flow()
result = _run(flow.async_step_user(None))
flow = make_flow(hass)
result = await flow.async_step_user(None)
assert result["type"] == "form"
assert result["step_id"] == "user"
def test_flow_creates_entry_on_submit() -> None:
"""Submitting the user step creates a config entry."""
flow = _make_flow()
result = _run(flow.async_step_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) == []
def test_second_flow_aborts_single_instance() -> None:
async def test_second_flow_aborts_single_instance(
hass, make_flow, make_config_flow_entry
) -> None:
"""A second flow aborts with single_instance_allowed."""
flow = _make_flow([_make_entry(DOMAIN)])
result = _run(flow.async_step_user(None))
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]
def test_flow_aborts_when_in_progress() -> None:
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(in_progress=[{"flow_id": "other"}])
result = _run(flow.async_step_user(None))
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"
def test_flow_sets_stable_unique_id() -> None:
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()
flow = make_flow(hass)
assert flow.context.get("unique_id") is None
_run(flow.async_step_user(None))
await flow.async_step_user(None)
assert flow.context["unique_id"] == DOMAIN
def test_flow_ignores_other_domain_entries() -> None:
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([_make_entry("other_domain")])
result = _run(flow.async_step_user(None))
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"
-6
View File
@@ -5,12 +5,6 @@ import ast
from datetime import datetime
import logging
from pathlib import Path
import sys
from unittest.mock import MagicMock
# Mock homeassistant modules if not installed in environment
for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"):
sys.modules.setdefault(_mod, MagicMock())
from custom_components.nestquest import const
+10
View File
@@ -64,6 +64,16 @@ def test_readme_exists_and_contains_required_sections() -> None:
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}"
+86
View File
@@ -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")]
+132 -183
View File
@@ -2,249 +2,198 @@
from __future__ import annotations
import asyncio
import inspect
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
# Mock homeassistant modules if not installed in environment
for _mod in (
"homeassistant",
"homeassistant.core",
"homeassistant.config_entries",
):
sys.modules.setdefault(_mod, MagicMock())
# Wire parent package attributes to the actual module mocks so that
# "from homeassistant import config_entries" resolves to the mocked module.
sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"]
import pytest
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 _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
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] = []
self._loop = asyncio.new_event_loop()
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
def dispatch_options_update(self, entry) -> None:
for listener in list(self._listeners.get(entry.entry_id, [])):
result = listener(self._hass, entry)
if inspect.isawaitable(result):
self._loop.run_until_complete(result)
@property
def size(self) -> int:
return sum(len(listeners) for listeners in self._listeners.values())
def _make_hass():
hass = SimpleNamespace(
data={},
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)
return hass, registry
def _make_entry(entry_id: str = "test_entry", options: dict | None = None):
entry = SimpleNamespace(entry_id=entry_id, options=dict(options or {}))
entry.add_update_listener = None
return entry
def _wire_entry(entry, registry: _ListenerRegistry):
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
return entry
def test_setup_stores_runtime_data_in_hass_data() -> None:
hass, _ = _make_hass()
entry = _wire_entry(_make_entry(), _ListenerRegistry())
assert _run(async_setup_entry(hass, entry)) is True
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
def test_setup_runtime_data_has_entry_id_and_options() -> None:
hass, _ = _make_hass()
entry = _wire_entry(_make_entry(options={"horizon_days": 7}), _ListenerRegistry())
_run(async_setup_entry(hass, entry))
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)
def test_unload_returns_true_and_removes_hass_data() -> None:
hass, _ = _make_hass()
registry = _ListenerRegistry()
entry = _wire_entry(_make_entry(), registry)
_run(async_setup_entry(hass, entry))
assert _run(async_unload_entry(hass, entry)) is True
assert entry.entry_id not in hass.data[DOMAIN]
def test_unload_is_idempotent() -> None:
hass, _ = _make_hass()
registry = _ListenerRegistry()
entry = _wire_entry(_make_entry(), registry)
_run(async_setup_entry(hass, entry))
assert _run(async_unload_entry(hass, entry)) is True
assert _run(async_unload_entry(hass, entry)) is True
assert entry.entry_id not in hass.data[DOMAIN]
def test_unload_without_setup_returns_true() -> None:
hass, _ = _make_hass()
entry = _make_entry()
assert _run(async_unload_entry(hass, entry)) is True
assert DOMAIN not in hass.data or entry.entry_id not in hass.data[DOMAIN]
def test_unload_clears_entry_runtime_data() -> None:
hass, _ = _make_hass()
registry = _ListenerRegistry()
entry = _wire_entry(_make_entry(), registry)
_run(async_setup_entry(hass, entry))
_run(async_unload_entry(hass, entry))
assert entry.runtime_data is None
def test_unload_removes_update_listener() -> None:
hass, _ = _make_hass()
registry = _ListenerRegistry()
entry = _wire_entry(_make_entry(), registry)
_run(async_setup_entry(hass, entry))
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
_run(async_unload_entry(hass, entry))
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
def test_options_update_triggers_reload_once() -> None:
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
_run(async_setup_entry(hass, entry))
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}
registry.dispatch_options_update(entry)
await registry.async_dispatch_options_update(entry)
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
def test_no_reload_after_unload() -> None:
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
_run(async_setup_entry(hass, entry))
_run(async_unload_entry(hass, entry))
registry.dispatch_options_update(entry)
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
def test_three_reload_cycles_keep_single_listener() -> None:
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
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 _run(async_setup_entry(hass, entry)) is True
assert await async_setup_entry(hass, entry) is True
assert registry.size == 1
assert _run(async_unload_entry(hass, entry)) is True
assert await async_unload_entry(hass, entry) is True
assert registry.size == 0
assert hass.config_entries.async_reload.call_count == 0
def test_three_setup_unload_cycles_leave_no_listeners() -> None:
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."""
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
registry = hass.registry
entry = _wire(make_entry(), registry)
for _ in range(3):
_run(async_setup_entry(hass, entry))
_run(async_unload_entry(hass, entry))
await async_setup_entry(hass, entry)
await async_unload_entry(hass, entry)
assert registry._listeners.get(entry.entry_id, []) == []
assert registry.size == 0
def test_options_update_after_each_setup_invokes_exactly_one_listener() -> None:
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."""
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
registry = hass.registry
entry = _wire(make_entry(), registry)
for _ in range(3):
_run(async_setup_entry(hass, entry))
registry.dispatch_options_update(entry)
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]
_run(async_unload_entry(hass, entry))
await async_unload_entry(hass, entry)
hass.config_entries.async_reload.reset_mock()
registry.reloaded.clear()
def test_reload_setup_cycles_dispatch_reload_each_time() -> None:
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
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):
_run(async_setup_entry(hass, entry))
registry.dispatch_options_update(entry)
_run(async_unload_entry(hass, entry))
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
def test_no_orphaned_tasks_after_unload() -> None:
async def _scenario():
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), 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 == []
_run(_scenario())
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 == []
def test_setup_entry_uses_domain_constant() -> None:
hass, _ = _make_hass()
entry = _wire_entry(_make_entry(), _ListenerRegistry())
_run(async_setup_entry(hass, entry))
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}
def test_setup_awaitable_listener_removal_supported() -> None:
async def test_setup_awaitable_listener_removal_supported() -> None:
"""A coroutine-returning remover is awaited during unload."""
removed = []
async def _scenario():
removed = []
async def _remover():
removed.append(True)
async def _remover():
removed.append(True)
entry = SimpleNamespace(entry_id="awaitable", options={})
entry.add_update_listener = lambda listener: _remover
hass = SimpleNamespace(data={}, config_entries=SimpleNamespace(async_reload=None))
await async_setup_entry(hass, entry)
result = await async_unload_entry(hass, entry)
assert result is True
assert removed == [True]
_run(_scenario())
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
-6
View File
@@ -3,12 +3,6 @@ from __future__ import annotations
import json
from pathlib import Path
import sys
from unittest.mock import MagicMock
# Mock homeassistant modules if not installed in environment
for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"):
sys.modules.setdefault(_mod, MagicMock())
import pytest
+376
View File
@@ -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}"
)
+151
View File
@@ -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()
Generated
+116
View File
@@ -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" },
]