352 lines
13 KiB
Python
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"
|