hass.tibber_prices/tests/test_timer_scheduling.py
Julian Pawlowski 1d065b11cd fix(services): use injected now in resolve_search_range day offset
_resolve_time_with_day_offset() was calling dt_util.now() internally
instead of using the injected now parameter. This caused incorrect date
calculations in tests and any caller that passes a specific reference time.

Also add missing price_rank_* sensor keys to TIME_SENSITIVE_ENTITY_KEYS
in coordinator/constants.py so quarter-hour refresh is registered for all
11 price rank sensors (current/next/previous interval and hour variants).

Rename dt as dt_utils → dt as dt_util (ICN001) across 11 files to follow
the project-wide import alias convention. Apply ruff auto-fixes for import
ordering and collapsing single-item imports throughout the codebase.

Released-Bug: no
2026-04-14 19:33:24 +00:00

262 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