"""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}" )