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"