Compare commits

..
12 changed files with 930 additions and 0 deletions
+21
View File
@@ -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.
+68
View File
@@ -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. It provides sensors, calendar events, and service calls designed for busy households and modern co-parenting setups.
## 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 <config_dir>/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.
+37
View File
@@ -1,6 +1,9 @@
"""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
@@ -11,6 +14,15 @@ 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
@@ -19,9 +31,34 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
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."""
runtime_data = hass.data.setdefault(DOMAIN, {}).pop(entry.entry_id, 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
@@ -0,0 +1,28 @@
"""Config flow for NestQuest."""
from __future__ import annotations
from typing import Any
from homeassistant import config_entries
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
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)
+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
@@ -0,0 +1,17 @@
{
"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."
}
}
}
@@ -0,0 +1,17 @@
{
"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."
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "NestQuest",
"homeassistant": "2024.6.0",
"render_readme": true
}
+225
View File
@@ -0,0 +1,225 @@
"""Tests for the config flow of NestQuest."""
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
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"
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:
"""The initial user step shows a form."""
flow = _make_flow()
result = _run(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({}))
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert result["data"] == {}
def test_second_flow_aborts_single_instance() -> None:
"""A second flow aborts with single_instance_allowed."""
flow = _make_flow([_make_entry(DOMAIN)])
result = _run(flow.async_step_user(None))
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
def test_flow_aborts_when_in_progress() -> 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))
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
def test_flow_sets_stable_unique_id() -> None:
"""The flow assigns a stable unique id equal to the domain."""
flow = _make_flow()
assert flow.context.get("unique_id") is None
_run(flow.async_step_user(None))
assert flow.context["unique_id"] == DOMAIN
def test_flow_ignores_other_domain_entries() -> None:
"""Entries of other domains do not trigger the abort."""
flow = _make_flow([_make_entry("other_domain")])
result = _run(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}"
)
+82
View File
@@ -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"
+250
View File
@@ -0,0 +1,250 @@
"""Tests for the setup/unload lifecycle of NestQuest."""
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 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)
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
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))
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))
assert registry.size == 1
_run(async_unload_entry(hass, entry))
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))
entry.options = {"horizon_days": 30}
registry.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)
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)
for _ in range(3):
assert _run(async_setup_entry(hass, entry)) is True
assert registry.size == 1
assert _run(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:
"""Listeners accumulate per entry (HA semantics); unload must remove them all."""
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
for _ in range(3):
_run(async_setup_entry(hass, entry))
_run(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:
"""After each setup, an options update dispatches to exactly one listener."""
hass, registry = _make_hass()
entry = _wire_entry(_make_entry(), registry)
for _ in range(3):
_run(async_setup_entry(hass, entry))
registry.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))
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)
for _ in range(3):
_run(async_setup_entry(hass, entry))
registry.dispatch_options_update(entry)
_run(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())
def test_setup_entry_uses_domain_constant() -> None:
hass, _ = _make_hass()
entry = _wire_entry(_make_entry(), _ListenerRegistry())
_run(async_setup_entry(hass, entry))
assert set(hass.data) == {DOMAIN_CONST}
def test_setup_awaitable_listener_removal_supported() -> None:
"""A coroutine-returning remover is awaited during unload."""
async def _scenario():
removed = []
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())
+160
View File
@@ -0,0 +1,160 @@
"""Tests for store.py of NestQuest."""
from __future__ import annotations
import ast
import asyncio
from pathlib import Path
import sys
from unittest.mock import AsyncMock, 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 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()