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>
SchiZzA 2026-06-21 12:51:52 +02:00
parent 25341f79b3
commit 50d0e9fae7
No known key found for this signature in database
8 changed files with 388 additions and 402 deletions

View File

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

View File

@ -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 == {}

View File

@ -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)

View File

@ -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):

View File

@ -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()

View File

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

View File

@ -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:

View File

@ -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():