Pytest harness (in-process HA + MockPanel)
==========================================
pyproject.toml — bumps requires-python to 3.14.2 to align with HA 2026.5.x
which is what pytest-homeassistant-custom-component pins. Dev group 'ha'
pulls the harness; .python-version updated to 3.14.
src/omni_pca/mock_panel.py — Thermostat (6) and Button (3) RequestProperties
handlers added (previous commit). Without these the HA coordinator's
discovery walk produced empty thermostat/button dicts.
custom_components/omni_pca/services.py — fix CONF_ENTRY_ID import: HA
exports it as ATTR_CONFIG_ENTRY_ID, not CONF_ENTRY_ID. Aliased on import.
tests/conftest.py — re-enables sockets globally (the HA harness installs
pytest_socket which otherwise blocks our network e2e tests).
tests/ha_integration/ — new directory with full HA boot harness:
conftest.py:
- autouse enable_custom_integrations so HA loads our component
- autouse expected_lingering_tasks=True (background event listener)
- autouse _short_scan_interval (1s instead of 30s for fast tests)
- panel fixture: MockPanel on a random localhost port for each test
- configured_panel fixture: builds a MockConfigEntry, runs setup,
yields, then unloads on teardown so the coordinator's reader task
and OmniClient socket close cleanly (otherwise verify_cleanup hangs)
test_setup.py — 12 tests:
- integration loads + system_info populated
- alarm_control_panel/light/switch/climate/button/event/binary_sensor
entities materialise per platform
- unload_entry tears down cleanly
- turning a light on via HA service updates the mock state
- arming via HA service with the right code transitions the area
- arming with wrong code keeps the area disarmed and surfaces error
Total: 351 passed, 1 skipped (PCA fixture). Ruff clean across src/ tests/
custom_components/. The 12 HA integration tests run in <1s end-to-end —
they boot HA in-process, drive the config flow, exercise services, and
verify state mutations on the mock side.
Docker dev stack (manual smoke / screenshots)
=============================================
dev/docker-compose.yml — HA 2026.5 container + MockPanel sidecar.
dev/run_mock_panel.py — long-running mock with a populated state
(5 zones, 4 units, 2 areas, 2 thermostats, 3 buttons, codes 1234/5678).
dev/Makefile — make dev-up / dev-logs / dev-down / dev-mock / dev-reset.
dev/README.md — onboarding walkthrough (host=host.docker.internal,
port=14369, controller_key=000102030405060708090a0b0c0d0e0f).
.gitignore — adds ha-config/ so the persisted HA state from the dev
stack doesn't get committed.
134 lines
4.3 KiB
Python
134 lines
4.3 KiB
Python
"""Fixtures for the HA-side integration tests.
|
|
|
|
Each test gets:
|
|
* a fresh ``MockPanel`` listening on a random localhost port,
|
|
* a HA config entry whose ``host``/``port``/``controller_key`` point at it,
|
|
* a fully booted HA instance with the integration loaded.
|
|
|
|
The HA harness blocks real sockets by default; we re-enable them here
|
|
so the in-process client can talk to the in-process mock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from custom_components.omni_pca.const import CONF_CONTROLLER_KEY, DOMAIN
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
|
from homeassistant.core import HomeAssistant
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from omni_pca.mock_panel import (
|
|
MockAreaState,
|
|
MockButtonState,
|
|
MockPanel,
|
|
MockState,
|
|
MockThermostatState,
|
|
MockUnitState,
|
|
MockZoneState,
|
|
)
|
|
|
|
CONTROLLER_KEY = bytes(range(16))
|
|
CONTROLLER_KEY_HEX = CONTROLLER_KEY.hex()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
|
|
"""Tell HA to load components from ``custom_components/`` for every test."""
|
|
return None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def expected_lingering_tasks() -> bool:
|
|
"""Allow the coordinator's background event-listener task to outlive the
|
|
test body — the integration cancels it on entry unload, but the harness's
|
|
default ``verify_cleanup`` flags any task still alive at teardown."""
|
|
return True
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Cut the 30s polling interval down so tests don't wait on it."""
|
|
from datetime import timedelta
|
|
|
|
from custom_components.omni_pca import const, coordinator
|
|
|
|
fast = timedelta(seconds=1)
|
|
monkeypatch.setattr(const, "SCAN_INTERVAL", fast)
|
|
monkeypatch.setattr(coordinator, "SCAN_INTERVAL", fast)
|
|
|
|
|
|
@pytest.fixture
|
|
def populated_state() -> MockState:
|
|
"""A lightly-populated mock state covering every entity platform."""
|
|
return MockState(
|
|
zones={
|
|
1: MockZoneState(name="FRONT_DOOR"),
|
|
2: MockZoneState(name="GARAGE_ENTRY"),
|
|
10: MockZoneState(name="LIVING_MOTION"),
|
|
},
|
|
units={
|
|
1: MockUnitState(name="LIVING_LAMP"),
|
|
2: MockUnitState(name="KITCHEN_OVERHEAD"),
|
|
},
|
|
areas={
|
|
1: MockAreaState(name="MAIN"),
|
|
},
|
|
thermostats={
|
|
1: MockThermostatState(name="LIVING_ROOM"),
|
|
},
|
|
buttons={
|
|
1: MockButtonState(name="GOOD_MORNING"),
|
|
},
|
|
user_codes={1: 1234},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def panel(populated_state: MockState) -> AsyncIterator[tuple[MockPanel, str, int]]:
|
|
"""Spin up a MockPanel on a random localhost port for the test's lifetime."""
|
|
mock = MockPanel(controller_key=CONTROLLER_KEY, state=populated_state)
|
|
async with mock.serve(host="127.0.0.1") as (host, port):
|
|
yield mock, host, port
|
|
|
|
|
|
@pytest.fixture
|
|
def config_entry_data(panel: tuple[MockPanel, str, int]) -> dict[str, Any]:
|
|
_, host, port = panel
|
|
return {
|
|
CONF_HOST: host,
|
|
CONF_PORT: port,
|
|
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
async def configured_panel(
|
|
hass: HomeAssistant, config_entry_data: dict[str, Any]
|
|
) -> AsyncIterator[ConfigEntry]:
|
|
"""Add a config entry to HA, trigger setup, unload at teardown.
|
|
|
|
The unload step is important — it cancels the coordinator's background
|
|
event-listener task and closes the OmniClient socket. Without it, the
|
|
HA harness's ``verify_cleanup`` hangs waiting for the lingering reader
|
|
coroutine.
|
|
"""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data=config_entry_data,
|
|
title=f"Mock Omni at {config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
|
|
unique_id=f"{config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
|
|
)
|
|
entry.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
try:
|
|
yield entry
|
|
finally:
|
|
if entry.entry_id in hass.data.get(DOMAIN, {}):
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|