test: align suite with v2.0 runtime_data architecture
The suite predated the coordinator extraction and the typed runtime_data migration: 5 modules failed to import and 18 tests failed. - Import IncorrectDataError/WeatherDataUpdateCoordinator from .coordinator. - Point received_data monkeypatch targets at custom_components.sws12500.coordinator.* - Stub config entries with async_on_unload + runtime_data (SWSRuntimeData). - Rewrite test_data, test_sensor_platform and test_integration_lifecycle off the removed ENTRY_* hass.data keys onto runtime_data (route dispatcher reuse, ecowitt POST route, no hass.data pop on unload, health first_refresh mocked). - Update config_flow tests for the user menu (pws step) and the options menu gaining wslink_port_setup. - Fix VOCLevel.EXCELENT typo and stale t9 expectations (tuple battery list, HCHO int coercion, connection-gated subset). Result: 168 passed, 0 collection errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent
25341f79b3
commit
50d0e9fae7
|
|
@ -13,6 +13,7 @@ from custom_components.sws12500.const import (
|
||||||
ECOWITT_ENABLED,
|
ECOWITT_ENABLED,
|
||||||
ECOWITT_WEBHOOK_ID,
|
ECOWITT_WEBHOOK_ID,
|
||||||
INVALID_CREDENTIALS,
|
INVALID_CREDENTIALS,
|
||||||
|
LEGACY_ENABLED,
|
||||||
POCASI_CZ_API_ID,
|
POCASI_CZ_API_ID,
|
||||||
POCASI_CZ_API_KEY,
|
POCASI_CZ_API_KEY,
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
|
|
@ -36,9 +37,15 @@ async def test_config_flow_user_form_then_create_entry(
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "menu"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
form = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||||
|
)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == "pws"
|
||||||
|
|
||||||
user_input = {
|
user_input = {
|
||||||
API_ID: "my_id",
|
API_ID: "my_id",
|
||||||
API_KEY: "my_key",
|
API_KEY: "my_key",
|
||||||
|
|
@ -46,12 +53,16 @@ async def test_config_flow_user_form_then_create_entry(
|
||||||
DEV_DBG: False,
|
DEV_DBG: False,
|
||||||
}
|
}
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], user_input=user_input
|
form["flow_id"], user_input=user_input
|
||||||
)
|
)
|
||||||
assert result2["type"] == "create_entry"
|
assert result2["type"] == "create_entry"
|
||||||
assert result2["title"] == DOMAIN
|
assert result2["title"] == DOMAIN
|
||||||
assert result2["data"] == user_input
|
# The PWS step augments user input with legacy/ecowitt flags.
|
||||||
assert result2["options"] == user_input
|
assert result2["data"][API_ID] == "my_id"
|
||||||
|
assert result2["data"][API_KEY] == "my_key"
|
||||||
|
assert result2["data"][LEGACY_ENABLED] is True
|
||||||
|
assert result2["data"][ECOWITT_ENABLED] is False
|
||||||
|
assert result2["options"] == result2["data"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -62,7 +73,13 @@ async def test_config_flow_user_invalid_credentials_api_id(
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "menu"
|
||||||
|
|
||||||
|
form = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||||
|
)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == "pws"
|
||||||
|
|
||||||
user_input = {
|
user_input = {
|
||||||
API_ID: INVALID_CREDENTIALS[0],
|
API_ID: INVALID_CREDENTIALS[0],
|
||||||
|
|
@ -71,10 +88,10 @@ async def test_config_flow_user_invalid_credentials_api_id(
|
||||||
DEV_DBG: False,
|
DEV_DBG: False,
|
||||||
}
|
}
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], user_input=user_input
|
form["flow_id"], user_input=user_input
|
||||||
)
|
)
|
||||||
assert result2["type"] == "form"
|
assert result2["type"] == "form"
|
||||||
assert result2["step_id"] == "user"
|
assert result2["step_id"] == "pws"
|
||||||
assert result2["errors"][API_ID] == "valid_credentials_api"
|
assert result2["errors"][API_ID] == "valid_credentials_api"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,7 +103,13 @@ async def test_config_flow_user_invalid_credentials_api_key(
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "menu"
|
||||||
|
|
||||||
|
form = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||||
|
)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == "pws"
|
||||||
|
|
||||||
user_input = {
|
user_input = {
|
||||||
API_ID: "ok_id",
|
API_ID: "ok_id",
|
||||||
|
|
@ -95,10 +118,10 @@ async def test_config_flow_user_invalid_credentials_api_key(
|
||||||
DEV_DBG: False,
|
DEV_DBG: False,
|
||||||
}
|
}
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], user_input=user_input
|
form["flow_id"], user_input=user_input
|
||||||
)
|
)
|
||||||
assert result2["type"] == "form"
|
assert result2["type"] == "form"
|
||||||
assert result2["step_id"] == "user"
|
assert result2["step_id"] == "pws"
|
||||||
assert result2["errors"][API_KEY] == "valid_credentials_key"
|
assert result2["errors"][API_KEY] == "valid_credentials_key"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,7 +133,13 @@ async def test_config_flow_user_invalid_credentials_match(
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "menu"
|
||||||
|
|
||||||
|
form = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||||
|
)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == "pws"
|
||||||
|
|
||||||
user_input = {
|
user_input = {
|
||||||
API_ID: "same",
|
API_ID: "same",
|
||||||
|
|
@ -119,10 +148,10 @@ async def test_config_flow_user_invalid_credentials_match(
|
||||||
DEV_DBG: False,
|
DEV_DBG: False,
|
||||||
}
|
}
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], user_input=user_input
|
form["flow_id"], user_input=user_input
|
||||||
)
|
)
|
||||||
assert result2["type"] == "form"
|
assert result2["type"] == "form"
|
||||||
assert result2["step_id"] == "user"
|
assert result2["step_id"] == "pws"
|
||||||
assert result2["errors"]["base"] == "valid_credentials_match"
|
assert result2["errors"]["base"] == "valid_credentials_match"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -135,7 +164,7 @@ async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None:
|
||||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
assert result["type"] == "menu"
|
assert result["type"] == "menu"
|
||||||
assert result["step_id"] == "init"
|
assert result["step_id"] == "init"
|
||||||
assert set(result["menu_options"]) == {"basic", "ecowitt", "windy", "pocasi"}
|
assert set(result["menu_options"]) == {"basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,46 @@
|
||||||
from custom_components.sws12500.data import (
|
"""Tests for the typed per-entry runtime data container."""
|
||||||
ENTRY_ADD_ENTITIES,
|
|
||||||
ENTRY_COORDINATOR,
|
from __future__ import annotations
|
||||||
ENTRY_DESCRIPTIONS,
|
|
||||||
ENTRY_LAST_OPTIONS,
|
from custom_components.sws12500.data import SWSRuntimeData
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_data_constants():
|
def test_runtime_data_defaults():
|
||||||
assert ENTRY_COORDINATOR == "coordinator"
|
"""SWSRuntimeData exposes the expected fields with safe defaults.
|
||||||
assert ENTRY_ADD_ENTITIES == "async_add_entities"
|
|
||||||
assert ENTRY_DESCRIPTIONS == "sensor_descriptions"
|
The ad-hoc hass.data[DOMAIN][entry_id] string keys (ENTRY_*) were replaced by
|
||||||
assert ENTRY_LAST_OPTIONS == "last_options"
|
this typed dataclass stored on entry.runtime_data in v2.0.
|
||||||
|
"""
|
||||||
|
runtime = SWSRuntimeData(
|
||||||
|
coordinator=object(), # type: ignore[arg-type]
|
||||||
|
health_coordinator=object(), # type: ignore[arg-type]
|
||||||
|
last_options={"legacy_enabled": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.last_options == {"legacy_enabled": True}
|
||||||
|
|
||||||
|
# Optional platform wiring defaults.
|
||||||
|
assert runtime.add_sensor_entities is None
|
||||||
|
assert runtime.sensor_descriptions == {}
|
||||||
|
assert runtime.add_binary_entities is None
|
||||||
|
assert runtime.binary_descriptions == {}
|
||||||
|
assert runtime.added_binary_keys == set()
|
||||||
|
|
||||||
|
# Diagnostics / staleness defaults.
|
||||||
|
assert runtime.health_data is None
|
||||||
|
assert runtime.last_seen == {}
|
||||||
|
assert runtime.started_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_data_collections_are_independent_per_instance():
|
||||||
|
"""Mutable defaults must not be shared between instances."""
|
||||||
|
a = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type]
|
||||||
|
b = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
a.sensor_descriptions["x"] = object() # type: ignore[assignment]
|
||||||
|
a.added_binary_keys.add("y")
|
||||||
|
a.last_seen["z"] = object() # type: ignore[assignment]
|
||||||
|
|
||||||
|
assert b.sensor_descriptions == {}
|
||||||
|
assert b.added_binary_keys == set()
|
||||||
|
assert b.last_seen == {}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry
|
from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry
|
||||||
from custom_components.sws12500.const import DOMAIN
|
from custom_components.sws12500.const import DOMAIN
|
||||||
|
from custom_components.sws12500.data import SWSRuntimeData
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -43,6 +44,14 @@ async def test_async_setup_entry_creates_runtime_state(
|
||||||
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the
|
||||||
|
# health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does
|
||||||
|
# network I/O) is mocked out to keep this test focused on setup wiring.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
# Avoid depending on Home Assistant integration loader in this test.
|
# Avoid depending on Home Assistant integration loader in this test.
|
||||||
# This keeps the test focused on our integration's setup behavior.
|
# This keeps the test focused on our integration's setup behavior.
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -54,9 +63,12 @@ async def test_async_setup_entry_creates_runtime_state(
|
||||||
result = await async_setup_entry(hass, config_entry)
|
result = await async_setup_entry(hass, config_entry)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData), not in
|
||||||
|
# hass.data[DOMAIN][entry_id]. hass.data[DOMAIN] only holds shared route state.
|
||||||
assert DOMAIN in hass.data
|
assert DOMAIN in hass.data
|
||||||
assert config_entry.entry_id in hass.data[DOMAIN]
|
assert isinstance(config_entry.runtime_data, SWSRuntimeData)
|
||||||
assert isinstance(hass.data[DOMAIN][config_entry.entry_id], dict)
|
assert config_entry.runtime_data.coordinator is not None
|
||||||
|
assert config_entry.runtime_data.health_coordinator is not None
|
||||||
|
|
||||||
|
|
||||||
async def test_async_setup_entry_forwards_sensor_platform(
|
async def test_async_setup_entry_forwards_sensor_platform(
|
||||||
|
|
@ -72,6 +84,14 @@ async def test_async_setup_entry_forwards_sensor_platform(
|
||||||
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the
|
||||||
|
# health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does
|
||||||
|
# network I/O) is mocked out to keep this test focused on setup wiring.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
# Patch forwarding so we don't need to load real platforms for this unit/integration test.
|
# Patch forwarding so we don't need to load real platforms for this unit/integration test.
|
||||||
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
|
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,23 @@ from aiohttp.web_exceptions import HTTPUnauthorized
|
||||||
import pytest
|
import pytest
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.sws12500 import (
|
from custom_components.sws12500 import async_setup_entry, async_unload_entry, register_path, update_listener
|
||||||
HealthCoordinator,
|
|
||||||
IncorrectDataError,
|
|
||||||
WeatherDataUpdateCoordinator,
|
|
||||||
async_setup_entry,
|
|
||||||
async_unload_entry,
|
|
||||||
register_path,
|
|
||||||
update_listener,
|
|
||||||
)
|
|
||||||
from custom_components.sws12500.const import (
|
from custom_components.sws12500.const import (
|
||||||
API_ID,
|
API_ID,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
SENSORS_TO_LOAD,
|
SENSORS_TO_LOAD,
|
||||||
WSLINK,
|
WSLINK,
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
)
|
)
|
||||||
from custom_components.sws12500.data import ENTRY_COORDINATOR, ENTRY_LAST_OPTIONS
|
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
|
||||||
|
from custom_components.sws12500.data import SWSRuntimeData
|
||||||
|
from custom_components.sws12500.health_coordinator import HealthCoordinator
|
||||||
|
|
||||||
|
ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -70,16 +67,27 @@ def hass_with_http(hass):
|
||||||
return hass
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_health_first_refresh(monkeypatch) -> None:
|
||||||
|
"""Calling async_setup_entry directly leaves the entry NOT_LOADED.
|
||||||
|
|
||||||
|
The health coordinator's first refresh requires SETUP_IN_PROGRESS and does network
|
||||||
|
I/O, so we mock it out to keep these lifecycle tests focused on wiring.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- register_path ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_http):
|
async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_http):
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={},
|
data={},
|
||||||
options={
|
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||||
API_ID: "id",
|
|
||||||
API_KEY: "key",
|
|
||||||
WSLINK: False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
|
|
@ -89,21 +97,19 @@ async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_ht
|
||||||
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
|
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|
||||||
# Router registrations
|
# Router registrations: GET for legacy/wslink/health, POST for wslink + ecowitt.
|
||||||
router: _RouterStub = hass_with_http.http.app.router
|
router: _RouterStub = hass_with_http.http.app.router
|
||||||
assert [p for (p, _h) in router.add_get_calls] == [
|
assert [p for (p, _h) in router.add_get_calls] == [
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
]
|
]
|
||||||
assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL]
|
assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL, ECOWITT_PATH]
|
||||||
|
|
||||||
# Dispatcher stored
|
# Dispatcher stored under the shared (cross-reload) hass.data[DOMAIN].
|
||||||
assert DOMAIN in hass_with_http.data
|
assert DOMAIN in hass_with_http.data
|
||||||
assert "routes" in hass_with_http.data[DOMAIN]
|
routes = hass_with_http.data[DOMAIN].get("routes")
|
||||||
routes = hass_with_http.data[DOMAIN]["routes"]
|
|
||||||
assert routes is not None
|
assert routes is not None
|
||||||
# show_enabled() should return a string
|
|
||||||
assert isinstance(routes.show_enabled(), str)
|
assert isinstance(routes.show_enabled(), str)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,18 +122,13 @@ async def test_register_path_raises_config_entry_not_ready_on_router_runtime_err
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={},
|
data={},
|
||||||
options={
|
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||||
API_ID: "id",
|
|
||||||
API_KEY: "key",
|
|
||||||
WSLINK: False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
||||||
coordinator_health = HealthCoordinator(hass_with_http, entry)
|
coordinator_health = HealthCoordinator(hass_with_http, entry)
|
||||||
|
|
||||||
# Make router raise RuntimeError on add
|
|
||||||
router: _RouterStub = hass_with_http.http.app.router
|
router: _RouterStub = hass_with_http.http.app.router
|
||||||
router.raise_on_add = RuntimeError("router broken")
|
router.raise_on_add = RuntimeError("router broken")
|
||||||
|
|
||||||
|
|
@ -145,26 +146,24 @@ async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_no
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={},
|
data={},
|
||||||
options={
|
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||||
API_ID: "id",
|
|
||||||
API_KEY: "key",
|
|
||||||
WSLINK: False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
||||||
coordinator_health = HealthCoordinator(hass_with_http, entry)
|
coordinator_health = HealthCoordinator(hass_with_http, entry)
|
||||||
|
|
||||||
# Force wrong type under DOMAIN so `checked(..., dict)` fails.
|
hass_with_http.data[DOMAIN] = [] # wrong type -> checked(..., dict) fails
|
||||||
hass_with_http.data[DOMAIN] = []
|
|
||||||
|
|
||||||
with pytest.raises(ConfigEntryNotReady):
|
with pytest.raises(ConfigEntryNotReady):
|
||||||
register_path(hass_with_http, coordinator, coordinator_health, entry)
|
register_path(hass_with_http, coordinator, coordinator_health, entry)
|
||||||
|
|
||||||
|
|
||||||
|
# --- async_setup_entry -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards_platforms(
|
async def test_async_setup_entry_creates_runtime_data_and_forwards_platforms(
|
||||||
hass_with_http,
|
hass_with_http,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
):
|
):
|
||||||
|
|
@ -175,7 +174,7 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
# Avoid loading actual platforms via HA loader.
|
_mock_health_first_refresh(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
hass_with_http.config_entries,
|
hass_with_http.config_entries,
|
||||||
"async_forward_entry_setups",
|
"async_forward_entry_setups",
|
||||||
|
|
@ -185,17 +184,14 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards
|
||||||
ok = await async_setup_entry(hass_with_http, entry)
|
ok = await async_setup_entry(hass_with_http, entry)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|
||||||
# Runtime storage exists and is a dict
|
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData).
|
||||||
assert DOMAIN in hass_with_http.data
|
assert isinstance(entry.runtime_data, SWSRuntimeData)
|
||||||
assert entry.entry_id in hass_with_http.data[DOMAIN]
|
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
|
||||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
assert isinstance(entry.runtime_data.last_options, dict)
|
||||||
assert isinstance(entry_data, dict)
|
|
||||||
|
|
||||||
# Coordinator stored and last options snapshot stored
|
# Shared dispatcher registered under hass.data[DOMAIN].
|
||||||
assert isinstance(entry_data.get(ENTRY_COORDINATOR), WeatherDataUpdateCoordinator)
|
assert "routes" in hass_with_http.data[DOMAIN]
|
||||||
assert isinstance(entry_data.get(ENTRY_LAST_OPTIONS), dict)
|
|
||||||
|
|
||||||
# Forwarded setups invoked
|
|
||||||
hass_with_http.config_entries.async_forward_entry_setups.assert_awaited()
|
hass_with_http.config_entries.async_forward_entry_setups.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -203,12 +199,7 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards
|
||||||
async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
||||||
hass_with_http, monkeypatch
|
hass_with_http, monkeypatch
|
||||||
):
|
):
|
||||||
"""Cover the fatal branch when `register_path` returns False.
|
"""Cover the fatal branch when `register_path` returns False -> PlatformNotReady."""
|
||||||
|
|
||||||
async_setup_entry does:
|
|
||||||
routes_enabled = register_path(...)
|
|
||||||
if not routes_enabled: raise PlatformNotReady
|
|
||||||
"""
|
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
|
|
@ -218,17 +209,14 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
# Ensure there are no pre-registered routes so async_setup_entry calls register_path.
|
# No pre-registered routes -> async_setup_entry calls register_path.
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
hass_with_http.data.setdefault(DOMAIN, {})
|
||||||
hass_with_http.data[DOMAIN].pop("routes", None)
|
hass_with_http.data[DOMAIN].pop("routes", None)
|
||||||
|
|
||||||
# Force register_path to return False
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.register_path",
|
"custom_components.sws12500.register_path",
|
||||||
lambda _hass, _coordinator, _coordinator_h, _entry: False,
|
lambda _hass, _coordinator, _coordinator_h, _entry: False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Forwarding shouldn't be reached; patch anyway to avoid accidental loader calls.
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
hass_with_http.config_entries,
|
hass_with_http.config_entries,
|
||||||
"async_forward_entry_setups",
|
"async_forward_entry_setups",
|
||||||
|
|
@ -240,10 +228,11 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes(
|
async def test_async_setup_entry_reuses_route_dispatcher_and_switches_protocol(
|
||||||
hass_with_http,
|
hass_with_http,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
):
|
):
|
||||||
|
"""On reload the shared route dispatcher is reused; the coordinator is recreated."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={},
|
data={},
|
||||||
|
|
@ -251,29 +240,19 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
# Pretend setup already happened and a coordinator exists
|
# Pre-register routes once (legacy/WU active).
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
initial_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
||||||
existing_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
initial_health = HealthCoordinator(hass_with_http, entry)
|
||||||
hass_with_http.data[DOMAIN][entry.entry_id] = {
|
register_path(hass_with_http, initial_coordinator, initial_health, entry)
|
||||||
ENTRY_COORDINATOR: existing_coordinator,
|
routes_before = hass_with_http.data[DOMAIN]["routes"]
|
||||||
ENTRY_LAST_OPTIONS: dict(entry.options),
|
assert routes_before.path_enabled(DEFAULT_URL) is True
|
||||||
}
|
|
||||||
|
|
||||||
# Provide pre-registered routes dispatcher
|
# Switch to WSLink and run setup again.
|
||||||
routes = hass_with_http.data[DOMAIN].get("routes")
|
|
||||||
if routes is None:
|
|
||||||
# Create a dispatcher via register_path once
|
|
||||||
coordinator_health = HealthCoordinator(hass_with_http, entry)
|
|
||||||
register_path(hass_with_http, existing_coordinator, coordinator_health, entry)
|
|
||||||
routes = hass_with_http.data[DOMAIN]["routes"]
|
|
||||||
|
|
||||||
# Turn on WSLINK to trigger dispatcher switching.
|
|
||||||
# ConfigEntry.options cannot be changed directly; use async_update_entry.
|
|
||||||
hass_with_http.config_entries.async_update_entry(
|
hass_with_http.config_entries.async_update_entry(
|
||||||
entry, options={**dict(entry.options), WSLINK: True}
|
entry, options={**dict(entry.options), WSLINK: True}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Avoid loading actual platforms via HA loader.
|
_mock_health_first_refresh(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
hass_with_http.config_entries,
|
hass_with_http.config_entries,
|
||||||
"async_forward_entry_setups",
|
"async_forward_entry_setups",
|
||||||
|
|
@ -283,34 +262,42 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes
|
||||||
ok = await async_setup_entry(hass_with_http, entry)
|
ok = await async_setup_entry(hass_with_http, entry)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|
||||||
# Coordinator reused (same object)
|
# Same dispatcher object reused (survives across reloads).
|
||||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
assert hass_with_http.data[DOMAIN]["routes"] is routes_before
|
||||||
assert entry_data[ENTRY_COORDINATOR] is existing_coordinator
|
# Protocol switched to WSLink.
|
||||||
|
assert routes_before.path_enabled(WSLINK_URL) is True
|
||||||
|
assert routes_before.path_enabled(DEFAULT_URL) is False
|
||||||
|
# A fresh coordinator is wired onto entry.runtime_data.
|
||||||
|
assert isinstance(entry.runtime_data, SWSRuntimeData)
|
||||||
|
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
|
||||||
|
|
||||||
|
|
||||||
|
# --- update_listener -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_with_runtime(hass, *, options: dict[str, Any]) -> MockConfigEntry:
|
||||||
|
entry = MockConfigEntry(domain=DOMAIN, data={}, options=options)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
entry.runtime_data = SWSRuntimeData(
|
||||||
|
coordinator=object(), # type: ignore[arg-type]
|
||||||
|
health_coordinator=object(), # type: ignore[arg-type]
|
||||||
|
last_options=dict(options),
|
||||||
|
)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
|
async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
|
||||||
hass_with_http,
|
hass_with_http,
|
||||||
):
|
):
|
||||||
entry = MockConfigEntry(
|
entry = _entry_with_runtime(
|
||||||
domain=DOMAIN,
|
hass_with_http,
|
||||||
data={},
|
|
||||||
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]},
|
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]},
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
|
||||||
|
|
||||||
# Seed hass.data snapshot
|
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
|
||||||
hass_with_http.data[DOMAIN][entry.entry_id] = {
|
|
||||||
# Seed the full old options snapshot. If we only store SENSORS_TO_LOAD here,
|
|
||||||
# update_listener will detect differences for other keys (e.g. auth keys) and reload.
|
|
||||||
ENTRY_LAST_OPTIONS: dict(entry.options),
|
|
||||||
}
|
|
||||||
|
|
||||||
hass_with_http.config_entries.async_reload = AsyncMock()
|
hass_with_http.config_entries.async_reload = AsyncMock()
|
||||||
|
|
||||||
# Only SENSORS_TO_LOAD changes.
|
# Only SENSORS_TO_LOAD changes.
|
||||||
# ConfigEntry.options cannot be changed directly; use async_update_entry.
|
|
||||||
hass_with_http.config_entries.async_update_entry(
|
hass_with_http.config_entries.async_update_entry(
|
||||||
entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]}
|
entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]}
|
||||||
)
|
)
|
||||||
|
|
@ -318,9 +305,8 @@ async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
|
||||||
await update_listener(hass_with_http, entry)
|
await update_listener(hass_with_http, entry)
|
||||||
|
|
||||||
hass_with_http.config_entries.async_reload.assert_not_awaited()
|
hass_with_http.config_entries.async_reload.assert_not_awaited()
|
||||||
# Snapshot should be updated
|
# The snapshot on runtime_data is refreshed.
|
||||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
assert entry.runtime_data.last_options == dict(entry.options)
|
||||||
assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -328,22 +314,13 @@ async def test_update_listener_triggers_reload_when_other_option_changes(
|
||||||
hass_with_http,
|
hass_with_http,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
):
|
):
|
||||||
entry = MockConfigEntry(
|
entry = _entry_with_runtime(
|
||||||
domain=DOMAIN,
|
hass_with_http,
|
||||||
data={},
|
|
||||||
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
|
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
|
||||||
|
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
|
||||||
hass_with_http.data[DOMAIN][entry.entry_id] = {
|
|
||||||
ENTRY_LAST_OPTIONS: dict(entry.options),
|
|
||||||
}
|
|
||||||
|
|
||||||
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
|
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
|
||||||
|
|
||||||
# Change a different option.
|
|
||||||
# ConfigEntry.options cannot be changed directly; use async_update_entry.
|
|
||||||
hass_with_http.config_entries.async_update_entry(
|
hass_with_http.config_entries.async_update_entry(
|
||||||
entry, options={**dict(entry.options), WSLINK: True}
|
entry, options={**dict(entry.options), WSLINK: True}
|
||||||
)
|
)
|
||||||
|
|
@ -358,76 +335,58 @@ async def test_update_listener_triggers_reload_when_other_option_changes(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_listener_missing_snapshot_stores_current_options_then_reloads(
|
async def test_update_listener_without_runtime_snapshot_reloads(hass_with_http):
|
||||||
hass_with_http,
|
"""When runtime_data is not a valid snapshot, update_listener reloads."""
|
||||||
):
|
|
||||||
"""Cover update_listener branch where the options snapshot is missing/invalid.
|
|
||||||
|
|
||||||
This hits:
|
|
||||||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
|
||||||
and then proceeds to reload.
|
|
||||||
"""
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={},
|
data={},
|
||||||
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
|
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
# Not an SWSRuntimeData instance -> the skip-reload fast path is bypassed.
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
entry.runtime_data = "invalid"
|
||||||
# Store an invalid snapshot type to force the "No/invalid snapshot" branch.
|
|
||||||
hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_LAST_OPTIONS: "invalid"}
|
|
||||||
|
|
||||||
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
|
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
|
||||||
|
|
||||||
await update_listener(hass_with_http, entry)
|
await update_listener(hass_with_http, entry)
|
||||||
|
|
||||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
|
||||||
assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options)
|
|
||||||
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
|
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
# --- async_unload_entry ----------------------------------------------------
|
||||||
async def test_async_unload_entry_pops_runtime_data_on_success(hass_with_http):
|
|
||||||
entry = MockConfigEntry(
|
|
||||||
domain=DOMAIN,
|
|
||||||
data={},
|
|
||||||
options={API_ID: "id", API_KEY: "key"},
|
|
||||||
)
|
|
||||||
entry.add_to_hass(hass_with_http)
|
|
||||||
|
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
|
||||||
hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()}
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_unload_entry_returns_true_on_success(hass_with_http):
|
||||||
|
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
|
||||||
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
|
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
|
||||||
|
|
||||||
ok = await async_unload_entry(hass_with_http, entry)
|
ok = await async_unload_entry(hass_with_http, entry)
|
||||||
|
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert entry.entry_id not in hass_with_http.data[DOMAIN]
|
hass_with_http.config_entries.async_unload_platforms.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_unload_entry_keeps_runtime_data_on_failure(hass_with_http):
|
async def test_async_unload_entry_returns_false_on_failure(hass_with_http):
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
|
||||||
domain=DOMAIN,
|
|
||||||
data={},
|
|
||||||
options={API_ID: "id", API_KEY: "key"},
|
|
||||||
)
|
|
||||||
entry.add_to_hass(hass_with_http)
|
entry.add_to_hass(hass_with_http)
|
||||||
|
|
||||||
hass_with_http.data.setdefault(DOMAIN, {})
|
|
||||||
hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()}
|
|
||||||
|
|
||||||
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=False)
|
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=False)
|
||||||
|
|
||||||
ok = await async_unload_entry(hass_with_http, entry)
|
ok = await async_unload_entry(hass_with_http, entry)
|
||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert entry.entry_id in hass_with_http.data[DOMAIN]
|
|
||||||
|
|
||||||
|
# --- coordinator auth (lifecycle-adjacent) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
||||||
"""A few lifecycle-adjacent assertions to cover coordinator auth behavior in __init__.py."""
|
"""Cover coordinator auth behavior reachable from the webhook entrypoint."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={},
|
data={},
|
||||||
|
|
@ -447,9 +406,7 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
||||||
) # type: ignore[arg-type]
|
) # type: ignore[arg-type]
|
||||||
|
|
||||||
# Missing API_ID in options -> IncorrectDataError
|
# Missing API_ID in options -> IncorrectDataError
|
||||||
entry2 = MockConfigEntry(
|
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
|
||||||
domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False}
|
|
||||||
)
|
|
||||||
entry2.add_to_hass(hass)
|
entry2.add_to_hass(hass)
|
||||||
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
|
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
|
||||||
with pytest.raises(IncorrectDataError):
|
with pytest.raises(IncorrectDataError):
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
from aiohttp.web_exceptions import HTTPUnauthorized
|
from aiohttp.web_exceptions import HTTPUnauthorized
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.sws12500 import IncorrectDataError, WeatherDataUpdateCoordinator
|
|
||||||
from custom_components.sws12500.const import (
|
from custom_components.sws12500.const import (
|
||||||
API_ID,
|
API_ID,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
|
|
@ -20,6 +19,8 @@ from custom_components.sws12500.const import (
|
||||||
WSLINK,
|
WSLINK,
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
)
|
)
|
||||||
|
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -60,6 +61,18 @@ def _make_entry(
|
||||||
entry = SimpleNamespace()
|
entry = SimpleNamespace()
|
||||||
entry.entry_id = "test_entry_id"
|
entry.entry_id = "test_entry_id"
|
||||||
entry.options = options
|
entry.options = options
|
||||||
|
# DataUpdateCoordinator.__init__ calls config_entry.async_on_unload(...) when a
|
||||||
|
# config_entry is passed (see WeatherDataUpdateCoordinator.__init__).
|
||||||
|
entry.async_on_unload = lambda *_args, **_kwargs: None
|
||||||
|
# Per-entry runtime state lives on entry.runtime_data (SWSRuntimeData) since v2.0.
|
||||||
|
# received_data writes last_seen and reads health_coordinator / add_*_entities.
|
||||||
|
entry.runtime_data = SimpleNamespace(
|
||||||
|
health_coordinator=None,
|
||||||
|
add_sensor_entities=None,
|
||||||
|
add_binary_entities=None,
|
||||||
|
last_seen={},
|
||||||
|
started_at=dt_util.utcnow(),
|
||||||
|
)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -135,13 +148,13 @@ async def test_received_data_success_remaps_and_updates_coordinator_data(
|
||||||
# Patch remapping so this test doesn't depend on mapping tables.
|
# Patch remapping so this test doesn't depend on mapping tables.
|
||||||
remapped = {"outside_temp": "10"}
|
remapped = {"outside_temp": "10"}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.remap_items",
|
"custom_components.sws12500.coordinator.remap_items",
|
||||||
lambda _data: remapped,
|
lambda _data: remapped,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure no autodiscovery triggers
|
# Ensure no autodiscovery triggers
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: [],
|
lambda _remaped_items, _config: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -162,12 +175,12 @@ async def test_received_data_success_wslink_uses_wslink_remap(hass, monkeypatch)
|
||||||
|
|
||||||
remapped = {"ws_temp": "1"}
|
remapped = {"ws_temp": "1"}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.remap_wslink_items",
|
"custom_components.sws12500.coordinator.remap_wslink_items",
|
||||||
lambda _data: remapped,
|
lambda _data: remapped,
|
||||||
)
|
)
|
||||||
# If the wrong remapper is used, we'd crash because we won't patch it:
|
# If the wrong remapper is used, we'd crash because we won't patch it:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: [],
|
lambda _remaped_items, _config: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -188,11 +201,11 @@ async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch):
|
||||||
coordinator.windy.push_data_to_windy = AsyncMock()
|
coordinator.windy.push_data_to_windy = AsyncMock()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.remap_items",
|
"custom_components.sws12500.coordinator.remap_items",
|
||||||
lambda _data: {"k": "v"},
|
lambda _data: {"k": "v"},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: [],
|
lambda _remaped_items, _config: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -216,11 +229,11 @@ async def test_received_data_forwards_to_pocasi_when_enabled(hass, monkeypatch):
|
||||||
coordinator.pocasi.push_data_to_server = AsyncMock()
|
coordinator.pocasi.push_data_to_server = AsyncMock()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.remap_wslink_items",
|
"custom_components.sws12500.coordinator.remap_wslink_items",
|
||||||
lambda _data: {"k": "v"},
|
lambda _data: {"k": "v"},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: [],
|
lambda _remaped_items, _config: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -246,35 +259,35 @@ async def test_received_data_autodiscovery_updates_options_notifies_and_adds_sen
|
||||||
|
|
||||||
# Arrange: remapped payload contains keys that are disabled.
|
# Arrange: remapped payload contains keys that are disabled.
|
||||||
remapped = {"a": "1", "b": "2"}
|
remapped = {"a": "1", "b": "2"}
|
||||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||||
|
|
||||||
# Autodiscovery finds two sensors to add
|
# Autodiscovery finds two sensors to add
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: ["a", "b"],
|
lambda _remaped_items, _config: ["a", "b"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# No previously loaded sensors
|
# No previously loaded sensors
|
||||||
monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: [])
|
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
|
||||||
|
|
||||||
# translations returns a friendly name for each sensor key
|
# translations returns a friendly name for each sensor key
|
||||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||||
# return something non-None so it's included in human readable string
|
# return something non-None so it's included in human readable string
|
||||||
return "Name"
|
return "Name"
|
||||||
|
|
||||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||||
|
|
||||||
translated_notification = AsyncMock()
|
translated_notification = AsyncMock()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.translated_notification", translated_notification
|
"custom_components.sws12500.coordinator.translated_notification", translated_notification
|
||||||
)
|
)
|
||||||
|
|
||||||
update_options = AsyncMock()
|
update_options = AsyncMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||||
|
|
||||||
add_new_sensors = MagicMock()
|
add_new_sensors = MagicMock()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.sensor.add_new_sensors", add_new_sensors
|
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
@ -313,19 +326,19 @@ async def test_received_data_autodiscovery_human_readable_empty_branch_via_check
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||||
|
|
||||||
remapped = {"a": "1"}
|
remapped = {"a": "1"}
|
||||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: ["a"],
|
lambda _remaped_items, _config: ["a"],
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: [])
|
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
|
||||||
|
|
||||||
# Return a translation so the list comprehension would normally include an item.
|
# Return a translation so the list comprehension would normally include an item.
|
||||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||||
return "Name"
|
return "Name"
|
||||||
|
|
||||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||||
|
|
||||||
# Force checked(...) to return None when the code tries to validate translate_sensors as list[str].
|
# Force checked(...) to return None when the code tries to validate translate_sensors as list[str].
|
||||||
def _checked_override(value, expected_type):
|
def _checked_override(value, expected_type):
|
||||||
|
|
@ -333,19 +346,19 @@ async def test_received_data_autodiscovery_human_readable_empty_branch_via_check
|
||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
monkeypatch.setattr("custom_components.sws12500.checked", _checked_override)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.checked", _checked_override)
|
||||||
|
|
||||||
translated_notification = AsyncMock()
|
translated_notification = AsyncMock()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.translated_notification", translated_notification
|
"custom_components.sws12500.coordinator.translated_notification", translated_notification
|
||||||
)
|
)
|
||||||
|
|
||||||
update_options = AsyncMock()
|
update_options = AsyncMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||||
|
|
||||||
add_new_sensors = MagicMock()
|
add_new_sensors = MagicMock()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.sensor.add_new_sensors", add_new_sensors
|
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
@ -371,33 +384,33 @@ async def test_received_data_autodiscovery_extends_with_loaded_sensors_branch(
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||||
|
|
||||||
remapped = {"new": "1"}
|
remapped = {"new": "1"}
|
||||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||||
|
|
||||||
# Autodiscovery finds one new sensor
|
# Autodiscovery finds one new sensor
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: ["new"],
|
lambda _remaped_items, _config: ["new"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pretend there are already loaded sensors in options
|
# Pretend there are already loaded sensors in options
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.loaded_sensors", lambda _c: ["existing"]
|
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||||
return "Name"
|
return "Name"
|
||||||
|
|
||||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.translated_notification", AsyncMock()
|
"custom_components.sws12500.coordinator.translated_notification", AsyncMock()
|
||||||
)
|
)
|
||||||
|
|
||||||
update_options = AsyncMock()
|
update_options = AsyncMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.sensor.add_new_sensors", MagicMock()
|
"custom_components.sws12500.coordinator.add_new_sensors", MagicMock()
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
@ -424,31 +437,31 @@ async def test_received_data_autodiscovery_translations_all_none_still_notifies_
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||||
|
|
||||||
remapped = {"a": "1"}
|
remapped = {"a": "1"}
|
||||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: ["a"],
|
lambda _remaped_items, _config: ["a"],
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: [])
|
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
|
||||||
|
|
||||||
# Force translations to return None for every lookup -> translate_sensors becomes None and human_readable ""
|
# Force translations to return None for every lookup -> translate_sensors becomes None and human_readable ""
|
||||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||||
|
|
||||||
translated_notification = AsyncMock()
|
translated_notification = AsyncMock()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.translated_notification", translated_notification
|
"custom_components.sws12500.coordinator.translated_notification", translated_notification
|
||||||
)
|
)
|
||||||
|
|
||||||
update_options = AsyncMock()
|
update_options = AsyncMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||||
|
|
||||||
add_new_sensors = MagicMock()
|
add_new_sensors = MagicMock()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.sensor.add_new_sensors", add_new_sensors
|
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
@ -469,17 +482,17 @@ async def test_received_data_dev_logging_calls_anonymize_and_logs(hass, monkeypa
|
||||||
entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True)
|
entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True)
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||||
|
|
||||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: {"k": "v"})
|
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: {"k": "v"})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _remaped_items, _config: [],
|
lambda _remaped_items, _config: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
anonymize = MagicMock(return_value={"safe": True})
|
anonymize = MagicMock(return_value={"safe": True})
|
||||||
monkeypatch.setattr("custom_components.sws12500.anonymize", anonymize)
|
monkeypatch.setattr("custom_components.sws12500.coordinator.anonymize", anonymize)
|
||||||
|
|
||||||
log_info = MagicMock()
|
log_info = MagicMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500._LOGGER.info", log_info)
|
monkeypatch.setattr("custom_components.sws12500.coordinator._LOGGER.info", log_info)
|
||||||
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
@ -17,11 +17,7 @@ from custom_components.sws12500.const import (
|
||||||
WIND_SPEED,
|
WIND_SPEED,
|
||||||
WSLINK,
|
WSLINK,
|
||||||
)
|
)
|
||||||
from custom_components.sws12500.data import (
|
from custom_components.sws12500.data import SWSRuntimeData
|
||||||
ENTRY_ADD_ENTITIES,
|
|
||||||
ENTRY_COORDINATOR,
|
|
||||||
ENTRY_DESCRIPTIONS,
|
|
||||||
)
|
|
||||||
from custom_components.sws12500.sensor import (
|
from custom_components.sws12500.sensor import (
|
||||||
WeatherSensor,
|
WeatherSensor,
|
||||||
_auto_enable_derived_sensors,
|
_auto_enable_derived_sensors,
|
||||||
|
|
@ -32,25 +28,55 @@ from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
|
||||||
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
class _EcowittBridgeStub:
|
||||||
class _ConfigEntryStub:
|
"""Records the platform callback the sensor setup wires into the bridge."""
|
||||||
entry_id: str
|
|
||||||
options: dict[str, Any]
|
def __init__(self) -> None:
|
||||||
|
self.add_entities_cb: Any = None
|
||||||
|
|
||||||
|
def set_add_entities(self, callback: Any) -> None:
|
||||||
|
self.add_entities_cb = callback
|
||||||
|
|
||||||
|
|
||||||
class _CoordinatorStub:
|
class _CoordinatorStub:
|
||||||
"""Minimal coordinator stub for WeatherSensor and platform setup."""
|
"""Minimal coordinator stub for WeatherSensor and platform setup."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, data: dict[str, Any] | None = None, *, options: dict[str, Any] | None = None) -> None:
|
||||||
self, data: dict[str, Any] | None = None, *, config: Any | None = None
|
|
||||||
) -> None:
|
|
||||||
self.data = data if data is not None else {}
|
self.data = data if data is not None else {}
|
||||||
self.config = config
|
# WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag.
|
||||||
|
self.config = SimpleNamespace(options=options if options is not None else {})
|
||||||
|
self.ecowitt_bridge = _EcowittBridgeStub()
|
||||||
|
|
||||||
|
|
||||||
|
class _HealthCoordinatorStub:
|
||||||
|
"""Stand-in for HealthCoordinator (health diagnostic sensors subscribe to it)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_entry(
|
||||||
|
*, options: dict[str, Any] | None = None, coordinator: _CoordinatorStub | None = None
|
||||||
|
) -> tuple[Any, _CoordinatorStub, SWSRuntimeData]:
|
||||||
|
"""Build a config-entry stub carrying typed runtime_data, like the integration does."""
|
||||||
|
coordinator = coordinator or _CoordinatorStub()
|
||||||
|
runtime = SWSRuntimeData(
|
||||||
|
coordinator=coordinator, # type: ignore[arg-type]
|
||||||
|
health_coordinator=_HealthCoordinatorStub(), # type: ignore[arg-type]
|
||||||
|
last_options={},
|
||||||
|
)
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
entry_id="test_entry_id",
|
||||||
|
options=options if options is not None else {},
|
||||||
|
runtime_data=runtime,
|
||||||
|
)
|
||||||
|
return entry, coordinator, runtime
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def hass():
|
def hass():
|
||||||
# Use a very small hass-like object; sensor platform uses only `hass.data`.
|
# Sensor platform setup only forwards hass to health_sensor.async_setup_entry,
|
||||||
|
# which ignores it, and add_new_sensors deletes it. A tiny stub is enough.
|
||||||
class _Hass:
|
class _Hass:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.data: dict[str, Any] = {}
|
self.data: dict[str, Any] = {}
|
||||||
|
|
@ -58,11 +84,6 @@ def hass():
|
||||||
return _Hass()
|
return _Hass()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def config_entry() -> _ConfigEntryStub:
|
|
||||||
return _ConfigEntryStub(entry_id="test_entry_id", options={})
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_add_entities():
|
def _capture_add_entities():
|
||||||
captured: list[Any] = []
|
captured: list[Any] = []
|
||||||
|
|
||||||
|
|
@ -72,207 +93,118 @@ def _capture_add_entities():
|
||||||
return captured, _add_entities
|
return captured, _add_entities
|
||||||
|
|
||||||
|
|
||||||
|
def _weather_keys(captured: list[Any]) -> set[str]:
|
||||||
|
return {e.entity_description.key for e in captured if isinstance(e, WeatherSensor)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- _auto_enable_derived_sensors ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_auto_enable_derived_sensors_wind_azimut():
|
def test_auto_enable_derived_sensors_wind_azimut():
|
||||||
requested = {WIND_DIR}
|
expanded = _auto_enable_derived_sensors({WIND_DIR})
|
||||||
expanded = _auto_enable_derived_sensors(requested)
|
|
||||||
assert WIND_DIR in expanded
|
assert WIND_DIR in expanded
|
||||||
assert WIND_AZIMUT in expanded
|
assert WIND_AZIMUT in expanded
|
||||||
|
|
||||||
|
|
||||||
def test_auto_enable_derived_sensors_heat_index():
|
def test_auto_enable_derived_sensors_heat_index():
|
||||||
requested = {OUTSIDE_TEMP, OUTSIDE_HUMIDITY}
|
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, OUTSIDE_HUMIDITY})
|
||||||
expanded = _auto_enable_derived_sensors(requested)
|
|
||||||
assert HEAT_INDEX in expanded
|
assert HEAT_INDEX in expanded
|
||||||
|
|
||||||
|
|
||||||
def test_auto_enable_derived_sensors_chill_index():
|
def test_auto_enable_derived_sensors_chill_index():
|
||||||
requested = {OUTSIDE_TEMP, WIND_SPEED}
|
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, WIND_SPEED})
|
||||||
expanded = _auto_enable_derived_sensors(requested)
|
|
||||||
assert CHILL_INDEX in expanded
|
assert CHILL_INDEX in expanded
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
# --- async_setup_entry -----------------------------------------------------
|
||||||
async def test_sensor_async_setup_entry_no_coordinator_is_noop(hass, config_entry):
|
|
||||||
# No entry dict created by integration yet; async_setup_entry should be defensive and no-op.
|
|
||||||
captured, add_entities = _capture_add_entities()
|
|
||||||
|
|
||||||
await async_setup_entry(hass, config_entry, add_entities)
|
|
||||||
|
|
||||||
assert captured == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sensor_async_setup_entry_stores_callback_and_descriptions_even_if_no_sensors_to_load(
|
async def test_setup_stores_callback_and_descriptions_even_without_sensors_to_load(hass):
|
||||||
hass, config_entry
|
entry, coordinator, runtime = _make_entry()
|
||||||
):
|
|
||||||
# Prepare runtime entry data and coordinator like integration does.
|
|
||||||
hass.data.setdefault("sws12500", {})
|
|
||||||
hass.data["sws12500"][config_entry.entry_id] = {
|
|
||||||
ENTRY_COORDINATOR: _CoordinatorStub()
|
|
||||||
}
|
|
||||||
|
|
||||||
captured, add_entities = _capture_add_entities()
|
captured, add_entities = _capture_add_entities()
|
||||||
|
|
||||||
# No SENSORS_TO_LOAD set -> early return, but it should still store callback + descriptions.
|
await async_setup_entry(hass, entry, add_entities)
|
||||||
await async_setup_entry(hass, config_entry, add_entities)
|
|
||||||
|
|
||||||
entry_data = hass.data["sws12500"][config_entry.entry_id]
|
# Callback + description map persisted for dynamic entity creation.
|
||||||
assert entry_data[ENTRY_ADD_ENTITIES] is add_entities
|
assert runtime.add_sensor_entities is add_entities
|
||||||
assert isinstance(entry_data[ENTRY_DESCRIPTIONS], dict)
|
assert isinstance(runtime.sensor_descriptions, dict)
|
||||||
assert captured == []
|
# Ecowitt bridge wired up even though there are no sensors to load yet.
|
||||||
|
assert coordinator.ecowitt_bridge.add_entities_cb is add_entities
|
||||||
|
# No weather sensors created (only health diagnostics, which we ignore here).
|
||||||
|
assert _weather_keys(captured) == set()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sensor_async_setup_entry_selects_weather_api_descriptions_when_wslink_disabled(
|
async def test_setup_selects_weather_api_descriptions_when_wslink_disabled(hass):
|
||||||
hass, config_entry
|
entry, _coordinator, runtime = _make_entry(options={WSLINK: False})
|
||||||
):
|
_captured, add_entities = _capture_add_entities()
|
||||||
hass.data.setdefault("sws12500", {})
|
|
||||||
hass.data["sws12500"][config_entry.entry_id] = {
|
|
||||||
ENTRY_COORDINATOR: _CoordinatorStub()
|
|
||||||
}
|
|
||||||
|
|
||||||
captured, add_entities = _capture_add_entities()
|
await async_setup_entry(hass, entry, add_entities)
|
||||||
|
|
||||||
# Explicitly disabled WSLINK
|
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
|
||||||
config_entry.options[WSLINK] = False
|
|
||||||
|
|
||||||
await async_setup_entry(hass, config_entry, add_entities)
|
|
||||||
|
|
||||||
descriptions = hass.data["sws12500"][config_entry.entry_id][ENTRY_DESCRIPTIONS]
|
|
||||||
assert set(descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
|
|
||||||
assert captured == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sensor_async_setup_entry_selects_wslink_descriptions_when_wslink_enabled(
|
async def test_setup_selects_wslink_descriptions_when_wslink_enabled(hass):
|
||||||
hass, config_entry
|
entry, _coordinator, runtime = _make_entry(options={WSLINK: True})
|
||||||
):
|
_captured, add_entities = _capture_add_entities()
|
||||||
hass.data.setdefault("sws12500", {})
|
|
||||||
hass.data["sws12500"][config_entry.entry_id] = {
|
|
||||||
ENTRY_COORDINATOR: _CoordinatorStub()
|
|
||||||
}
|
|
||||||
|
|
||||||
captured, add_entities = _capture_add_entities()
|
await async_setup_entry(hass, entry, add_entities)
|
||||||
|
|
||||||
config_entry.options[WSLINK] = True
|
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
|
||||||
|
|
||||||
await async_setup_entry(hass, config_entry, add_entities)
|
|
||||||
|
|
||||||
descriptions = hass.data["sws12500"][config_entry.entry_id][ENTRY_DESCRIPTIONS]
|
|
||||||
assert set(descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
|
|
||||||
assert captured == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sensor_async_setup_entry_adds_requested_entities_and_auto_enables_derived(
|
async def test_setup_adds_requested_entities_and_auto_enables_derived(hass):
|
||||||
hass, config_entry
|
entry, _coordinator, _runtime = _make_entry(
|
||||||
):
|
options={
|
||||||
hass.data.setdefault("sws12500", {})
|
WSLINK: False,
|
||||||
coordinator = _CoordinatorStub()
|
SENSORS_TO_LOAD: [WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED],
|
||||||
hass.data["sws12500"][config_entry.entry_id] = {ENTRY_COORDINATOR: coordinator}
|
|
||||||
|
|
||||||
captured, add_entities = _capture_add_entities()
|
|
||||||
|
|
||||||
# Request WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED -> should auto-add derived keys too.
|
|
||||||
config_entry.options[WSLINK] = False
|
|
||||||
config_entry.options[SENSORS_TO_LOAD] = [
|
|
||||||
WIND_DIR,
|
|
||||||
OUTSIDE_TEMP,
|
|
||||||
OUTSIDE_HUMIDITY,
|
|
||||||
WIND_SPEED,
|
|
||||||
]
|
|
||||||
|
|
||||||
await async_setup_entry(hass, config_entry, add_entities)
|
|
||||||
|
|
||||||
# We should have at least those requested + derived in the added entities.
|
|
||||||
keys_added = {
|
|
||||||
e.entity_description.key for e in captured if isinstance(e, WeatherSensor)
|
|
||||||
}
|
|
||||||
assert WIND_DIR in keys_added
|
|
||||||
assert OUTSIDE_TEMP in keys_added
|
|
||||||
assert OUTSIDE_HUMIDITY in keys_added
|
|
||||||
assert WIND_SPEED in keys_added
|
|
||||||
|
|
||||||
# Derived:
|
|
||||||
assert WIND_AZIMUT in keys_added
|
|
||||||
assert HEAT_INDEX in keys_added
|
|
||||||
assert CHILL_INDEX in keys_added
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_sensors_is_noop_when_domain_missing(hass, config_entry):
|
|
||||||
called = False
|
|
||||||
|
|
||||||
def add_entities(_entities: list[Any]) -> None:
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
|
|
||||||
# No hass.data["sws12500"] at all.
|
|
||||||
add_new_sensors(hass, config_entry, keys=["anything"])
|
|
||||||
|
|
||||||
assert called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_sensors_is_noop_when_entry_missing(hass, config_entry):
|
|
||||||
hass.data["sws12500"] = {}
|
|
||||||
called = False
|
|
||||||
|
|
||||||
def add_entities(_entities: list[Any]) -> None:
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
|
|
||||||
add_new_sensors(hass, config_entry, keys=["anything"])
|
|
||||||
|
|
||||||
assert called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_sensors_is_noop_when_callback_or_descriptions_missing(
|
|
||||||
hass, config_entry
|
|
||||||
):
|
|
||||||
hass.data["sws12500"] = {
|
|
||||||
config_entry.entry_id: {ENTRY_COORDINATOR: _CoordinatorStub()}
|
|
||||||
}
|
|
||||||
called = False
|
|
||||||
|
|
||||||
def add_entities(_entities: list[Any]) -> None:
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
|
|
||||||
# Missing ENTRY_ADD_ENTITIES + ENTRY_DESCRIPTIONS -> no-op.
|
|
||||||
add_new_sensors(hass, config_entry, keys=["anything"])
|
|
||||||
|
|
||||||
assert called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_sensors_ignores_unknown_keys(hass, config_entry):
|
|
||||||
hass.data["sws12500"] = {
|
|
||||||
config_entry.entry_id: {
|
|
||||||
ENTRY_COORDINATOR: _CoordinatorStub(),
|
|
||||||
ENTRY_ADD_ENTITIES: MagicMock(),
|
|
||||||
ENTRY_DESCRIPTIONS: {}, # nothing known
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
captured, add_entities = _capture_add_entities()
|
||||||
|
|
||||||
add_new_sensors(hass, config_entry, keys=["unknown_key"])
|
await async_setup_entry(hass, entry, add_entities)
|
||||||
|
|
||||||
hass.data["sws12500"][config_entry.entry_id][ENTRY_ADD_ENTITIES].assert_not_called()
|
keys_added = _weather_keys(captured)
|
||||||
|
# Requested.
|
||||||
|
assert {WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED} <= keys_added
|
||||||
|
# Derived.
|
||||||
|
assert {WIND_AZIMUT, HEAT_INDEX, CHILL_INDEX} <= keys_added
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_sensors_adds_known_keys(hass, config_entry):
|
# --- add_new_sensors -------------------------------------------------------
|
||||||
coordinator = _CoordinatorStub()
|
|
||||||
|
|
||||||
|
def test_add_new_sensors_is_noop_when_callback_missing(hass):
|
||||||
|
entry, _coordinator, runtime = _make_entry()
|
||||||
|
# Platform not set up yet -> no stored callback.
|
||||||
|
assert runtime.add_sensor_entities is None
|
||||||
|
|
||||||
|
# Must not raise.
|
||||||
|
add_new_sensors(hass, entry, keys=["anything"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_new_sensors_ignores_unknown_keys(hass):
|
||||||
|
entry, _coordinator, runtime = _make_entry()
|
||||||
add_entities = MagicMock()
|
add_entities = MagicMock()
|
||||||
|
runtime.add_sensor_entities = add_entities
|
||||||
|
runtime.sensor_descriptions = {} # nothing known
|
||||||
|
|
||||||
# Use one known description from the weather API list.
|
add_new_sensors(hass, entry, keys=["unknown_key"])
|
||||||
|
|
||||||
|
add_entities.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_new_sensors_adds_known_keys(hass):
|
||||||
|
entry, _coordinator, runtime = _make_entry()
|
||||||
|
add_entities = MagicMock()
|
||||||
known_desc = SENSOR_TYPES_WEATHER_API[0]
|
known_desc = SENSOR_TYPES_WEATHER_API[0]
|
||||||
|
runtime.add_sensor_entities = add_entities
|
||||||
|
runtime.sensor_descriptions = {known_desc.key: known_desc}
|
||||||
|
|
||||||
hass.data["sws12500"] = {
|
add_new_sensors(hass, entry, keys=[known_desc.key])
|
||||||
config_entry.entry_id: {
|
|
||||||
ENTRY_COORDINATOR: coordinator,
|
|
||||||
ENTRY_ADD_ENTITIES: add_entities,
|
|
||||||
ENTRY_DESCRIPTIONS: {known_desc.key: known_desc},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
add_new_sensors(hass, config_entry, keys=[known_desc.key])
|
|
||||||
|
|
||||||
add_entities.assert_called_once()
|
add_entities.assert_called_once()
|
||||||
(entities_arg,) = add_entities.call_args.args
|
(entities_arg,) = add_entities.call_args.args
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,13 @@ def test_t9_keys_are_remapped() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_connection_gated_sensors_definition() -> None:
|
def test_connection_gated_sensors_definition() -> None:
|
||||||
assert CONNECTION_GATED_SENSORS == {"t9cn": [HCHO, VOC, T9_BATTERY]}
|
# The T9 HCHO/VOC probe is gated by its own connection flag. (Multi-channel
|
||||||
|
# CH2-CH8 probes have their own gates too; we only assert the T9 one here.)
|
||||||
|
assert CONNECTION_GATED_SENSORS["t9cn"] == [HCHO, VOC, T9_BATTERY]
|
||||||
|
|
||||||
|
|
||||||
def test_t9_battery_is_non_binary_only() -> None:
|
def test_t9_battery_is_non_binary_only() -> None:
|
||||||
assert BATTERY_NON_BINARY == [T9_BATTERY]
|
assert BATTERY_NON_BINARY == (T9_BATTERY,)
|
||||||
# the 0-5 / percentage battery must not be treated as a binary low/normal one
|
# the 0-5 / percentage battery must not be treated as a binary low/normal one
|
||||||
assert T9_BATTERY not in BATTERY_LIST
|
assert T9_BATTERY not in BATTERY_LIST
|
||||||
|
|
||||||
|
|
@ -84,7 +86,7 @@ def test_voc_level_map_is_complete_and_ordered() -> None:
|
||||||
assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5}
|
assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5}
|
||||||
assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel)
|
assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel)
|
||||||
assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY
|
assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY
|
||||||
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELENT
|
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELLENT
|
||||||
assert [member.value for member in VOCLevel] == [
|
assert [member.value for member in VOCLevel] == [
|
||||||
"unhealthy",
|
"unhealthy",
|
||||||
"poor",
|
"poor",
|
||||||
|
|
@ -109,7 +111,7 @@ def test_voc_level_to_text_handles_empty(empty) -> None:
|
||||||
("2", VOCLevel.POOR),
|
("2", VOCLevel.POOR),
|
||||||
("3", VOCLevel.MODERATE),
|
("3", VOCLevel.MODERATE),
|
||||||
("4", VOCLevel.GOOD),
|
("4", VOCLevel.GOOD),
|
||||||
("5", VOCLevel.EXCELENT),
|
("5", VOCLevel.EXCELLENT),
|
||||||
(3, VOCLevel.MODERATE),
|
(3, VOCLevel.MODERATE),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -184,8 +186,8 @@ def test_hcho_entity_description(wslink_descriptions) -> None:
|
||||||
assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||||
assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION
|
assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION
|
||||||
assert description.state_class is SensorStateClass.MEASUREMENT
|
assert description.state_class is SensorStateClass.MEASUREMENT
|
||||||
# value_fn is a pass-through (typing.cast is a no-op at runtime; HA coerces the str)
|
# HCHO is a numeric ppb concentration, so value_fn coerces to int.
|
||||||
assert description.value_fn("57") == "57"
|
assert description.value_fn("57") == 57
|
||||||
|
|
||||||
|
|
||||||
def test_voc_entity_description(wslink_descriptions) -> None:
|
def test_voc_entity_description(wslink_descriptions) -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ from types import SimpleNamespace
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from custom_components.sws12500.const import DOMAIN
|
from custom_components.sws12500.const import DOMAIN
|
||||||
from custom_components.sws12500.sensor import WeatherSensor
|
from custom_components.sws12500.sensor import WeatherSensor
|
||||||
|
|
||||||
|
|
@ -33,7 +31,9 @@ class _CoordinatorStub:
|
||||||
self, data: dict[str, Any] | None = None, *, config: Any | None = None
|
self, data: dict[str, Any] | None = None, *, config: Any | None = None
|
||||||
):
|
):
|
||||||
self.data = data if data is not None else {}
|
self.data = data if data is not None else {}
|
||||||
self.config = config
|
# WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag,
|
||||||
|
# so default to a config with empty options when the test doesn't supply one.
|
||||||
|
self.config = config if config is not None else SimpleNamespace(options={})
|
||||||
|
|
||||||
|
|
||||||
def test_native_value_prefers_value_from_data_fn_success():
|
def test_native_value_prefers_value_from_data_fn_success():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue