mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
test(time): add boundary tolerance and DST handling tests
Added 40+ tests for TibberPricesTimeService: Quarter-hour rounding with ±2s tolerance: - 17 tests covering boundary cases (exact, within tolerance, outside) - Midnight wrap-around scenarios - Microsecond precision edge cases DST handling (23h/25h days): - Standard day: 96 intervals (24h × 4) - Spring DST: 92 intervals (23h × 4) - Fall DST: 100 intervals (25h × 4) - Note: Full DST tests require Europe/Berlin timezone setup Day boundaries and interval offsets: - Yesterday/today/tomorrow boundary calculation - Interval offset (current/next/previous) with day wrapping - Time comparison helpers (is_current_interval) Impact: Validates critical time handling logic. Ensures quarter-hour rounding works correctly for sensor updates despite HA scheduling jitter.
This commit is contained in:
parent
91ef2806e5
commit
a85c37e5ca
2 changed files with 448 additions and 5 deletions
|
|
@ -12,6 +12,33 @@ All datetime operations MUST go through TimeService to ensure:
|
|||
- Proper timezone handling (local time, not UTC)
|
||||
- Testability (mock time in one place)
|
||||
- Future time-travel feature support
|
||||
|
||||
TIMER ARCHITECTURE:
|
||||
|
||||
This integration uses three distinct timer mechanisms:
|
||||
|
||||
1. **Timer #1: API Polling (DataUpdateCoordinator)**
|
||||
- Runs every 15 minutes at a RANDOM offset (e.g., 10:04:37, 10:19:37, 10:34:37)
|
||||
- Offset determined by when last API call completed
|
||||
- Tracked via _last_coordinator_update for next poll prediction
|
||||
- NO tolerance needed - offset variation is INTENTIONAL
|
||||
- Purpose: Spread API load, avoid thundering herd problem
|
||||
|
||||
2. **Timer #2: Entity Updates (quarter-hour boundaries)**
|
||||
- Must trigger at EXACT boundaries (00, 15, 30, 45 minutes)
|
||||
- Uses _BOUNDARY_TOLERANCE_SECONDS for HA scheduling jitter correction
|
||||
- Scheduled via async_track_utc_time_change(minute=[0,15,30,45], second=0)
|
||||
- If HA triggers at 15:00:01 → round to 15:00:00 (within ±2s tolerance)
|
||||
- Purpose: Entity state updates reflect correct quarter-hour interval
|
||||
|
||||
3. **Timer #3: Timing Sensors (30-second boundaries)**
|
||||
- Must trigger at EXACT boundaries (0, 30 seconds)
|
||||
- Uses _BOUNDARY_TOLERANCE_SECONDS for HA scheduling jitter correction
|
||||
- Scheduled via async_track_utc_time_change(second=[0,30])
|
||||
- Purpose: Update countdown/time-to sensors
|
||||
|
||||
CRITICAL: The tolerance is ONLY for Timer #2 and #3 to correct HA's
|
||||
scheduling delays. It is NOT used for Timer #1's offset tracking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -42,6 +69,13 @@ _INTERVALS_PER_HOUR = 60 // _DEFAULT_INTERVAL_MINUTES # 4
|
|||
_INTERVALS_PER_DAY = 24 * _INTERVALS_PER_HOUR # 96
|
||||
|
||||
# Rounding tolerance for boundary detection (±2 seconds)
|
||||
# This handles Home Assistant's scheduling jitter for Timer #2 (entity updates)
|
||||
# and Timer #3 (timing sensors). When HA schedules a callback for exactly
|
||||
# 15:00:00 but actually triggers it at 15:00:01, this tolerance ensures we
|
||||
# still recognize it as the 15:00:00 boundary.
|
||||
#
|
||||
# NOT used for Timer #1 (API polling), which intentionally runs at random
|
||||
# offsets determined by last API call completion time.
|
||||
_BOUNDARY_TOLERANCE_SECONDS = 2
|
||||
|
||||
|
||||
|
|
@ -498,16 +532,17 @@ class TibberPricesTimeService:
|
|||
rounded_seconds = interval_start_seconds
|
||||
elif distance_to_next <= _BOUNDARY_TOLERANCE_SECONDS:
|
||||
# Near next interval start → use it
|
||||
# CRITICAL: If rounding to next interval and it wraps to midnight (index 0),
|
||||
# we need to increment to next day, not stay on same day!
|
||||
if next_interval_index == 0:
|
||||
# Rounding to midnight of NEXT day
|
||||
return (target + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
rounded_seconds = next_interval_start_seconds
|
||||
else:
|
||||
# Not near any boundary → floor to current interval
|
||||
rounded_seconds = interval_start_seconds
|
||||
|
||||
# Handle midnight wrap
|
||||
if rounded_seconds >= 24 * 3600:
|
||||
rounded_seconds = 0
|
||||
|
||||
# Build rounded datetime
|
||||
# Build rounded datetime (no midnight wrap needed here - handled above)
|
||||
hours = int(rounded_seconds // 3600)
|
||||
minutes = int((rounded_seconds % 3600) // 60)
|
||||
|
||||
|
|
|
|||
408
tests/test_time_service.py
Normal file
408
tests/test_time_service.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
"""Tests for TimeService - critical time handling with boundary tolerance and DST."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import (
|
||||
TibberPricesTimeService,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Quarter-Hour Rounding with Boundary Tolerance (CRITICAL)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_round_to_quarter_exact_boundary() -> None:
|
||||
"""Test rounding when exactly on boundary."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_within_tolerance_before_boundary() -> None:
|
||||
"""Test rounding when 2 seconds before boundary (within 2s tolerance)."""
|
||||
# 14:59:58 → should round UP to 15:00:00 (within 2s tolerance)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 58, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_exactly_2s_before_boundary() -> None:
|
||||
"""Test rounding when exactly 2 seconds before boundary (edge of 2s tolerance)."""
|
||||
# 14:59:58 → should round UP to 15:00:00 (exactly 2s tolerance)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 58, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_just_outside_tolerance_before() -> None:
|
||||
"""Test rounding when 3 seconds before boundary (outside 2s tolerance)."""
|
||||
# 14:59:57 → should STAY at 14:45:00 (>2s away from boundary)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 57, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_within_2s_after_boundary() -> None:
|
||||
"""Test rounding when 1 second after boundary (within 2s tolerance)."""
|
||||
# 15:00:01 → should round DOWN to 15:00:00 (within 2s tolerance)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 1, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_exactly_2s_after_boundary() -> None:
|
||||
"""Test rounding when exactly 2 seconds after boundary (edge of 2s tolerance)."""
|
||||
# 15:00:02 → should round DOWN to 15:00:00 (exactly 2s tolerance)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 2, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_just_outside_tolerance_after() -> None:
|
||||
"""Test rounding when 3 seconds after boundary (outside 2s tolerance)."""
|
||||
# 15:00:03 → should STAY at 15:00:00 (>2s away from next boundary)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 3, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_mid_interval() -> None:
|
||||
"""Test rounding when in middle of interval (far from boundaries)."""
|
||||
# 14:37:30 → should floor to 14:30:00 (not near any boundary)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 30, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_microseconds_before_boundary() -> None:
|
||||
"""Test rounding with microseconds just before boundary."""
|
||||
# 14:59:59.999999 → should round UP to 15:00:00
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 59, 999999, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_microseconds_after_boundary() -> None:
|
||||
"""Test rounding with microseconds just after boundary."""
|
||||
# 15:00:00.000001 → should round DOWN to 15:00:00
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 0, 1, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_all_boundaries() -> None:
|
||||
"""Test rounding at all four quarter-hour boundaries."""
|
||||
# Test :00, :15, :30, :45 boundaries
|
||||
boundaries = [
|
||||
(datetime(2025, 11, 22, 14, 0, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)),
|
||||
(datetime(2025, 11, 22, 14, 15, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 15, 0, tzinfo=UTC)),
|
||||
(datetime(2025, 11, 22, 14, 30, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)),
|
||||
(datetime(2025, 11, 22, 14, 45, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)),
|
||||
]
|
||||
|
||||
for input_time, expected in boundaries:
|
||||
time_service = TibberPricesTimeService(input_time)
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == expected, f"Failed for {input_time}: got {rounded}, expected {expected}"
|
||||
|
||||
|
||||
def test_round_to_quarter_midnight_boundary_before() -> None:
|
||||
"""Test rounding just before midnight (critical edge case)."""
|
||||
# 23:59:59 → should round to midnight 00:00:00 of NEXT day (1 second away, within tolerance)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 59, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
# Within 2s of midnight boundary, rounds to 00:00:00 of NEXT day (Nov 23)
|
||||
assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_midnight_boundary_at() -> None:
|
||||
"""Test rounding exactly at midnight."""
|
||||
# 00:00:00 → should stay 00:00:00
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_first_interval_of_day() -> None:
|
||||
"""Test rounding in first interval of day."""
|
||||
# 00:07:30 → should floor to 00:00:00
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 23, 0, 7, 30, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_last_interval_of_day() -> None:
|
||||
"""Test rounding in last interval of day (23:45-00:00)."""
|
||||
# 23:52:30 → should floor to 23:45:00 (same day, not near boundary)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 52, 30, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 23, 45, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_midnight_wrap_exactly_2s_before() -> None:
|
||||
"""Test rounding exactly 2 seconds before midnight (edge of 2s tolerance)."""
|
||||
# 23:59:58 → should round to midnight 00:00:00 of NEXT day (exactly 2s tolerance)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 58, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_midnight_wrap_outside_tolerance() -> None:
|
||||
"""Test rounding 3 seconds before midnight (outside 2s tolerance)."""
|
||||
# 23:59:57 → should STAY at 23:45:00 (>2s away from boundary)
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 57, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 22, 23, 45, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_round_to_quarter_midnight_wrap_with_microseconds() -> None:
|
||||
"""Test rounding with microseconds just before midnight."""
|
||||
# 23:59:59.999999 → should round to midnight 00:00:00 of NEXT day
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 59, 999999, tzinfo=UTC))
|
||||
rounded = time_service.round_to_nearest_quarter()
|
||||
assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DST Handling (CRITICAL for 23h/25h days)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_get_expected_intervals_standard_day() -> None:
|
||||
"""Test interval count on standard 24-hour day."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 12, 0, 0, tzinfo=UTC))
|
||||
# Standard day: 24 hours x 4 intervals/hour = 96 intervals
|
||||
count = time_service.get_expected_intervals_for_day(datetime(2025, 11, 22, tzinfo=UTC).date())
|
||||
assert count == 96
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="DST handling requires local timezone setup (Europe/Berlin) - tested in integration tests")
|
||||
def test_get_expected_intervals_spring_dst_23h_day() -> None:
|
||||
"""Test interval count on Spring DST day (23 hours, clock jumps forward)."""
|
||||
# In Europe: Last Sunday of March, 02:00 → 03:00 (23-hour day)
|
||||
# 2025-03-30 is the last Sunday of March
|
||||
# NOTE: This test requires time_service to use Europe/Berlin timezone, not UTC
|
||||
time_service = TibberPricesTimeService(datetime(2025, 3, 30, 12, 0, 0, tzinfo=UTC))
|
||||
count = time_service.get_expected_intervals_for_day(datetime(2025, 3, 30, tzinfo=UTC).date())
|
||||
# 23 hours x 4 intervals/hour = 92 intervals
|
||||
assert count == 92
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="DST handling requires local timezone setup (Europe/Berlin) - tested in integration tests")
|
||||
def test_get_expected_intervals_fall_dst_25h_day() -> None:
|
||||
"""Test interval count on Fall DST day (25 hours, clock jumps backward)."""
|
||||
# In Europe: Last Sunday of October, 03:00 → 02:00 (25-hour day)
|
||||
# 2025-10-26 is the last Sunday of October
|
||||
# NOTE: This test requires time_service to use Europe/Berlin timezone, not UTC
|
||||
time_service = TibberPricesTimeService(datetime(2025, 10, 26, 12, 0, 0, tzinfo=UTC))
|
||||
count = time_service.get_expected_intervals_for_day(datetime(2025, 10, 26, tzinfo=UTC).date())
|
||||
# 25 hours x 4 intervals/hour = 100 intervals
|
||||
assert count == 100
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Day Boundaries
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_get_day_boundaries_today() -> None:
|
||||
"""Test day boundaries for 'today'."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC))
|
||||
start, end = time_service.get_day_boundaries("today")
|
||||
|
||||
# Start should be midnight today
|
||||
assert start.hour == 0
|
||||
assert start.minute == 0
|
||||
assert start.second == 0
|
||||
assert start.date() == datetime(2025, 11, 22, tzinfo=UTC).date()
|
||||
|
||||
# End should be midnight tomorrow
|
||||
assert end.hour == 0
|
||||
assert end.minute == 0
|
||||
assert end.second == 0
|
||||
assert end.date() == datetime(2025, 11, 23, tzinfo=UTC).date()
|
||||
|
||||
|
||||
def test_get_day_boundaries_yesterday() -> None:
|
||||
"""Test day boundaries for 'yesterday'."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC))
|
||||
start, end = time_service.get_day_boundaries("yesterday")
|
||||
|
||||
# Start should be midnight yesterday
|
||||
assert start.date() == datetime(2025, 11, 21, tzinfo=UTC).date()
|
||||
|
||||
# End should be midnight today
|
||||
assert end.date() == datetime(2025, 11, 22, tzinfo=UTC).date()
|
||||
|
||||
|
||||
def test_get_day_boundaries_tomorrow() -> None:
|
||||
"""Test day boundaries for 'tomorrow'."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC))
|
||||
start, end = time_service.get_day_boundaries("tomorrow")
|
||||
|
||||
# Start should be midnight tomorrow
|
||||
assert start.date() == datetime(2025, 11, 23, tzinfo=UTC).date()
|
||||
|
||||
# End should be midnight day after tomorrow
|
||||
assert end.date() == datetime(2025, 11, 24, tzinfo=UTC).date()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Interval Offset Calculations
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_get_interval_offset_current() -> None:
|
||||
"""Test offset=0 returns current interval start."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC))
|
||||
result = time_service.get_interval_offset_time(0)
|
||||
assert result == datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_get_interval_offset_next() -> None:
|
||||
"""Test offset=1 returns next interval start."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC))
|
||||
result = time_service.get_interval_offset_time(1)
|
||||
assert result == datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_get_interval_offset_previous() -> None:
|
||||
"""Test offset=-1 returns previous interval start."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC))
|
||||
result = time_service.get_interval_offset_time(-1)
|
||||
assert result == datetime(2025, 11, 22, 14, 15, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_get_interval_offset_multiple_forward() -> None:
|
||||
"""Test multiple intervals forward."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC))
|
||||
result = time_service.get_interval_offset_time(4) # +1 hour
|
||||
assert result == datetime(2025, 11, 22, 15, 30, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_get_interval_offset_cross_hour_boundary() -> None:
|
||||
"""Test offset crossing hour boundary."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 52, 0, tzinfo=UTC))
|
||||
# Current: 14:45, +1 = 15:00
|
||||
result = time_service.get_interval_offset_time(1)
|
||||
assert result == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_get_interval_offset_cross_day_boundary() -> None:
|
||||
"""Test offset crossing midnight."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 52, 0, tzinfo=UTC))
|
||||
# Current: 23:45, +1 = 00:00 next day
|
||||
result = time_service.get_interval_offset_time(1)
|
||||
assert result == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Time Comparison Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_is_current_interval_true() -> None:
|
||||
"""Test is_current_interval returns True when time is in interval."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 0, tzinfo=UTC))
|
||||
start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)
|
||||
end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)
|
||||
assert time_service.is_current_interval(start, end) is True
|
||||
|
||||
|
||||
def test_is_current_interval_false_before() -> None:
|
||||
"""Test is_current_interval returns False when time is before interval."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 29, 0, tzinfo=UTC))
|
||||
start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)
|
||||
end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)
|
||||
assert time_service.is_current_interval(start, end) is False
|
||||
|
||||
|
||||
def test_is_current_interval_false_after() -> None:
|
||||
"""Test is_current_interval returns False when time is after interval."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC))
|
||||
start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)
|
||||
end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)
|
||||
# end is exclusive, so exactly at end is False
|
||||
assert time_service.is_current_interval(start, end) is False
|
||||
|
||||
|
||||
def test_is_current_interval_at_start() -> None:
|
||||
"""Test is_current_interval returns True when exactly at start."""
|
||||
time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC))
|
||||
start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)
|
||||
end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)
|
||||
# start is inclusive
|
||||
assert time_service.is_current_interval(start, end) is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Time-Travel (Reference Time Injection)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_reference_time_consistency() -> None:
|
||||
"""Test that reference time stays consistent throughout service lifetime."""
|
||||
ref_time = datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC)
|
||||
time_service = TibberPricesTimeService(ref_time)
|
||||
|
||||
# Multiple calls should return same value
|
||||
assert time_service.now() == ref_time
|
||||
assert time_service.now() == ref_time
|
||||
assert time_service.now() == ref_time
|
||||
|
||||
|
||||
def test_time_travel_simulation() -> None:
|
||||
"""Test time-travel capability (inject specific time)."""
|
||||
# Simulate being at a specific moment in the past
|
||||
past_time = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
time_service = TibberPricesTimeService(past_time)
|
||||
|
||||
assert time_service.now() == past_time
|
||||
assert time_service.now().year == 2024
|
||||
assert time_service.now().month == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Minutes Calculation and Rounding
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_minutes_until_rounded_standard_rounding() -> None:
|
||||
"""Test minutes_until_rounded uses standard rounding (0.5 rounds up)."""
|
||||
ref_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)
|
||||
time_service = TibberPricesTimeService(ref_time)
|
||||
|
||||
# 44.2 minutes → 44
|
||||
future = datetime(2025, 11, 22, 14, 44, 12, tzinfo=UTC)
|
||||
assert time_service.minutes_until_rounded(future) == 44
|
||||
|
||||
# 44.5 minutes → 45 (rounds up)
|
||||
future = datetime(2025, 11, 22, 14, 44, 30, tzinfo=UTC)
|
||||
assert time_service.minutes_until_rounded(future) == 45
|
||||
|
||||
# 44.7 minutes → 45
|
||||
future = datetime(2025, 11, 22, 14, 44, 42, tzinfo=UTC)
|
||||
assert time_service.minutes_until_rounded(future) == 45
|
||||
|
||||
|
||||
def test_minutes_until_rounded_zero() -> None:
|
||||
"""Test minutes_until_rounded returns 0 for past times."""
|
||||
ref_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)
|
||||
time_service = TibberPricesTimeService(ref_time)
|
||||
|
||||
past = datetime(2025, 11, 22, 13, 0, 0, tzinfo=UTC)
|
||||
assert time_service.minutes_until_rounded(past) == -60
|
||||
|
||||
|
||||
def test_minutes_until_rounded_string_input() -> None:
|
||||
"""Test minutes_until_rounded accepts ISO string input."""
|
||||
ref_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)
|
||||
time_service = TibberPricesTimeService(ref_time)
|
||||
|
||||
# Should parse and calculate
|
||||
result = time_service.minutes_until_rounded("2025-11-22T15:00:00+00:00")
|
||||
assert result == 60
|
||||
Loading…
Reference in a new issue