Files
houseplan-card/tests_backend/test_ha_setup.py
T
2026-09-08 00:31:25 +03:00

230 lines
8.3 KiB
Python

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