From f787d04a6b247c7af23d3f22628499290daca299 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 13 Sep 2026 14:52:06 -0400 Subject: [PATCH] feat: implement UI config flow with single-instance enforcement (#4) --- custom_components/nestquest/config_flow.py | 28 +++ custom_components/nestquest/strings.json | 17 ++ .../nestquest/translations/en.json | 17 ++ tests/test_config_flow.py | 225 ++++++++++++++++++ 4 files changed, 287 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/config_flow.py b/custom_components/nestquest/config_flow.py new file mode 100644 index 0000000..1d5f1ba --- /dev/null +++ b/custom_components/nestquest/config_flow.py @@ -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) \ 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..46eaefb --- /dev/null +++ b/tests/test_config_flow.py @@ -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}" + ) \ No newline at end of file