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

226 lines
8.0 KiB
Python

"""Tests for the binary sensor platform and battery binary sensor entity.
Covers:
- `binary_sensor.async_setup_entry` (with and without battery keys in SENSORS_TO_LOAD)
- `binary_sensor.add_new_binary_sensors` (no-op / dedupe / unknown / new)
- `battery_sensors.BatteryBinarySensor` (`is_on` value mapping + `device_info`)
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from custom_components.sws12500.battery_sensors import BatteryBinarySensor
from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS
from custom_components.sws12500.binary_sensor import add_new_binary_sensors, async_setup_entry
from custom_components.sws12500.const import CH2_BATTERY, DOMAIN, INDOOR_BATTERY, OUTSIDE_BATTERY, SENSORS_TO_LOAD
from custom_components.sws12500.data import SWSRuntimeData
class _CoordinatorStub:
"""Minimal coordinator stub: CoordinatorEntity only stores it, and `is_on` reads `.data`."""
def __init__(self, data: dict[str, Any] | None = None) -> None:
self.data: dict[str, Any] = data if data is not None else {}
# device_info reads coordinator.config for the shared device model.
self.config = SimpleNamespace(options={})
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=object(), # 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():
# binary_sensor platform setup deletes hass; add_new_binary_sensors also deletes it.
return object()
def _capture_add_entities():
captured: list[Any] = []
def _add_entities(entities: list[Any]) -> None:
captured.extend(entities)
return captured, _add_entities
def _desc_for(key: str):
return next(d for d in BATTERY_BINARY_SENSORS if d.key == key)
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
async def test_setup_creates_entities_for_battery_keys(hass):
entry, coordinator, runtime = _make_entry(
options={SENSORS_TO_LOAD: [OUTSIDE_BATTERY, INDOOR_BATTERY]}
)
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_binary_entities is add_entities
assert set(runtime.binary_descriptions.keys()) == {d.key for d in BATTERY_BINARY_SENSORS}
# Entities created for the requested battery keys only.
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
created_keys = {e.entity_description.key for e in captured}
assert created_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
# added_binary_keys tracks them.
assert runtime.added_binary_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
# Entities are bound to our coordinator stub.
assert all(e.coordinator is coordinator for e in captured)
@pytest.mark.asyncio
async def test_setup_no_battery_keys_adds_nothing(hass):
entry, _coordinator, runtime = _make_entry(options={SENSORS_TO_LOAD: ["outside_temp"]})
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
# Callback/descriptions still stored, but no entities created.
assert runtime.add_binary_entities is add_entities
assert runtime.binary_descriptions # populated
assert captured == []
assert runtime.added_binary_keys == set()
@pytest.mark.asyncio
async def test_setup_no_sensors_to_load_option_adds_nothing(hass):
entry, _coordinator, runtime = _make_entry() # no options at all
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert captured == []
assert runtime.added_binary_keys == set()
# --- add_new_binary_sensors ------------------------------------------------
def test_add_new_is_noop_when_callback_missing(hass):
entry, _coordinator, runtime = _make_entry()
# Platform not set up yet -> no stored callback.
assert runtime.add_binary_entities is None
# Must not raise and must not populate anything.
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
assert runtime.added_binary_keys == set()
def test_add_new_ignores_already_added_keys(hass):
entry, _coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
runtime.added_binary_keys = {OUTSIDE_BATTERY}
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
# Already added -> no new entities, callback not invoked.
assert captured == []
assert runtime.added_binary_keys == {OUTSIDE_BATTERY}
def test_add_new_ignores_unknown_keys(hass):
entry, _coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
add_new_binary_sensors(hass, entry, ["totally_unknown_key"])
assert captured == []
assert runtime.added_binary_keys == set()
def test_add_new_adds_new_known_keys(hass):
entry, coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
# Mix of new known, unknown, and a key we'll mark already-added.
runtime.added_binary_keys = {INDOOR_BATTERY}
add_new_binary_sensors(
hass, entry, [OUTSIDE_BATTERY, CH2_BATTERY, INDOOR_BATTERY, "unknown"]
)
created_keys = {e.entity_description.key for e in captured}
assert created_keys == {OUTSIDE_BATTERY, CH2_BATTERY}
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
assert all(e.coordinator is coordinator for e in captured)
assert runtime.added_binary_keys == {INDOOR_BATTERY, OUTSIDE_BATTERY, CH2_BATTERY}
# --- BatteryBinarySensor ---------------------------------------------------
@pytest.mark.parametrize(
("raw", "expected"),
[
("0", True), # low battery -> on
(0, True),
("1", False), # battery OK -> off
(1, False),
(None, None), # missing
("", None), # empty string
("x", None), # non-int
([], None), # non-int / TypeError path
],
)
def test_is_on_value_mapping(raw, expected):
coordinator = _CoordinatorStub({OUTSIDE_BATTERY: raw})
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
assert sensor.is_on is expected
assert sensor.unique_id == f"{OUTSIDE_BATTERY}_binary"
def test_is_on_key_absent_returns_none():
coordinator = _CoordinatorStub({}) # key not present at all
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
assert sensor.is_on is None
def test_device_info():
coordinator = _CoordinatorStub()
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500"
assert info["manufacturer"] == "Schizza"
assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
assert info["identifiers"] == {(DOMAIN,)}
def test_add_new_binary_sensors_noop_when_runtime_data_missing():
"""add_new_binary_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_binary_sensors(None, entry, keys=["outside_battery"]) # must not raise