"""Entry setup/unload tests (CI).""" from pathlib import Path from unittest.mock import AsyncMock import pytest @pytest.fixture(autouse=True) def _enable_custom_integrations(enable_custom_integrations): """Allow loading custom_components in the test hass.""" yield from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.houseplan.const import DOMAIN, STORAGE_MINOR_VERSION async def _setup(hass: HomeAssistant) -> MockConfigEntry: entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={}) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry async def test_setup_creates_runtime_data(hass: HomeAssistant) -> None: entry = await _setup(hass) assert entry.state.value == "loaded" assert entry.runtime_data is not None assert entry.runtime_data.store is not None assert entry.runtime_data.config_store is not None assert entry.runtime_data.virtual_light_store is not None async def test_setup_tolerates_unreadable_virtual_light_state( hass: HomeAssistant, monkeypatch ) -> None: """Operational light state must not take the whole integration offline.""" from custom_components.houseplan.store import HouseplanStore real_load = HouseplanStore.async_load async def failing_load(self): if self.key == "houseplan.virtual_lights": raise OSError("corrupt operational store") return await real_load(self) monkeypatch.setattr(HouseplanStore, "async_load", failing_load) entry = await _setup(hass) assert entry.state.value == "loaded" async def test_missing_frontend_bundle_does_not_skip_backend_setup( hass: HomeAssistant, monkeypatch ) -> None: """A packaging fault stays diagnosable without skipping repairs or sweep.""" import custom_components.houseplan as integration from custom_components.houseplan.frontend_registration import ( FRONTEND_REGISTRATION_KEY, ) real_is_file = Path.is_file monkeypatch.setattr( Path, "is_file", lambda path: False if path.name == "houseplan-card.js" else real_is_file(path), ) check_plan_files = AsyncMock() monkeypatch.setattr(integration, "async_check_plan_files", check_plan_files) entry = await _setup(hass) assert entry.state.value == "loaded" assert entry.runtime_data.sweep is not None check_plan_files.assert_awaited_once_with(hass, entry) state = hass.data[DOMAIN][FRONTEND_REGISTRATION_KEY] assert state.card_file_present is False assert state.resource_status == "not_attempted" assert state.loader == "none" async def test_panel_registration_runs_after_repair_and_initial_housekeeping( hass: HomeAssistant, monkeypatch ) -> None: """The optional sidebar surface must be the final entry setup operation.""" import custom_components.houseplan as integration order: list[str] = [] async def check_plan_files(_hass, _entry) -> None: order.append("repair") def sweep_uploads(_path) -> int: order.append("sweep") return 0 async def setup_panel(_hass, entry, path) -> None: assert entry.runtime_data.sweep is not None assert path.name == "houseplan-panel.js" order.append("panel") monkeypatch.setattr(integration, "async_check_plan_files", check_plan_files) monkeypatch.setattr(integration, "sweep_upload_temps", sweep_uploads) monkeypatch.setattr(integration, "collect_attachments", lambda *_: 0) monkeypatch.setattr(integration, "collect_plans", lambda *_: 0) monkeypatch.setattr(integration, "async_setup_panel_registration", setup_panel) entry = await _setup(hass) assert entry.state.value == "loaded" assert order == ["repair", "sweep", "panel"] async def test_panel_api_failure_does_not_abort_entry_setup( hass: HomeAssistant, monkeypatch ) -> None: """Panel registration is fail-soft after the backend has become usable.""" from custom_components.houseplan import panel_registration from custom_components.houseplan.frontend_registration import ( StaticPathRegistrationOutcome, ) real_is_file = Path.is_file monkeypatch.setattr( Path, "is_file", lambda path: True if path.name == "houseplan-panel.js" else real_is_file(path), ) monkeypatch.setattr( panel_registration, "async_register_frontend_static_path", AsyncMock(return_value=StaticPathRegistrationOutcome(True)), ) monkeypatch.setattr( panel_registration.panel_custom, "async_register_panel", AsyncMock(side_effect=RuntimeError("private failure details")), ) entry = await _setup(hass) assert entry.state.value == "loaded" state = panel_registration.get_panel_registration_state(hass) assert state is not None assert state.panel_status == "registration_error" assert state.panel_error == "registration:RuntimeError" async def test_unload(hass: HomeAssistant) -> None: entry = await _setup(hass) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state.value == "not_loaded" async def test_square_migration_finishes_after_a_crash_between_the_writes( hass: HomeAssistant, hass_storage, monkeypatch ) -> None: """HP-1490-01, end to end on the real stores. The layout write is made to fail once, AFTER the config write succeeded — the exact boundary that used to strand the layout in the old coordinates forever, because the config write had already deleted the `aspect` fields the layout half needed. The durable intent must finish the job on the next setup. """ from custom_components.houseplan.store import HouseplanStore hass_storage["houseplan.config"] = { "version": 1, "minor_version": STORAGE_MINOR_VERSION, "data": { "config": {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}], "markers": [], "settings": {}}, "rev": 3, }, } hass_storage["houseplan.layout"] = { "version": 1, "minor_version": STORAGE_MINOR_VERSION, "data": {"layout": {"m": {"s": "f1", "x": 0.1, "y": 0.1}}, "rev": 7}, } real_save = HouseplanStore.async_save state = {"layout_saves": 0} async def failing_save(self, data): if self.key == "houseplan.layout" and "geom_pending" not in data: state["layout_saves"] += 1 if state["layout_saves"] == 1: raise OSError("disk full at the worst possible moment") await real_save(self, data) monkeypatch.setattr(HouseplanStore, "async_save", failing_save) entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={}) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() # the crash boundary: config migrated, layout not, intent saved cfg = hass_storage["houseplan.config"]["data"]["config"] assert "aspect" not in cfg["spaces"][0], "the config half committed" lay = hass_storage["houseplan.layout"]["data"] assert lay["layout"]["m"]["y"] == 0.1, "the layout half did NOT commit" assert lay.get("geom_pending") == {"f1": 2.0}, "but the intent is durable" # next start: the store write works again monkeypatch.setattr(HouseplanStore, "async_save", real_save) if entry.state.value == "loaded": await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() lay = hass_storage["houseplan.layout"]["data"] assert lay["layout"]["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, ( "the saved intent finished the layout half" ) assert "geom_pending" not in lay, "and left with the layout write" cfg = hass_storage["houseplan.config"]["data"]["config"] assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0] # a third start changes nothing — both triggers are gone before = repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"]) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() assert repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"]) == before