""" Test timer scheduling for entity updates at correct intervals. This tests the three-timer architecture: - Timer #1: API polling (15 min, random offset) - tested in test_next_api_poll.py - Timer #2: Quarter-hour entity refresh (:00, :15, :30, :45) - Timer #3: Timing sensors refresh (:00, :30 every minute) See docs/development/timer-architecture.md for architecture overview. """ from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock, patch import pytest from custom_components.tibber_prices.coordinator.constants import ( QUARTER_HOUR_BOUNDARIES, ) from custom_components.tibber_prices.coordinator.listeners import ( TibberPricesListenerManager, ) from homeassistant.core import HomeAssistant @pytest.fixture def hass_mock() -> HomeAssistant: """Create a mock HomeAssistant instance.""" return MagicMock(spec=HomeAssistant) @pytest.fixture def listener_manager(hass_mock: HomeAssistant) -> TibberPricesListenerManager: """Create a ListenerManager instance for testing.""" return TibberPricesListenerManager(hass_mock, log_prefix="test_home") def test_schedule_quarter_hour_refresh_registers_timer( listener_manager: TibberPricesListenerManager, ) -> None: """ Test that quarter-hour refresh registers timer with correct boundaries. Timer #2 should trigger at :00, :15, :30, :45 exactly. """ handler = MagicMock() with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: mock_track.return_value = MagicMock() # Simulated cancel callback listener_manager.schedule_quarter_hour_refresh(handler) # Verify async_track_utc_time_change was called with correct parameters mock_track.assert_called_once() args, kwargs = mock_track.call_args # Check positional arguments assert args[0] == listener_manager.hass # hass instance assert args[1] == handler # callback function # Check keyword arguments assert "minute" in kwargs assert "second" in kwargs assert kwargs["minute"] == (0, 15, 30, 45) # QUARTER_HOUR_BOUNDARIES assert kwargs["second"] == 0 # Exact boundary def test_schedule_quarter_hour_refresh_cancels_existing_timer( listener_manager: TibberPricesListenerManager, ) -> None: """Test that scheduling quarter-hour refresh cancels any existing timer.""" handler = MagicMock() cancel_mock = MagicMock() with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: mock_track.return_value = cancel_mock # Schedule first timer listener_manager.schedule_quarter_hour_refresh(handler) first_cancel = listener_manager._quarter_hour_timer_cancel # noqa: SLF001 # type: ignore[attr-defined] assert first_cancel is not None # Schedule second timer (should cancel first) listener_manager.schedule_quarter_hour_refresh(handler) # Verify cancel was called cancel_mock.assert_called_once() def test_schedule_minute_refresh_registers_timer( listener_manager: TibberPricesListenerManager, ) -> None: """ Test that minute refresh registers timer with correct 30-second boundaries. Timer #3 should trigger at :XX:00 and :XX:30 every minute. """ handler = MagicMock() with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: mock_track.return_value = MagicMock() # Simulated cancel callback listener_manager.schedule_minute_refresh(handler) # Verify async_track_utc_time_change was called with correct parameters mock_track.assert_called_once() args, kwargs = mock_track.call_args # Check positional arguments assert args[0] == listener_manager.hass # hass instance assert args[1] == handler # callback function # Check keyword arguments assert "second" in kwargs assert kwargs["second"] == [0, 30] # Every 30 seconds def test_schedule_minute_refresh_cancels_existing_timer( listener_manager: TibberPricesListenerManager, ) -> None: """Test that scheduling minute refresh cancels any existing timer.""" handler = MagicMock() cancel_mock = MagicMock() with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: mock_track.return_value = cancel_mock # Schedule first timer listener_manager.schedule_minute_refresh(handler) first_cancel = listener_manager._minute_timer_cancel # noqa: SLF001 # type: ignore[attr-defined] assert first_cancel is not None # Schedule second timer (should cancel first) listener_manager.schedule_minute_refresh(handler) # Verify cancel was called cancel_mock.assert_called_once() def test_quarter_hour_timer_boundaries_match_constants( listener_manager: TibberPricesListenerManager, ) -> None: """ Test that timer boundaries match QUARTER_HOUR_BOUNDARIES constant. This ensures Timer #2 triggers match the expected quarter-hour marks. """ handler = MagicMock() with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: mock_track.return_value = MagicMock() listener_manager.schedule_quarter_hour_refresh(handler) _, kwargs = mock_track.call_args assert kwargs["minute"] == QUARTER_HOUR_BOUNDARIES @pytest.mark.asyncio async def test_quarter_hour_callback_execution( listener_manager: TibberPricesListenerManager, ) -> None: """ Test that quarter-hour timer callback is executed when scheduled time arrives. This simulates Home Assistant triggering the callback at quarter-hour boundary. """ callback_executed = False callback_time = None def test_callback(now: datetime) -> None: nonlocal callback_executed, callback_time callback_executed = True callback_time = now # We need to actually trigger the callback to test execution with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: # Capture the callback that would be registered registered_callback = None def capture_callback(_hass: Any, callback: Any, **_kwargs: Any) -> Any: nonlocal registered_callback registered_callback = callback return MagicMock() # Cancel function mock_track.side_effect = capture_callback listener_manager.schedule_quarter_hour_refresh(test_callback) # Simulate Home Assistant triggering the callback assert registered_callback is not None test_time = datetime(2025, 11, 22, 14, 15, 0, tzinfo=UTC) registered_callback(test_time) # Verify callback was executed assert callback_executed assert callback_time == test_time @pytest.mark.asyncio async def test_minute_callback_execution( listener_manager: TibberPricesListenerManager, ) -> None: """ Test that minute timer callback is executed when scheduled time arrives. This simulates Home Assistant triggering the callback at 30-second boundary. """ callback_executed = False callback_time = None def test_callback(now: datetime) -> None: nonlocal callback_executed, callback_time callback_executed = True callback_time = now with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: # Capture the callback that would be registered registered_callback = None def capture_callback(_hass: Any, callback: Any, **_kwargs: Any) -> Any: nonlocal registered_callback registered_callback = callback return MagicMock() # Cancel function mock_track.side_effect = capture_callback listener_manager.schedule_minute_refresh(test_callback) # Simulate Home Assistant triggering the callback at :30 seconds assert registered_callback is not None test_time = datetime(2025, 11, 22, 14, 23, 30, tzinfo=UTC) registered_callback(test_time) # Verify callback was executed assert callback_executed assert callback_time == test_time def test_multiple_timer_independence( listener_manager: TibberPricesListenerManager, ) -> None: """ Test that quarter-hour and minute timers operate independently. Both timers should be able to coexist without interfering. """ quarter_handler = MagicMock() minute_handler = MagicMock() with patch("custom_components.tibber_prices.coordinator.listeners.async_track_utc_time_change") as mock_track: mock_track.return_value = MagicMock() # Schedule both timers listener_manager.schedule_quarter_hour_refresh(quarter_handler) listener_manager.schedule_minute_refresh(minute_handler) # Verify both were registered (implementation detail check) assert hasattr(listener_manager, "_quarter_hour_timer_cancel") assert hasattr(listener_manager, "_minute_timer_cancel") assert listener_manager._quarter_hour_timer_cancel is not None # noqa: SLF001 # type: ignore[attr-defined] assert listener_manager._minute_timer_cancel is not None # noqa: SLF001 # type: ignore[attr-defined] # Verify async_track_utc_time_change was called twice assert mock_track.call_count == 2