mirror of
https://github.com/home-assistant/core.git
synced 2026-10-02 01:03:04 +01:00
1013 lines
34 KiB
Python
1013 lines
34 KiB
Python
"""Tests for the NeoPool time platform."""
|
|
|
|
import asyncio
|
|
from datetime import time as dt_time, timedelta
|
|
import gc
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
from neopool_modbus.exceptions import NeoPoolConnectionError
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
|
|
from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE
|
|
from homeassistant.const import ATTR_TIME, Platform
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import entity_platform as ep, entity_registry as er
|
|
|
|
from . import setup_integration
|
|
from .conftest import MOCK_POOL_DATA, _read_all_timers
|
|
|
|
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
|
|
|
FLUSH = timedelta(seconds=5)
|
|
PARTIAL = timedelta(seconds=2)
|
|
|
|
|
|
def _time_entity_id(
|
|
hass: HomeAssistant, entry: MockConfigEntry, key_lower_suffix: str
|
|
) -> str:
|
|
"""Resolve a time entity by its trailing unique_id segment."""
|
|
registry = er.async_get(hass)
|
|
entries = [
|
|
e
|
|
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
|
|
if e.domain == TIME_DOMAIN and e.unique_id.endswith(f"_{key_lower_suffix}")
|
|
]
|
|
assert entries, (
|
|
f"no time entity ending in _{key_lower_suffix} - found: "
|
|
+ ", ".join(
|
|
e.unique_id
|
|
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
|
|
if e.domain == TIME_DOMAIN
|
|
)
|
|
)
|
|
return entries[0].entity_id
|
|
|
|
|
|
def _time_entity(hass: HomeAssistant, entity_id: str) -> Any:
|
|
"""Return the live NeoPoolTime object for entity_id."""
|
|
for platform in ep.async_get_platforms(hass, "neopool"):
|
|
if entity_id in platform.entities:
|
|
return platform.entities[entity_id]
|
|
raise AssertionError(f"no time entity {entity_id}")
|
|
|
|
|
|
async def _set_time(hass: HomeAssistant, entity_id: str, value: dt_time) -> None:
|
|
await hass.services.async_call(
|
|
TIME_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{"entity_id": entity_id, ATTR_TIME: value},
|
|
blocking=True,
|
|
)
|
|
|
|
|
|
def _set_time_nowait(
|
|
hass: HomeAssistant, entity_id: str, value: dt_time
|
|
) -> asyncio.Task[None]:
|
|
"""Start a blocking set_value as a task so the freezer can drive the flush.
|
|
|
|
A blocking call now awaits the debounced write, which only runs on a
|
|
freezer tick. Awaiting it inline would deadlock, so callers schedule it,
|
|
flush, then await the task to observe the write's outcome.
|
|
"""
|
|
return hass.async_create_task(_set_time(hass, entity_id, value))
|
|
|
|
|
|
async def _let_park(hass: HomeAssistant) -> None:
|
|
"""Yield enough for a scheduled set_value task to reach its await point.
|
|
|
|
``async_block_till_done`` waits on hass-tracked tasks, so it would block on
|
|
a set_value task intentionally parked before the flush; plain event-loop
|
|
yields let the task register its optimistic state without that wait.
|
|
"""
|
|
for _ in range(3):
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
async def _write(
|
|
hass: HomeAssistant,
|
|
freezer: FrozenDateTimeFactory,
|
|
entity_id: str,
|
|
value: dt_time,
|
|
) -> None:
|
|
"""Set a value, flush the debounce, and await the write's outcome.
|
|
|
|
Raises whatever the debounced write raises (a device error surfaces to the
|
|
blocking caller), matching how a ``blocking: true`` service call behaves.
|
|
"""
|
|
task = _set_time_nowait(hass, entity_id, value)
|
|
await _flush(hass, freezer)
|
|
await task
|
|
|
|
|
|
async def _flush(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None:
|
|
"""Advance past the debounce cooldown and let the pending write run."""
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
|
|
async def _advance(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None:
|
|
"""Advance by less than the cooldown; a pending write must not fire yet.
|
|
|
|
Uses event-loop yields rather than ``async_block_till_done`` so a set_value
|
|
task parked on the coalesce future does not stall the advance.
|
|
"""
|
|
freezer.tick(PARTIAL)
|
|
async_fire_time_changed(hass)
|
|
await _let_park(hass)
|
|
|
|
|
|
async def _poll(
|
|
hass: HomeAssistant,
|
|
freezer: FrozenDateTimeFactory,
|
|
mock_client: MagicMock,
|
|
data: dict[str, Any],
|
|
) -> None:
|
|
"""Push a coordinator poll returning ``data``.
|
|
|
|
Filtration timer fields land in coordinator data via read_all_timers, not
|
|
async_read_all, so any filtration1_start/stop override in ``data`` is
|
|
reflected into the mocked timer block (start -> on, stop -> stop).
|
|
|
|
Copy ``data`` so the coordinator's in-place merge cannot mutate a shared
|
|
module-level dict.
|
|
"""
|
|
mock_client.async_read_all.return_value = dict(data)
|
|
start = data.get("filtration1_start")
|
|
stop = data.get("filtration1_stop")
|
|
|
|
def _timers(
|
|
enabled_timers: list[str] | None = None, **_kwargs: Any
|
|
) -> dict[str, dict[str, Any]]:
|
|
blocks = _read_all_timers(enabled_timers)
|
|
if "filtration1" in blocks:
|
|
if start is not None:
|
|
blocks["filtration1"]["on"] = start
|
|
if stop is not None:
|
|
blocks["filtration1"]["stop"] = stop
|
|
return blocks
|
|
|
|
mock_client.read_all_timers.side_effect = _timers
|
|
freezer.tick(timedelta(seconds=60))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
|
|
async def test_native_value_decodes_seconds_since_midnight(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Coordinator seconds become HH:MM:SS state."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(
|
|
hass,
|
|
freezer,
|
|
mock_neopool_client,
|
|
{**MOCK_POOL_DATA, "filtration1_start": 6 * 3600 + 30 * 60},
|
|
)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "06:30:00"
|
|
|
|
|
|
async def test_native_value_returns_none_when_data_missing(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Missing coordinator key surfaces as 'unknown'."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
mock_neopool_client.read_all_timers.side_effect = None
|
|
mock_neopool_client.read_all_timers.return_value = {
|
|
"filtration1": {
|
|
"enable": 0,
|
|
"on": None,
|
|
"interval": None,
|
|
"stop": None,
|
|
"period": None,
|
|
"countdown": 0,
|
|
}
|
|
}
|
|
freezer.tick(timedelta(seconds=60))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
async def test_native_value_handles_out_of_range_seconds(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Values >= 86400 wrap modulo 86400."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(
|
|
hass,
|
|
freezer,
|
|
mock_neopool_client,
|
|
{**MOCK_POOL_DATA, "filtration1_start": 86400 + 3600},
|
|
)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "01:00:00"
|
|
|
|
|
|
async def test_set_value_on_start_writes_timer(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Setting *_start passes only the on endpoint; the library holds the stop."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(hass, freezer, mock_neopool_client, MOCK_POOL_DATA)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
await _write(hass, freezer, entity_id, dt_time(6, 0))
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 1
|
|
timer_name, payload = mock_neopool_client.write_timer.await_args.args
|
|
assert timer_name == "filtration1"
|
|
assert payload == {"on": 6 * 3600}
|
|
|
|
|
|
async def test_set_value_on_stop_writes_timer(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Setting *_stop passes only the stop endpoint; the library holds the on."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(hass, freezer, mock_neopool_client, MOCK_POOL_DATA)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_stop")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
await _write(hass, freezer, entity_id, dt_time(10, 0))
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 1
|
|
timer_name, payload = mock_neopool_client.write_timer.await_args.args
|
|
assert timer_name == "filtration1"
|
|
assert payload == {"stop": 10 * 3600}
|
|
|
|
|
|
async def test_pending_value_shown_optimistically_before_write(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""State surfaces the requested value while the debounce is in flight."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _advance(hass, freezer)
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "06:00:00"
|
|
mock_neopool_client.write_timer.assert_not_awaited()
|
|
|
|
await _flush(hass, freezer)
|
|
await task
|
|
|
|
|
|
@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_set_value_maps_communication_error_to_home_assistant_error(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
write_error: Exception,
|
|
) -> None:
|
|
"""A failed timer write surfaces to the blocking caller as HomeAssistantError."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.side_effect = write_error
|
|
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _flush(hass, freezer)
|
|
with pytest.raises(HomeAssistantError) as err:
|
|
await task
|
|
|
|
assert err.value.translation_key == "modbus_communication_error"
|
|
mock_neopool_client.write_timer.assert_awaited_once()
|
|
|
|
|
|
async def test_unexpected_write_error_reaches_caller(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""An unexpected write error surfaces unchanged, not as a comm error."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.side_effect = ValueError("boom")
|
|
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _flush(hass, freezer)
|
|
with pytest.raises(ValueError, match="boom"):
|
|
await task
|
|
|
|
|
|
async def test_post_write_merge_failure_reaches_caller(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A raise after a successful device write surfaces unchanged."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
with patch.object(
|
|
mock_config_entry_timers.runtime_data,
|
|
"async_set_updated_data",
|
|
side_effect=RuntimeError("merge boom"),
|
|
):
|
|
await _flush(hass, freezer)
|
|
with pytest.raises(RuntimeError, match="merge boom"):
|
|
await task
|
|
|
|
|
|
async def test_optimistic_state_rolls_back_on_write_failure(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A failed write rolls the optimistic state back to the device reading."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(
|
|
hass,
|
|
freezer,
|
|
mock_neopool_client,
|
|
{**MOCK_POOL_DATA, "filtration1_start": 6 * 3600},
|
|
)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.side_effect = NeoPoolConnectionError("boom")
|
|
|
|
task = _set_time_nowait(hass, entity_id, dt_time(8, 0))
|
|
await _let_park(hass)
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "08:00:00"
|
|
|
|
await _flush(hass, freezer)
|
|
with pytest.raises(HomeAssistantError):
|
|
await task
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "06:00:00"
|
|
|
|
|
|
async def test_rapid_set_value_coalesces_via_debounce(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Sibling start/stop are independent single-endpoint writes to one block."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(
|
|
hass,
|
|
freezer,
|
|
mock_neopool_client,
|
|
{**MOCK_POOL_DATA, "filtration1_start": 0, "filtration1_stop": 0},
|
|
)
|
|
|
|
start_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
stop_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_stop")
|
|
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
start_task = _set_time_nowait(hass, start_id, dt_time(6, 0))
|
|
stop_task = _set_time_nowait(hass, stop_id, dt_time(10, 0))
|
|
await _flush(hass, freezer)
|
|
await asyncio.gather(start_task, stop_task)
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 2
|
|
payloads = [
|
|
call.args[1] for call in mock_neopool_client.write_timer.await_args_list
|
|
]
|
|
assert {"on": 6 * 3600} in payloads
|
|
assert {"stop": 10 * 3600} in payloads
|
|
assert all(
|
|
call.args[0] == "filtration1"
|
|
for call in mock_neopool_client.write_timer.await_args_list
|
|
)
|
|
|
|
|
|
async def test_sibling_writes_to_same_block_are_serialized(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Concurrent start/stop writes to one block never overlap in the library.
|
|
|
|
write_timer is a read-modify-write of the shared block, so overlapping
|
|
sibling writes could read a stale endpoint and clobber each other. A
|
|
per-block lock must serialize them: the in-flight count never exceeds one.
|
|
"""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(
|
|
hass,
|
|
freezer,
|
|
mock_neopool_client,
|
|
{**MOCK_POOL_DATA, "filtration1_start": 0, "filtration1_stop": 0},
|
|
)
|
|
|
|
start_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
stop_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_stop")
|
|
|
|
in_flight = 0
|
|
max_in_flight = 0
|
|
|
|
async def _tracking_write(block: str, timer_data: dict[str, Any]) -> bool:
|
|
nonlocal in_flight, max_in_flight
|
|
in_flight += 1
|
|
max_in_flight = max(max_in_flight, in_flight)
|
|
# Yield so a second write not held by the lock would overlap here.
|
|
await asyncio.sleep(0)
|
|
in_flight -= 1
|
|
return True
|
|
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
mock_neopool_client.write_timer.side_effect = _tracking_write
|
|
|
|
start_task = _set_time_nowait(hass, start_id, dt_time(6, 0))
|
|
stop_task = _set_time_nowait(hass, stop_id, dt_time(10, 0))
|
|
await _flush(hass, freezer)
|
|
await asyncio.gather(start_task, stop_task)
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 2
|
|
assert max_in_flight == 1
|
|
|
|
|
|
async def test_repeated_set_value_writes_only_latest(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Two quick set_value calls debounce to a single write of the last value."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(hass, freezer, mock_neopool_client, MOCK_POOL_DATA)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
|
|
task1 = _set_time_nowait(hass, entity_id, dt_time(5, 0))
|
|
task2 = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _flush(hass, freezer)
|
|
await asyncio.gather(task1, task2)
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 1
|
|
_timer_name, payload = mock_neopool_client.write_timer.await_args.args
|
|
assert payload == {"on": 6 * 3600}
|
|
|
|
|
|
async def test_settle_restarts_timer(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Each edit restarts the timer; the write fires after the last edit."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
|
|
task1 = _set_time_nowait(hass, entity_id, dt_time(5, 0))
|
|
await _advance(hass, freezer)
|
|
task2 = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _advance(hass, freezer)
|
|
|
|
mock_neopool_client.write_timer.assert_not_awaited()
|
|
|
|
await _flush(hass, freezer)
|
|
await asyncio.gather(task1, task2)
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 1
|
|
_timer_name, payload = mock_neopool_client.write_timer.await_args.args
|
|
assert payload == {"on": 6 * 3600}
|
|
|
|
|
|
async def test_no_write_when_settled_value_unchanged(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Setting the value the device already holds writes nothing.
|
|
|
|
The settled seconds equal the decoded register, so the EEPROM cycle is
|
|
skipped and the optimistic state falls back to the device reading.
|
|
"""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await _poll(
|
|
hass,
|
|
freezer,
|
|
mock_neopool_client,
|
|
{**MOCK_POOL_DATA, "filtration1_start": 6 * 3600},
|
|
)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
await _write(hass, freezer, entity_id, dt_time(6, 0))
|
|
|
|
mock_neopool_client.write_timer.assert_not_awaited()
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "06:00:00"
|
|
|
|
|
|
async def test_coalesced_callers_all_succeed_together(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Three quick set_value calls collapse to one write and all callers succeed."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
|
|
tasks = [
|
|
_set_time_nowait(hass, entity_id, value)
|
|
for value in (dt_time(5, 0), dt_time(5, 30), dt_time(6, 0))
|
|
]
|
|
await _flush(hass, freezer)
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 1
|
|
assert results == [None, None, None]
|
|
|
|
|
|
async def test_coalesced_callers_all_raise_together(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A failed coalesced write raises for every caller in the debounce window."""
|
|
mock_neopool_client.write_timer.side_effect = NeoPoolConnectionError("boom")
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
|
|
tasks = [
|
|
_set_time_nowait(hass, entity_id, value)
|
|
for value in (dt_time(5, 0), dt_time(5, 30), dt_time(6, 0))
|
|
]
|
|
await _flush(hass, freezer)
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
assert mock_neopool_client.write_timer.await_count == 1
|
|
assert all(isinstance(r, HomeAssistantError) for r in results)
|
|
|
|
|
|
async def test_external_cancel_propagates_but_spares_coalesced_caller(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Cancelling one caller re-raises for it but spares the coalesced batch."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
|
|
victim = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
survivor = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _let_park(hass)
|
|
|
|
victim.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await victim
|
|
|
|
await _flush(hass, freezer)
|
|
await survivor
|
|
mock_neopool_client.write_timer.assert_awaited_once()
|
|
|
|
|
|
async def test_failed_write_survives_a_cancelled_coalesced_caller(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A failed coalesced write raises for the survivor and logs no warning.
|
|
|
|
Two callers share one coalesce future; one is cancelled before the delayed
|
|
write fails. Cancelling a caller makes asyncio.shield attach its own logger
|
|
to the shared future, so failing it via set_exception would be reported as
|
|
an unretrieved error at teardown. The write instead carries its outcome as
|
|
the future's result, so the survivor still re-raises the device error and
|
|
no "exception in shielded future" warning is logged.
|
|
"""
|
|
in_write = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def _blocking_boom(block: str, timer_data: dict[str, Any]) -> bool:
|
|
in_write.set()
|
|
await release.wait()
|
|
raise NeoPoolConnectionError("boom")
|
|
|
|
mock_neopool_client.write_timer = AsyncMock(side_effect=_blocking_boom)
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
|
|
victim = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
survivor = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _let_park(hass)
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await in_write.wait()
|
|
|
|
victim.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await victim
|
|
|
|
release.set()
|
|
with pytest.raises(HomeAssistantError):
|
|
await survivor
|
|
await hass.async_block_till_done()
|
|
|
|
gc.collect()
|
|
await asyncio.sleep(0)
|
|
|
|
assert "exception in shielded future" not in caplog.text
|
|
assert "exception was never retrieved" not in caplog.text
|
|
|
|
|
|
async def test_write_queued_during_flush_gets_its_own_outcome(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A set_value arriving mid-flush writes on its own, not the in-flight batch."""
|
|
in_write = asyncio.Event()
|
|
release = asyncio.Event()
|
|
seen: list[int] = []
|
|
|
|
async def _gated_write(block: str, timer_data: dict[str, Any]) -> bool:
|
|
seen.append(timer_data["on"])
|
|
if len(seen) == 1:
|
|
in_write.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
mock_neopool_client.write_timer = AsyncMock(side_effect=_gated_write)
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
seen.clear()
|
|
|
|
first = _set_time_nowait(hass, entity_id, dt_time(7, 0))
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await in_write.wait()
|
|
|
|
second = _set_time_nowait(hass, entity_id, dt_time(8, 0))
|
|
await _let_park(hass)
|
|
|
|
release.set()
|
|
await first
|
|
await _flush(hass, freezer)
|
|
await second
|
|
|
|
assert seen == [7 * 3600, 8 * 3600]
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "08:00:00"
|
|
|
|
|
|
async def test_same_value_queued_during_flush_still_resolves(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A second set_value of the same value mid-flush still gets an outcome."""
|
|
in_write = asyncio.Event()
|
|
release = asyncio.Event()
|
|
seen: list[int] = []
|
|
|
|
async def _gated_write(block: str, timer_data: dict[str, Any]) -> bool:
|
|
seen.append(timer_data["on"])
|
|
if len(seen) == 1:
|
|
in_write.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
mock_neopool_client.write_timer = AsyncMock(side_effect=_gated_write)
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
seen.clear()
|
|
|
|
first = _set_time_nowait(hass, entity_id, dt_time(7, 0))
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await in_write.wait()
|
|
|
|
second = _set_time_nowait(hass, entity_id, dt_time(7, 0))
|
|
await _let_park(hass)
|
|
|
|
release.set()
|
|
await first
|
|
await _flush(hass, freezer)
|
|
await second
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "07:00:00"
|
|
|
|
|
|
async def test_optimistic_value_survives_overlapping_flush(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A newer optimistic value is not clobbered by an in-flight older write."""
|
|
in_write = asyncio.Event()
|
|
release = asyncio.Event()
|
|
seen: list[int] = []
|
|
|
|
async def _gated_write(block: str, timer_data: dict[str, Any]) -> bool:
|
|
seen.append(timer_data["on"])
|
|
if len(seen) == 1:
|
|
in_write.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
mock_neopool_client.write_timer = AsyncMock(side_effect=_gated_write)
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
seen.clear()
|
|
|
|
first = _set_time_nowait(hass, entity_id, dt_time(7, 0))
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await in_write.wait()
|
|
|
|
second = _set_time_nowait(hass, entity_id, dt_time(8, 0))
|
|
await _let_park(hass)
|
|
|
|
release.set()
|
|
await first
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "08:00:00"
|
|
|
|
await _flush(hass, freezer)
|
|
await second
|
|
assert seen == [7 * 3600, 8 * 3600]
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == "08:00:00"
|
|
|
|
|
|
async def test_write_works_after_entity_id_change(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A write still reaches the device after the entity is renamed.
|
|
|
|
Changing the entity ID removes and re-adds the same object. If removal
|
|
leaves the removing flag set, every later flush aborts and the write never
|
|
reaches the device, so the write must run against the new entity ID.
|
|
"""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
new_entity_id = f"{entity_id}_renamed"
|
|
entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id)
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get(new_entity_id) is not None
|
|
|
|
mock_neopool_client.write_timer.reset_mock()
|
|
await _write(hass, freezer, new_entity_id, dt_time(6, 0))
|
|
|
|
mock_neopool_client.write_timer.assert_awaited_once()
|
|
state = hass.states.get(new_entity_id)
|
|
assert state is not None
|
|
assert state.state == "06:00:00"
|
|
|
|
|
|
async def test_pending_write_dropped_on_remove(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Unloading cancels an un-elapsed write and releases the awaiting caller."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _let_park(hass)
|
|
|
|
await hass.config_entries.async_unload(mock_config_entry_timers.entry_id)
|
|
await _flush(hass, freezer)
|
|
|
|
await task
|
|
mock_neopool_client.write_timer.assert_not_awaited()
|
|
|
|
|
|
async def test_flush_aborts_when_removed_while_holding_lock(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A flush that finds the entity removed after winning the lock aborts."""
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
entity = _time_entity(hass, entity_id)
|
|
|
|
await entity._flush_lock.acquire()
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _let_park(hass)
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await _let_park(hass)
|
|
|
|
entity._removing = True
|
|
entity._flush_lock.release()
|
|
await task
|
|
|
|
mock_neopool_client.write_timer.assert_not_awaited()
|
|
entity._removing = False
|
|
|
|
|
|
async def test_inflight_write_skips_coordinator_on_remove(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A write already in flight when the entity is removed skips the coordinator."""
|
|
in_write = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def _blocking_write(block: str, timer_data: dict[str, Any]) -> bool:
|
|
in_write.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
mock_neopool_client.write_timer = AsyncMock(side_effect=_blocking_write)
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
task = _set_time_nowait(hass, entity_id, dt_time(6, 0))
|
|
await _let_park(hass)
|
|
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await in_write.wait()
|
|
|
|
with patch.object(
|
|
mock_config_entry_timers.runtime_data, "async_set_updated_data"
|
|
) as mock_update:
|
|
await hass.config_entries.async_unload(mock_config_entry_timers.entry_id)
|
|
release.set()
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
await task
|
|
mock_neopool_client.write_timer.assert_awaited_once()
|
|
mock_update.assert_not_called()
|
|
|
|
|
|
async def test_client_close_waits_for_all_overlapping_flushes(
|
|
hass: HomeAssistant,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Removal cancels every overlapping flush, not just the most recent one."""
|
|
in_write = asyncio.Event()
|
|
write_active = False
|
|
close_saw_write_active: bool | None = None
|
|
|
|
async def _blocking_write(block: str, timer_data: dict[str, Any]) -> bool:
|
|
nonlocal write_active
|
|
write_active = True
|
|
in_write.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
return True
|
|
finally:
|
|
write_active = False
|
|
|
|
async def _record_close() -> None:
|
|
nonlocal close_saw_write_active
|
|
close_saw_write_active = write_active
|
|
|
|
mock_neopool_client.write_timer = AsyncMock(side_effect=_blocking_write)
|
|
mock_neopool_client.close = AsyncMock(side_effect=_record_close)
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
|
|
entity_id = _time_entity_id(hass, mock_config_entry_timers, "filtration1_start")
|
|
|
|
first = _set_time_nowait(hass, entity_id, dt_time(7, 0))
|
|
await _let_park(hass)
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await in_write.wait()
|
|
|
|
second = _set_time_nowait(hass, entity_id, dt_time(8, 0))
|
|
await _let_park(hass)
|
|
freezer.tick(FLUSH)
|
|
async_fire_time_changed(hass)
|
|
await _let_park(hass)
|
|
|
|
assert await hass.config_entries.async_unload(mock_config_entry_timers.entry_id)
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
await first
|
|
await second
|
|
mock_neopool_client.close.assert_awaited_once()
|
|
assert close_saw_write_active is False
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client")
|
|
async def test_all_entities(
|
|
hass: HomeAssistant,
|
|
snapshot: SnapshotAssertion,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
) -> None:
|
|
"""Snapshot every entity registered by the time platform."""
|
|
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.TIME]):
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await snapshot_platform(
|
|
hass, entity_registry, snapshot, mock_config_entry_timers.entry_id
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
async def test_setup_when_modules_absent(
|
|
hass: HomeAssistant,
|
|
snapshot: SnapshotAssertion,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_config_entry_timers: MockConfigEntry,
|
|
mock_neopool_client: MagicMock,
|
|
minimal_pool_data: dict[str, Any],
|
|
) -> None:
|
|
"""Snapshot the time entities registered when no modules are present."""
|
|
mock_neopool_client.async_read_all.return_value = minimal_pool_data
|
|
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.TIME]):
|
|
await setup_integration(hass, mock_config_entry_timers)
|
|
await snapshot_platform(
|
|
hass, entity_registry, snapshot, mock_config_entry_timers.entry_id
|
|
)
|