SWS-12500-custom-component/tests/test_sensor_platform.py

290 lines
9.7 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from custom_components.sws12500.const import (
CHILL_INDEX,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
SENSORS_TO_LOAD,
WIND_AZIMUT,
WIND_DIR,
WIND_SPEED,
WSLINK,
)
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.sensor import (
WeatherSensor,
_auto_enable_derived_sensors,
add_new_sensors,
async_setup_entry,
)
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
class _EcowittBridgeStub:
"""Records the platform callback the sensor setup wires into the bridge."""
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:
"""Minimal coordinator stub for WeatherSensor and platform setup."""
def __init__(self, data: dict[str, Any] | None = None, *, options: dict[str, Any] | None = None) -> None:
self.data = data if data is not None else {}
# 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
def hass():
# 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:
def __init__(self) -> None:
self.data: dict[str, Any] = {}
return _Hass()
def _capture_add_entities():
captured: list[Any] = []
def _add_entities(entities: list[Any]) -> None:
captured.extend(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():
expanded = _auto_enable_derived_sensors({WIND_DIR})
assert WIND_DIR in expanded
assert WIND_AZIMUT in expanded
def test_auto_enable_derived_sensors_heat_index():
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, OUTSIDE_HUMIDITY})
assert HEAT_INDEX in expanded
def test_auto_enable_derived_sensors_chill_index():
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, WIND_SPEED})
assert CHILL_INDEX in expanded
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
async def test_setup_stores_callback_and_descriptions_even_without_sensors_to_load(hass):
entry, coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
# Callback + description map persisted for dynamic entity creation.
assert runtime.add_sensor_entities is add_entities
assert isinstance(runtime.sensor_descriptions, dict)
# 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
async def test_setup_selects_weather_api_descriptions_when_wslink_disabled(hass):
entry, _coordinator, runtime = _make_entry(options={WSLINK: False})
_captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
@pytest.mark.asyncio
async def test_setup_selects_wslink_descriptions_when_wslink_enabled(hass):
entry, _coordinator, runtime = _make_entry(options={WSLINK: True})
_captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
@pytest.mark.asyncio
async def test_setup_adds_requested_entities_and_auto_enables_derived(hass):
entry, _coordinator, _runtime = _make_entry(
options={
WSLINK: False,
SENSORS_TO_LOAD: [WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED],
}
)
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
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
# --- add_new_sensors -------------------------------------------------------
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()
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = {} # nothing known
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]
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = {known_desc.key: known_desc}
add_new_sensors(hass, entry, keys=[known_desc.key])
add_entities.assert_called_once()
(entities_arg,) = add_entities.call_args.args
assert isinstance(entities_arg, list)
assert len(entities_arg) == 1
assert isinstance(entities_arg[0], WeatherSensor)
assert entities_arg[0].entity_description.key == known_desc.key
def test_add_new_sensors_noop_when_runtime_data_missing():
"""add_new_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
add_new_sensors(None, entry, keys=["anything"]) # must not raise
# ---------------------------------------------------------------------------
# Probe type decides the humidity device class, once, at entity creation
# ---------------------------------------------------------------------------
def _wslink_desc(key: str):
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
return next(d for d in SENSOR_TYPES_WSLINK if d.key == key)
@pytest.mark.parametrize(
("channel_type", "expected"),
[("4", "moisture"), ("2", "humidity")],
ids=["soil-probe", "thermo-hygrometer"],
)
def test_channel_humidity_entity_takes_its_class_from_the_probe(channel_type, expected) -> None:
"""A soil probe reports soil moisture, so the entity must not claim humidity."""
from types import SimpleNamespace
from unittest.mock import MagicMock
from custom_components.sws12500.const import CH2_HUMIDITY, CHANNEL_TYPES
from custom_components.sws12500.sensor import WeatherSensor
coordinator = MagicMock()
coordinator.config = SimpleNamespace(options={CHANNEL_TYPES: {"t234c1tp": channel_type}})
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
assert sensor.device_class == expected
def test_channel_humidity_entity_falls_back_to_the_description() -> None:
"""No reported probe type leaves the description's own device class in place."""
from types import SimpleNamespace
from unittest.mock import MagicMock
from custom_components.sws12500.const import CH2_HUMIDITY
from custom_components.sws12500.sensor import WeatherSensor
coordinator = MagicMock()
coordinator.config = SimpleNamespace(options={})
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
assert sensor.device_class == "humidity"
def test_soil_channel_class_survives_a_restart() -> None:
"""The restart case: entities are built from options before any payload arrives.
Runtime state is empty at this point, so the probe type must come from the
persisted options or the soil channel silently reverts to air humidity.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
from custom_components.sws12500.const import CH2_HUMIDITY, CHANNEL_TYPES
from custom_components.sws12500.sensor import WeatherSensor
coordinator = MagicMock()
coordinator.data = {}
coordinator.config = SimpleNamespace(
options={CHANNEL_TYPES: {"t234c1tp": "4"}},
runtime_data=SimpleNamespace(),
)
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
assert sensor.device_class == "moisture"