Files
core/tests/components/neopool/test_light.py
T

321 lines
11 KiB
Python

"""Tests for the NeoPool light platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from neopool_modbus import NeoPoolInvalidStateError
from neopool_modbus.exceptions import NeoPoolConnectionError
from neopool_modbus.registers import RelayKind, TimerRelayMode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.neopool.const import FOLLOW_UP_REFRESH_DELAY
from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import MOCK_POOL_DATA
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
def _seed_light_relay_manual(mock_neopool_client: MagicMock) -> None:
"""Default the light relay timer to a manual mode so writes pass the guard."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"relay_light_enable": TimerRelayMode.ALWAYS_OFF,
"Pool Light": False,
}
async def _turn_on(hass: HomeAssistant, entity_id: str) -> None:
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": entity_id},
blocking=True,
)
async def _turn_off(hass: HomeAssistant, entity_id: str) -> None:
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": entity_id},
blocking=True,
)
def _light_entity_id(hass: HomeAssistant, entry: MockConfigEntry) -> str:
registry = er.async_get(hass)
entries = [
e
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
if e.domain == LIGHT_DOMAIN
]
assert len(entries) == 1, "expected exactly one neopool light entity"
return entries[0].entity_id
async def test_light_turn_on_off_writes_via_relay_state(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Light on/off delegates to the high-level async_set_relay_state API."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(
return_value={"Pool Light": True}
)
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, True
)
assert hass.states.get(entity_id).state == STATE_ON
mock_neopool_client.async_set_relay_state = AsyncMock(
return_value={"Pool Light": False}
)
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, False
)
assert hass.states.get(entity_id).state == STATE_OFF
async def test_light_is_on_reflects_relay_state(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""is_on tracks the "Pool Light" relay state key from a fresh poll."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"relay_light_enable": TimerRelayMode.ALWAYS_ON,
"Pool Light": True,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"relay_light_enable": TimerRelayMode.ALWAYS_OFF,
"Pool Light": False,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
@pytest.mark.parametrize(
"relay_data",
[
pytest.param({"relay_light_enable": TimerRelayMode.ENABLED}, id="auto"),
pytest.param({}, id="missing"),
pytest.param({"relay_light_enable": 0}, id="disabled"),
pytest.param({"relay_light_enable": 2}, id="unknown-state"),
],
)
async def test_light_refuses_when_not_in_manual_mode(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
relay_data: dict[str, int],
) -> None:
"""Turn on/off is rejected while the relay is not in a manual mode."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_read_all.return_value = {**MOCK_POOL_DATA, **relay_data}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_neopool_client.async_set_relay_state.reset_mock()
with pytest.raises(ServiceValidationError):
await _turn_on(hass, entity_id)
with pytest.raises(ServiceValidationError):
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_not_called()
async def test_light_maps_lib_invalid_state_to_service_validation(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Lib-raised NeoPoolInvalidStateError is remapped to ServiceValidationError.
Coordinator data may briefly lag the device state, so the pre-check passes
but the library refuses. The mapping surfaces a translated error to users
instead of leaking the raw library exception.
"""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(
side_effect=NeoPoolInvalidStateError("relay in auto mode")
)
with pytest.raises(ServiceValidationError):
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, True
)
@pytest.mark.parametrize(
"write_error",
[
pytest.param(NeoPoolConnectionError("boom"), id="lib-connection-error"),
pytest.param(TimeoutError("boom"), id="timeout"),
pytest.param(OSError("boom"), id="os-error"),
],
)
async def test_light_maps_communication_error_to_home_assistant_error(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
write_error: Exception,
) -> None:
"""Communication errors on write are surfaced as translated HomeAssistantError."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(side_effect=write_error)
with pytest.raises(HomeAssistantError):
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, True
)
@pytest.mark.usefixtures("mock_neopool_client")
async def test_light_absent_when_option_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""No light entity is created while the use_light option is off."""
await setup_integration(hass, mock_config_entry)
light_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if e.domain == LIGHT_DOMAIN
]
assert light_entries == []
async def test_light_absent_when_gpio_unassigned(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Light entity is not registered when the lighting GPIO is unassigned."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_LIGHTING_GPIO": 0,
"relay_light_enable": TimerRelayMode.ALWAYS_OFF,
}
await setup_integration(hass, mock_config_entry_light)
light_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry_light.entry_id
)
if e.domain == LIGHT_DOMAIN
]
assert light_entries == []
@pytest.mark.usefixtures("mock_neopool_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry_light: MockConfigEntry,
) -> None:
"""Snapshot every light entity registered by the platform."""
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, mock_config_entry_light)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry_light.entry_id
)
async def test_light_write_schedules_follow_up_refresh(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A successful write triggers a second refresh after the follow-up delay."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(
return_value={"Pool Light": True}
)
reads_before = mock_neopool_client.async_read_all.await_count
await _turn_on(hass, entity_id)
freezer.tick(timedelta(seconds=FOLLOW_UP_REFRESH_DELAY + 0.5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_neopool_client.async_read_all.await_count > reads_before
async def test_light_timer_enable_gates_writes(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""The relay-light timer enable read from read_all_timers gates writes.
The enable field only reaches the write-guard via the timer read, so an
auto mode here must make turn-on raise ServiceValidationError. A stale or
missing read would leave the seeded ALWAYS_OFF and let the write through.
"""
mock_neopool_client.read_all_timers = AsyncMock(
return_value={
"relay_light": {
"enable": TimerRelayMode.ENABLED,
"on": 3600,
"interval": 7200,
"period": 86400,
"countdown": 120,
"stop": 5400,
}
}
)
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
with pytest.raises(ServiceValidationError):
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_not_called()