mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
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.
320 lines
12 KiB
Python
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
|