"""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 )