hass.tibber_prices/tests/test_next_api_poll.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

320 lines
12 KiB
Python

"""
Unit tests for next_api_poll_time calculation logic.
Tests the precise minute/second offset calculation for Timer #1 scheduling,
ensuring accurate prediction of when the next API poll will occur.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import Mock
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.coordinator.constants import UPDATE_INTERVAL
from custom_components.tibber_prices.sensor.calculators.lifecycle import (
TibberPricesLifecycleCalculator,
)
@pytest.mark.unit
def test_next_api_poll_before_13_with_timer_offset() -> None:
"""
Test next_api_poll before 13:00 with known timer offset.
Scenario: Timer runs at X:04:37 (4 minutes 37 seconds past quarter-hour)
Current time: 10:19:37 (before 13:00)
Expected: Next poll at 13:04:37 (first timer execution at or after 13:00)
"""
# Mock coordinator with timer history
coordinator = Mock()
coordinator.time = Mock()
# Current time: 10:19:37 (Timer just ran)
current_time = datetime(2025, 11, 22, 10, 19, 37, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries (needed to determine if tomorrow data is missing)
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
# Timer last ran at 10:19:37 (offset: 4 min 37 sec past quarter)
coordinator._last_coordinator_update = current_time # noqa: SLF001
# Mock coordinator.data (no tomorrow data yet)
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}}
# Mock _needs_tomorrow_data (not relevant for this case)
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001
# Create calculator
calculator = TibberPricesLifecycleCalculator(coordinator)
# Calculate next poll
next_poll = calculator.get_next_api_poll_time()
# Should be 13:04:37 (first timer at or after 13:00 with same offset)
assert next_poll is not None
assert next_poll.hour == 13
assert next_poll.minute == 4
assert next_poll.second == 37
@pytest.mark.unit
def test_next_api_poll_before_13_different_offset() -> None:
"""
Test next_api_poll with different timer offset.
Scenario: Timer runs at X:11:22 (11 minutes 22 seconds past quarter-hour)
Current time: 09:26:22
Expected: Next poll at 13:11:22
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 9, 26, 22, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
coordinator._last_coordinator_update = current_time # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}}
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
assert next_poll is not None
assert next_poll.hour == 13
assert next_poll.minute == 11
assert next_poll.second == 22
@pytest.mark.unit
def test_next_api_poll_before_13_offset_requires_14xx() -> None:
"""
Test next_api_poll when timer offset doesn't fit in 13:xx hour.
Scenario: Timer runs at X:58:15 (58 minutes past hour, 13 min past 45-min mark)
Current time: 11:58:15
Expected: Next poll at 13:13:15 (13:00+13min, 13:15+13min, 13:30+13min, 13:45+13min)
Note: Even extreme offsets fit in 13:xx hour, 14:xx overflow is theoretical edge case
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 11, 58, 15, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
# Timer offset: 58 % 15 = 13 minutes past quarter-hour
coordinator._last_coordinator_update = current_time # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}}
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
# Even with 13-minute offset, first valid is 13:13:15
assert next_poll is not None
assert next_poll.hour == 13
assert next_poll.minute == 13
assert next_poll.second == 15
@pytest.mark.unit
def test_next_api_poll_before_13_no_timer_history() -> None:
"""
Test next_api_poll fallback when no timer history exists.
Scenario: Integration just started, no _last_coordinator_update yet
Current time: 10:30:00
Expected: Fallback to 13:00:00
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 10, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
# No timer history
coordinator._last_coordinator_update = None # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}}
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
# Should fallback to 13:00:00
assert next_poll is not None
assert next_poll.hour == 13
assert next_poll.minute == 0
assert next_poll.second == 0
@pytest.mark.unit
def test_next_api_poll_after_13_tomorrow_missing() -> None:
"""
Test next_api_poll after 13:00 when tomorrow data is missing.
Scenario: After 13:00, actively polling for tomorrow data
Current time: 14:30:00
Last update: 14:15:45
Expected: Last update + UPDATE_INTERVAL (15 minutes) = 14:30:45
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = datetime(2025, 11, 22, 14, 15, 45, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
coordinator._last_coordinator_update = last_update # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}} # Tomorrow missing!
coordinator._needs_tomorrow_data.return_value = True # noqa: SLF001 - Tomorrow missing!
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
# Should be last_update + 15 minutes
expected = last_update + UPDATE_INTERVAL
assert next_poll is not None
assert next_poll == expected
assert next_poll.minute == 30
assert next_poll.second == 45
@pytest.mark.unit
def test_next_api_poll_after_13_tomorrow_present() -> None:
"""
Test next_api_poll after 13:00 when tomorrow data is present.
Scenario: After 13:00, tomorrow data fetched, predicting tomorrow's first poll
Current time: 15:34:12
Timer offset: 4 minutes 12 seconds past quarter (from 15:34:12)
Expected: Tomorrow at 13:04:12
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 15, 34, 12, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
# Timer offset: 34 % 15 = 4 minutes past quarter-hour
coordinator._last_coordinator_update = current_time # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": [4, 5, 6]}} # Tomorrow present!
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001 - Tomorrow present!
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
# Should be tomorrow at 13:04:12
assert next_poll is not None
assert next_poll.day == 23 # Tomorrow
assert next_poll.hour == 13
assert next_poll.minute == 4
assert next_poll.second == 12
@pytest.mark.unit
def test_next_api_poll_exact_13_00_boundary() -> None:
"""
Test next_api_poll exactly at 13:00:00 boundary.
Scenario: Timer runs exactly at 13:00:00 (offset: 0 min 0 sec)
Current time: 13:00:00
Expected: 13:00:00 (current time matches first valid slot)
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 13, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
# Timer runs at exact quarter-hour boundaries
coordinator._last_coordinator_update = current_time # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}}
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
# Should be 13:00:00 (first valid slot)
assert next_poll is not None
assert next_poll.hour == 13
assert next_poll.minute == 0
assert next_poll.second == 0
@pytest.mark.unit
def test_next_api_poll_offset_spans_multiple_quarters() -> None:
"""
Test timer offset calculation across different quarter-hour marks.
Scenario: Timer at 12:47:33 (offset: 2 min 33 sec past 45-min mark)
Expected: 13:02:33, 13:17:33, 13:32:33, or 13:47:33 depending on >= 13:00
Result: First valid is 13:02:33
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 12, 47, 33, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator.time.as_local.side_effect = lambda dt: dt
# Mock get_day_boundaries
today_midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
tomorrow_midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.get_day_boundaries.return_value = (today_midnight, tomorrow_midnight)
# Timer offset: 47 % 15 = 2 minutes past quarter
coordinator._last_coordinator_update = current_time # noqa: SLF001
coordinator.data = {"priceInfo": {"today": [1, 2, 3], "tomorrow": []}}
coordinator._needs_tomorrow_data.return_value = False # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
next_poll = calculator.get_next_api_poll_time()
# Should be 13:02:33 (first quarter-hour slot >= 13:00 with offset 2:33)
assert next_poll is not None
assert next_poll.hour == 13
assert next_poll.minute == 2
assert next_poll.second == 33