From 30fc3eb1d3cb55db9536bf9a1e769fc78f6da9cf Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 14:39:12 -0400 Subject: [PATCH] feat: implement UI config flow with single-instance enforcement --- custom_components/nestquest/__init__.py | 2 + custom_components/nestquest/config_flow.py | 31 +++ custom_components/nestquest/strings.json | 17 ++ .../nestquest/translations/en.json | 17 ++ tests/test_config_flow.py | 204 ++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 custom_components/nestquest/config_flow.py create mode 100644 custom_components/nestquest/strings.json create mode 100644 custom_components/nestquest/translations/en.json create mode 100644 tests/test_config_flow.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 6588c0a..2adbbb4 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -19,9 +19,11 @@ 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, {}) + hass.data[DOMAIN][entry.entry_id] = entry return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + hass.data.setdefault(DOMAIN, {}).pop(entry.entry_id, None) return True diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py new file mode 100644 index 0000000..78c8d38 --- /dev/null +++ b/custom_components/nestquest/config_flow.py @@ -0,0 +1,31 @@ +"""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.""" + existing = any( + entry.domain == DOMAIN + for entry in self.hass.config_entries.async_entries(DOMAIN) + ) + if existing: + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self.async_show_form(step_id="user") + + return self.async_create_entry(title="NestQuest", data=user_input) \ No newline at end of file diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json new file mode 100644 index 0000000..e297112 --- /dev/null +++ b/custom_components/nestquest/strings.json @@ -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." + } + } +} \ No newline at end of file diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json new file mode 100644 index 0000000..e297112 --- /dev/null +++ b/custom_components/nestquest/translations/en.json @@ -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." + } + } +} \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..400fd2b --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,204 @@ +"""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) + + 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} + + +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): + """Create a config flow instance with a stubbed hass.""" + flow = config_flow.NestQuestConfigFlow() + flow.hass = SimpleNamespace( + config_entries=SimpleNamespace( + async_entries=lambda _domain: existing_entries or [] + ) + ) + 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_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" + + +def test_setup_entry_records_entry_in_hass_data() -> None: + """async_setup_entry stores the entry under hass.data[DOMAIN].""" + from custom_components.nestquest import async_setup_entry, async_unload_entry + + hass = MagicMock() + hass.data = {} + entry = SimpleNamespace(entry_id="abc123") + + _run(async_setup_entry(hass, entry)) + assert hass.data[DOMAIN][entry.entry_id] is entry + + _run(async_unload_entry(hass, entry)) + assert entry.entry_id not in hass.data[DOMAIN] + + +@pytest.fixture +def strings() -> dict: + """Load and parse strings.json.""" + assert STRINGS_PATH.exists(), f"strings.json does not exist at {STRINGS_PATH}" + with open(STRINGS_PATH, encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def translations_en() -> dict: + """Load and parse translations/en.json.""" + assert TRANSLATIONS_EN_PATH.exists(), ( + f"translations/en.json does not exist at {TRANSLATIONS_EN_PATH}" + ) + with open(TRANSLATIONS_EN_PATH, encoding="utf-8") as f: + return json.load(f) + + +@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"]) +def test_flow_strings_valid_json(fixture_name: str, request: pytest.FixtureRequest) -> None: + """strings.json and translations/en.json are valid JSON objects.""" + content = request.getfixturevalue(fixture_name) + assert isinstance(content, dict) + + +@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"]) +def test_flow_strings_structure(fixture_name: str, request: pytest.FixtureRequest) -> None: + """Config flow strings contain title, user step, and abort reasons.""" + content = request.getfixturevalue(fixture_name) + assert content.get("title") + config = content.get("config", {}) + assert config.get("step", {}).get("user", {}).get("title") + assert config.get("step", {}).get("user", {}).get("description") + abort = config.get("abort", {}) + assert "single_instance_allowed" in abort + assert abort["single_instance_allowed"] + + +@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"]) +def test_flow_strings_no_raw_keys(fixture_name: str, request: pytest.FixtureRequest) -> None: + """No placeholder/raw keys appear in the flow strings.""" + content = request.getfixturevalue(fixture_name) + + def check(value) -> None: + if isinstance(value, dict): + for key, val in value.items(): + assert val, f"Empty string value for key '{key}'" + check(val) + elif isinstance(value, str): + assert value.strip() != "" + + check(content) + + +def test_flow_strings_match_translations( + strings: dict, translations_en: dict +) -> None: + """strings.json and translations/en.json carry identical flow strings.""" + assert strings == translations_en + + +def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: + """Ensure no module other than const.py hardcodes the domain or db filename.""" + pkg_dir = Path(config_flow.__file__).parent + py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)] + + assert len(py_files) > 0, "No python modules found in package to check" + + for py_file in py_files: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if node.value == "nestquest": + raise AssertionError( + f"Found hard-coded domain string in {py_file.name}:{node.lineno}" + ) + assert node.value != "nestquest.db", ( + f"Found hard-coded database filename in {py_file.name}:{node.lineno}" + ) \ No newline at end of file