hass.tibber_prices/tests/test_timer_scheduling.py
Julian Pawlowski 91ef2806e5 test(timers): comprehensive timer architecture validation
Added 60+ tests for three-timer architecture:

Timer #1 (API polling): next_api_poll_time calculation
- 8 tests covering timer offset calculation before/after 13:00
- Tests tomorrow data presence logic
- Verifies minute/second offset preservation

Timer #2 (quarter-hour refresh): :00, :15, :30, :45 boundaries
- 10 tests covering registration, cancellation, callback execution
- Verifies exact boundary timing (second=0)
- Tests independence from Timer #3

Timer #3 (minute refresh): :00, :30 every minute
- 6 tests covering 30-second boundary registration
- Verifies timing sensors assignment
- Tests countdown/progress update frequency

Sensor assignment:
- 20+ tests mapping 80+ sensors to correct timers
- Verifies TIME_SENSITIVE and MINUTE_UPDATE constants
- Catches missing/incorrect timer assignments

Impact: Comprehensive validation of timer architecture prevents
regression in entity update scheduling. Documents which sensors
use which timers.
2025-11-22 04:46:30 +00:00

266 lines
9.4 KiB
Python

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