Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc24d50a0e | ||
|
|
30fc3eb1d3 | ||
|
|
ec888b6426 | ||
|
|
5bcaa54934 | ||
|
|
8d04c95314 | ||
|
|
20fc696f8e |
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
@@ -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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`
|
||||
5. Select category **Integration** and click **Add**.
|
||||
6. Search for **NestQuest** and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""The NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = LOGGER
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
|
||||
@@ -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)
|
||||
@@ -1,2 +1,22 @@
|
||||
"""Constants for the NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
PLATFORMS: list[str] = []
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
|
||||
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
|
||||
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
||||
|
||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "NestQuest",
|
||||
"homeassistant": "2024.1.0",
|
||||
"render_readme": true
|
||||
}
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for const.py of NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_domain() -> None:
|
||||
"""Verify DOMAIN is defined and equals 'nestquest'."""
|
||||
assert hasattr(const, "DOMAIN")
|
||||
assert const.DOMAIN == "nestquest"
|
||||
assert isinstance(const.DOMAIN, str)
|
||||
|
||||
|
||||
def test_logger() -> None:
|
||||
"""Verify LOGGER_NAME and LOGGER are defined with correct types and values."""
|
||||
assert hasattr(const, "LOGGER_NAME")
|
||||
assert const.LOGGER_NAME == "custom_components.nestquest"
|
||||
assert isinstance(const.LOGGER_NAME, str)
|
||||
|
||||
assert hasattr(const, "LOGGER")
|
||||
assert isinstance(const.LOGGER, logging.Logger)
|
||||
assert const.LOGGER.name == "custom_components.nestquest"
|
||||
|
||||
|
||||
def test_platforms() -> None:
|
||||
"""Verify PLATFORMS is defined as an empty list."""
|
||||
assert hasattr(const, "PLATFORMS")
|
||||
assert isinstance(const.PLATFORMS, list)
|
||||
assert const.PLATFORMS == []
|
||||
|
||||
|
||||
def test_sqlite_db_filename() -> None:
|
||||
"""Verify SQLITE_DB_FILENAME is defined and equals 'nestquest.db'."""
|
||||
assert hasattr(const, "SQLITE_DB_FILENAME")
|
||||
assert const.SQLITE_DB_FILENAME == "nestquest.db"
|
||||
assert isinstance(const.SQLITE_DB_FILENAME, str)
|
||||
|
||||
|
||||
def test_horizon_days_config() -> None:
|
||||
"""Verify CONF_HORIZON_DAYS and DEFAULT_HORIZON_DAYS are valid."""
|
||||
assert hasattr(const, "CONF_HORIZON_DAYS")
|
||||
assert const.CONF_HORIZON_DAYS == "horizon_days"
|
||||
assert hasattr(const, "DEFAULT_HORIZON_DAYS")
|
||||
assert isinstance(const.DEFAULT_HORIZON_DAYS, int)
|
||||
assert const.DEFAULT_HORIZON_DAYS == 14
|
||||
assert const.DEFAULT_HORIZON_DAYS > 0
|
||||
|
||||
|
||||
def test_day_rollover_time_config() -> None:
|
||||
"""Verify CONF_DAY_ROLLOVER_TIME and DEFAULT_DAY_ROLLOVER_TIME are valid."""
|
||||
assert hasattr(const, "CONF_DAY_ROLLOVER_TIME")
|
||||
assert const.CONF_DAY_ROLLOVER_TIME == "day_rollover_time"
|
||||
assert hasattr(const, "DEFAULT_DAY_ROLLOVER_TIME")
|
||||
assert isinstance(const.DEFAULT_DAY_ROLLOVER_TIME, str)
|
||||
assert const.DEFAULT_DAY_ROLLOVER_TIME == "00:00"
|
||||
# Validate time format HH:MM
|
||||
parsed = datetime.strptime(const.DEFAULT_DAY_ROLLOVER_TIME, "%H:%M")
|
||||
assert parsed.hour == 0 and parsed.minute == 0
|
||||
|
||||
|
||||
def test_panel_idle_timeout_config() -> None:
|
||||
"""Verify CONF_PANEL_IDLE_TIMEOUT and DEFAULT_PANEL_IDLE_TIMEOUT are valid."""
|
||||
assert hasattr(const, "CONF_PANEL_IDLE_TIMEOUT")
|
||||
assert const.CONF_PANEL_IDLE_TIMEOUT == "panel_idle_timeout"
|
||||
assert hasattr(const, "DEFAULT_PANEL_IDLE_TIMEOUT")
|
||||
assert isinstance(const.DEFAULT_PANEL_IDLE_TIMEOUT, int)
|
||||
assert const.DEFAULT_PANEL_IDLE_TIMEOUT == 300
|
||||
assert const.DEFAULT_PANEL_IDLE_TIMEOUT > 0
|
||||
|
||||
|
||||
def test_no_hardcoded_domain_or_db_filename_in_modules() -> None:
|
||||
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
|
||||
pkg_dir = Path(const.__file__).parent
|
||||
py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"]
|
||||
|
||||
assert len(py_files) > 0, "No python modules found in package to check"
|
||||
|
||||
for py_file in py_files:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(content, filename=str(py_file))
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
assert node.value != "nestquest.db", (
|
||||
f"Found hard-coded database filename '{node.value}' in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
if node.value == "nestquest":
|
||||
raise AssertionError(
|
||||
f"Found hard-coded domain string '{node.value}' in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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.1.0"
|
||||
assert hacs_config.get("render_readme") is True
|
||||
|
||||
|
||||
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_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"
|
||||
Reference in New Issue
Block a user