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

352 lines
13 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 _descriptions_for(*keys: str) -> dict[str, Any]:
by_key = {desc.key: desc for desc in SENSOR_TYPES_WEATHER_API}
return {key: by_key[key] for key in keys}
def test_discovering_wind_dir_also_adds_the_azimut(hass):
"""Derived sensors are computed, never sent, so discovery can only offer their inputs.
Without expanding here they would appear only after the next restart, when platform
setup applies `_auto_enable_derived_sensors` for the first time. That is exactly what
left Azimut and Heat index sitting as "no longer provided" after a domain migration.
"""
# The caller writes the discovered keys into SENSORS_TO_LOAD before calling us.
entry, _coordinator, runtime = _make_entry(options={SENSORS_TO_LOAD: [WIND_DIR]})
add_entities = MagicMock()
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = _descriptions_for(WIND_DIR, WIND_AZIMUT)
add_new_sensors(hass, entry, keys=[WIND_DIR])
(entities_arg,) = add_entities.call_args.args
assert {e.entity_description.key for e in entities_arg} == {WIND_DIR, WIND_AZIMUT}
def test_heat_index_appears_only_once_both_inputs_are_known(hass):
"""It needs temperature *and* humidity, so the first of the two must not trigger it."""
entry, _coordinator, runtime = _make_entry(options={SENSORS_TO_LOAD: [OUTSIDE_TEMP]})
add_entities = MagicMock()
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = _descriptions_for(OUTSIDE_TEMP, OUTSIDE_HUMIDITY, HEAT_INDEX)
add_new_sensors(hass, entry, keys=[OUTSIDE_TEMP])
(first,) = add_entities.call_args.args
assert {e.entity_description.key for e in first} == {OUTSIDE_TEMP}
entry.options = {SENSORS_TO_LOAD: [OUTSIDE_TEMP, OUTSIDE_HUMIDITY]}
add_new_sensors(hass, entry, keys=[OUTSIDE_HUMIDITY])
(second,) = add_entities.call_args.args
assert {e.entity_description.key for e in second} == {OUTSIDE_HUMIDITY, HEAT_INDEX}
def test_a_derived_sensor_is_never_added_twice(hass):
"""A second entity with the same unique id would be rejected and logged as an error.
Once the azimut exists, later discoveries must not offer it again - the wind
direction that unlocked it is still in SENSORS_TO_LOAD forever after.
"""
entry, _coordinator, runtime = _make_entry(options={SENSORS_TO_LOAD: [WIND_DIR]})
add_entities = MagicMock()
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = _descriptions_for(WIND_DIR, WIND_AZIMUT, OUTSIDE_TEMP)
add_new_sensors(hass, entry, keys=[WIND_DIR])
# A later payload brings something unrelated; the azimut already exists.
entry.options = {SENSORS_TO_LOAD: [WIND_DIR, OUTSIDE_TEMP]}
add_new_sensors(hass, entry, keys=[OUTSIDE_TEMP])
(second,) = add_entities.call_args.args
assert {e.entity_description.key for e in second} == {OUTSIDE_TEMP}
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"