diff --git a/custom_components/tibber_prices/coordinator/listeners.py b/custom_components/tibber_prices/coordinator/listeners.py index 3791eee..41f88e9 100644 --- a/custom_components/tibber_prices/coordinator/listeners.py +++ b/custom_components/tibber_prices/coordinator/listeners.py @@ -157,7 +157,18 @@ class TibberPricesListenerManager: self, handler_callback: Callable[[datetime], None], ) -> None: - """Schedule 30-second entity refresh for timing sensors.""" + """ + Schedule 30-second entity refresh for timing sensors (Timer #3). + + This is Timer #3 in the integration's timer architecture. It MUST trigger + at exact 30-second boundaries (0, 30 seconds) to keep timing sensors + (countdown, time-to) accurate. + + Home Assistant may introduce small scheduling delays (jitter), which are + corrected using _BOUNDARY_TOLERANCE_SECONDS in time_service.py. + + Runs independently of Timer #1 (API polling), which operates at random offsets. + """ # Cancel any existing timer if self._minute_timer_cancel: self._minute_timer_cancel() diff --git a/custom_components/tibber_prices/coordinator/midnight_handler.py b/custom_components/tibber_prices/coordinator/midnight_handler.py new file mode 100644 index 0000000..159bfb3 --- /dev/null +++ b/custom_components/tibber_prices/coordinator/midnight_handler.py @@ -0,0 +1,121 @@ +""" +Midnight turnover detection and coordination handler. + +This module provides atomic coordination logic for midnight turnover between +multiple timers (DataUpdateCoordinator and quarter-hour refresh timer). + +The handler ensures that midnight turnover happens exactly once per day, +regardless of which timer detects it first. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + +class TibberPricesMidnightHandler: + """ + Handles midnight turnover detection and atomic coordination. + + This class encapsulates the logic for detecting when midnight has passed + and ensuring that data rotation happens exactly once per day. + + The atomic coordination works without locks by comparing date values: + - Timer #1 and Timer #2 both check if current_date > last_checked_date + - First timer to succeed marks the date as checked + - Second timer sees dates are equal and skips turnover + - Timer #3 doesn't participate in midnight logic (only 30-second timing updates) + + HA Restart Handling: + - If HA restarts after midnight, _last_midnight_check is None (fresh handler) + - But _last_actual_turnover is restored from cache with yesterday's date + - is_turnover_needed() detects the date mismatch and returns True + - Missed midnight turnover is caught up on first timer run after restart + + Attributes: + _last_midnight_check: Last datetime when midnight turnover was checked + _last_actual_turnover: Last datetime when turnover actually happened + + """ + + def __init__(self) -> None: + """Initialize the midnight handler.""" + self._last_midnight_check: datetime | None = None + self._last_actual_turnover: datetime | None = None + + def is_turnover_needed(self, now: datetime) -> bool: + """ + Check if midnight turnover is needed without side effects. + + This is a pure check function - it doesn't modify state. Call + mark_turnover_done() after successfully performing the turnover. + + IMPORTANT: If handler is uninitialized (HA restart), this checks if we + need to catch up on midnight turnover that happened while HA was down. + + Args: + now: Current datetime to check + + Returns: + True if midnight has passed since last check, False otherwise + + """ + # First time initialization after HA restart + if self._last_midnight_check is None: + # Check if we need to catch up on missed midnight turnover + # If last_actual_turnover exists, we can determine if midnight was missed + if self._last_actual_turnover is not None: + last_turnover_date = self._last_actual_turnover.date() + current_date = now.date() + # Turnover needed if we're on a different day than last turnover + return current_date > last_turnover_date + # Both None = fresh start, no turnover needed yet + return False + + # Extract date components + last_checked_date = self._last_midnight_check.date() + current_date = now.date() + + # Midnight crossed if current date is after last checked date + return current_date > last_checked_date + + def mark_turnover_done(self, now: datetime) -> None: + """ + Mark that midnight turnover has been completed. + + Updates both check timestamp and actual turnover timestamp to prevent + duplicate turnover by another timer. + + Args: + now: Current datetime when turnover was completed + + """ + self._last_midnight_check = now + self._last_actual_turnover = now + + def update_check_time(self, now: datetime) -> None: + """ + Update the last check time without marking turnover as done. + + Used for initializing the handler or updating the check timestamp + without triggering turnover logic. + + Args: + now: Current datetime to set as last check time + + """ + if self._last_midnight_check is None: + self._last_midnight_check = now + + @property + def last_turnover_time(self) -> datetime | None: + """Get the timestamp of the last actual turnover.""" + return self._last_actual_turnover + + @property + def last_check_time(self) -> datetime | None: + """Get the timestamp of the last midnight check.""" + return self._last_midnight_check diff --git a/docs/development/critical-patterns.md b/docs/development/critical-patterns.md new file mode 100644 index 0000000..4e3877d --- /dev/null +++ b/docs/development/critical-patterns.md @@ -0,0 +1,286 @@ +# Critical Behavior Patterns - Testing Guide + +**Purpose:** This documentation lists essential behavior patterns that must be tested to ensure production-quality code and prevent resource leaks. + +**Last Updated:** 2025-11-22 +**Test Coverage:** 41 tests implemented (100% of critical patterns) + +## 🎯 Why Are These Tests Critical? + +Home Assistant integrations run **continuously** in the background. Resource leaks lead to: +- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable +- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases +- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks +- **File Handle Leaks**: Storage files remain open → system resources exhausted + +## ✅ Test Categories + +### 1. Resource Cleanup (Memory Leak Prevention) + +**File:** `tests/test_resource_cleanup.py` + +#### 1.1 Listener Cleanup ✅ + +**What is tested:** +- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`) +- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`) +- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`) +- Sensor cleanup removes ALL registered listeners +- Binary sensor cleanup removes ALL registered listeners + +**Why critical:** +- Each registered listener holds references to Entity + Coordinator +- Without cleanup: Entities are not freed by GC → Memory Leak +- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed + +**Code Locations:** +- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()` +- `coordinator/core.py` → `register_lifecycle_callback()` +- `sensor/core.py` → `async_will_remove_from_hass()` +- `binary_sensor/core.py` → `async_will_remove_from_hass()` + +#### 1.2 Timer Cleanup ✅ + +**What is tested:** +- Quarter-hour timer is cancelled and reference cleared +- Minute timer is cancelled and reference cleared +- Both timers are cancelled together +- Cleanup works even when timers are `None` + +**Why critical:** +- Uncancelled timers continue running after integration unload +- HA's `async_track_utc_time_change()` creates persistent callbacks +- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates + +**Code Locations:** +- `coordinator/listeners.py` → `cancel_timers()` +- `coordinator/core.py` → `async_shutdown()` + +#### 1.3 Config Entry Cleanup ✅ + +**What is tested:** +- Options update listener is registered via `async_on_unload()` +- Cleanup function is correctly passed to `async_on_unload()` + +**Why critical:** +- `entry.add_update_listener()` registers permanent callback +- Without `async_on_unload()`: Listener remains active after reload → duplicate updates +- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))` + +**Code Locations:** +- `coordinator/core.py` → `__init__()` (listener registration) +- `__init__.py` → `async_unload_entry()` + +### 2. Cache Invalidation ✅ + +**File:** `tests/test_resource_cleanup.py` + +#### 2.1 Config Cache Invalidation + +**What is tested:** +- DataTransformer config cache is invalidated on options change +- PeriodCalculator config + period cache is invalidated +- Trend calculator cache is cleared on coordinator update + +**Why critical:** +- Stale config → Sensors use old user settings +- Stale period cache → Incorrect best/peak price periods +- Stale trend cache → Outdated trend analysis + +**Code Locations:** +- `coordinator/data_transformation.py` → `invalidate_config_cache()` +- `coordinator/periods.py` → `invalidate_config_cache()` +- `sensor/calculators/trend.py` → `clear_trend_cache()` + +### 3. Storage Cleanup ✅ + +**File:** `tests/test_resource_cleanup.py` + `tests/test_coordinator_shutdown.py` + +#### 3.1 Persistent Storage Removal + +**What is tested:** +- Storage file is deleted on config entry removal +- Cache is saved on shutdown (no data loss) + +**Why critical:** +- Without storage removal: Old files remain after uninstallation +- Without cache save on shutdown: Data loss on HA restart +- Storage path: `.storage/tibber_prices.{entry_id}` + +**Code Locations:** +- `__init__.py` → `async_remove_entry()` +- `coordinator/core.py` → `async_shutdown()` + +### 4. Timer Scheduling ✅ + +**File:** `tests/test_timer_scheduling.py` + +**What is tested:** +- Quarter-hour timer is registered with correct parameters +- Minute timer is registered with correct parameters +- Timers can be re-scheduled (override old timer) +- Midnight turnover detection works correctly + +**Why critical:** +- Wrong timer parameters → Entities update at wrong times +- Without timer override on re-schedule → Multiple parallel timers → Performance problem + +### 5. Sensor-to-Timer Assignment ✅ + +**File:** `tests/test_sensor_timer_assignment.py` + +**What is tested:** +- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys +- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys +- Both lists are disjoint (no overlap) +- Sensor and binary sensor platforms are checked + +**Why critical:** +- Wrong timer assignment → Sensors update at wrong times +- Overlap → Duplicate updates → Performance problem + +## 🚨 Additional Analysis (Nice-to-Have Patterns) + +These patterns were analyzed and classified as **not critical**: + +### 6. Async Task Management + +**Current Status:** Fire-and-forget pattern for short tasks +- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds) +- `coordinator/core.py` → Cache storage (short-lived, max 100ms) + +**Why no tests needed:** +- No long-running tasks (all < 2 seconds) +- HA's event loop handles short tasks automatically +- Task exceptions are already logged + +**If needed:** `_chart_refresh_task` tracking + cancel in `async_will_remove_from_hass()` + +### 7. API Session Cleanup + +**Current Status:** ✅ Correctly implemented +- `async_get_clientsession(hass)` is used (shared session) +- No new sessions are created +- HA manages session lifecycle automatically + +**Code:** `api/client.py` + `__init__.py` + +### 8. Translation Cache Memory + +**Current Status:** ✅ Bounded cache +- Max ~5-10 languages × 5KB = 50KB total +- Module-level cache without re-loading +- Practically no memory issue + +**Code:** `const.py` → `_TRANSLATIONS_CACHE`, `_STANDARD_TRANSLATIONS_CACHE` + +### 9. Coordinator Data Structure Integrity + +**Current Status:** Manually tested via `./scripts/develop` +- Midnight turnover works correctly (observed over several days) +- Missing keys are handled via `.get()` with defaults +- 80+ sensors access `coordinator.data` without errors + +**Structure:** +```python +coordinator.data = { + "user_data": {...}, + "priceInfo": { + "yesterday": [...], + "today": [...], + "tomorrow": [...], + "currency": "EUR" + } +} +``` + +### 10. Service Response Memory + +**Current Status:** HA's response lifecycle +- HA automatically frees service responses after return +- ApexCharts ~20KB response is one-time per call +- No response accumulation in integration code + +**Code:** `services/apexcharts.py` + +## 📊 Test Coverage Status + +### ✅ Implemented Tests (41 total) + +| Category | Status | Tests | File | Coverage | +|----------|--------|-------|------|----------| +| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% | +| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% | +| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | +| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% | +| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | +| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% | +| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% | +| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% | +| **TOTAL** | **✅** | **41** | | **100% (critical)** | + +### 📋 Analyzed but Not Implemented (Nice-to-Have) + +| Category | Status | Rationale | +|----------|--------|-----------| +| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) | +| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) | +| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) | +| Data Structure Integrity | 📋 | Would add test time without finding real issues | +| Service Response Memory | 📋 | HA automatically frees service responses | + +**Legend:** +- ✅ = Fully tested or pattern verified correct +- 📋 = Analyzed, low priority for testing (no known issues) + +## 🎯 Development Status + +### ✅ All Critical Patterns Tested + +All essential memory leak prevention patterns are covered by 41 tests: +- ✅ Listeners are correctly removed (no callback leaks) +- ✅ Timers are cancelled (no background task leaks) +- ✅ Config entry cleanup works (no dangling listeners) +- ✅ Caches are invalidated (no stale data issues) +- ✅ Storage is saved and cleaned up (no data loss) +- ✅ Timer scheduling works correctly (no update issues) +- ✅ Sensor-timer assignment is correct (no wrong updates) + +### 📋 Nice-to-Have Tests (Optional) + +If problems arise in the future, these tests can be added: + +1. **Async Task Management** - Pattern analyzed (fire-and-forget for short tasks) +2. **Data Structure Integrity** - Midnight rotation manually tested +3. **Service Response Memory** - HA's response lifecycle automatic + +**Conclusion:** The integration has production-quality test coverage for all critical resource leak patterns. + +## 🔍 How to Run Tests + +```bash +# Run all resource cleanup tests (14 tests) +./scripts/test tests/test_resource_cleanup.py -v + +# Run all critical pattern tests (41 tests) +./scripts/test tests/test_resource_cleanup.py tests/test_coordinator_shutdown.py \ + tests/test_timer_scheduling.py tests/test_sensor_timer_assignment.py -v + +# Run all tests with coverage +./scripts/test --cov=custom_components.tibber_prices --cov-report=html + +# Type checking and linting +./scripts/check + +# Manual memory leak test +# 1. Start HA: ./scripts/develop +# 2. Monitor RAM: watch -n 1 'ps aux | grep home-assistant' +# 3. Reload integration multiple times (HA UI: Settings → Devices → Tibber Prices → Reload) +# 4. RAM should stabilize (not grow continuously) +``` + +## 📚 References + +- **Home Assistant Cleanup Patterns**: https://developers.home-assistant.io/docs/integration_setup_failures/#cleanup +- **Async Best Practices**: https://developers.home-assistant.io/docs/asyncio_101/ +- **Memory Profiling**: https://docs.python.org/3/library/tracemalloc.html diff --git a/tests/test_midnight_handler.py b/tests/test_midnight_handler.py new file mode 100644 index 0000000..52ae086 --- /dev/null +++ b/tests/test_midnight_handler.py @@ -0,0 +1,322 @@ +""" +Unit tests for midnight turnover handler. + +These tests verify the atomic coordination logic that prevents duplicate +midnight turnover between multiple timers. +""" + +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from custom_components.tibber_prices.coordinator.midnight_handler import ( + TibberPricesMidnightHandler, +) + + +@pytest.mark.unit +def test_first_check_initializes_without_turnover() -> None: + """Test that the first check initializes but doesn't trigger turnover.""" + handler = TibberPricesMidnightHandler() + + time1 = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo")) + + # First check should return False (no turnover yet) + assert not handler.is_turnover_needed(time1) + + # But update_check_time should initialize + handler.update_check_time(time1) + assert handler.last_check_time == time1 + + +@pytest.mark.unit +def test_midnight_crossing_triggers_turnover() -> None: + """Test that crossing midnight triggers turnover detection.""" + handler = TibberPricesMidnightHandler() + + # Initialize at 23:59:59 on Nov 22 + time1 = datetime(2025, 11, 22, 23, 59, 59, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(time1) + + # Check at 00:00:00 on Nov 23 (midnight crossed!) + time2 = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(time2) + + +@pytest.mark.unit +def test_same_day_no_turnover() -> None: + """Test that multiple checks on the same day don't trigger turnover.""" + handler = TibberPricesMidnightHandler() + + # Initialize at 10:00 + time1 = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(time1) + + # Check later same day at 14:00 + time2 = datetime(2025, 11, 22, 14, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert not handler.is_turnover_needed(time2) + + # Check even later at 23:59 + time3 = datetime(2025, 11, 22, 23, 59, 59, tzinfo=ZoneInfo("Europe/Oslo")) + assert not handler.is_turnover_needed(time3) + + +@pytest.mark.unit +def test_atomic_coordination_prevents_duplicate_turnover() -> None: + """Test that marking turnover done prevents duplicate execution.""" + handler = TibberPricesMidnightHandler() + + # Initialize on Nov 22 + time1 = datetime(2025, 11, 22, 23, 50, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(time1) + + # Midnight on Nov 23 - first timer detects it + midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(midnight) + + # First timer marks it done + handler.mark_turnover_done(midnight) + + # Second timer checks shortly after - should return False + time2 = datetime(2025, 11, 23, 0, 0, 10, tzinfo=ZoneInfo("Europe/Oslo")) + assert not handler.is_turnover_needed(time2) + + +@pytest.mark.unit +def test_mark_turnover_updates_both_timestamps() -> None: + """Test that mark_turnover_done updates both check and turnover timestamps.""" + handler = TibberPricesMidnightHandler() + + time1 = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(time1) + + midnight = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.mark_turnover_done(midnight) + + # Both timestamps should be updated + assert handler.last_check_time == midnight + assert handler.last_turnover_time == midnight + + +@pytest.mark.unit +def test_next_day_triggers_new_turnover() -> None: + """Test that the next day's midnight triggers turnover again.""" + handler = TibberPricesMidnightHandler() + + # Day 1: Initialize and mark turnover done + day1 = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(day1) + + midnight1 = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(midnight1) + handler.mark_turnover_done(midnight1) + + # Day 2: Next midnight should trigger again + midnight2 = datetime(2025, 11, 24, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(midnight2) + + +@pytest.mark.unit +def test_multiple_days_skipped_still_triggers() -> None: + """Test that skipping multiple days still triggers turnover.""" + handler = TibberPricesMidnightHandler() + + # Last check on Nov 22 + time1 = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(time1) + + # Check 3 days later on Nov 25 (skipped 23rd and 24th) + time2 = datetime(2025, 11, 25, 14, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(time2) + + +@pytest.mark.unit +def test_update_check_time_without_triggering_turnover() -> None: + """Test that update_check_time initializes without turnover side effects.""" + handler = TibberPricesMidnightHandler() + + time1 = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(time1) + + # Last turnover should still be None + assert handler.last_check_time == time1 + assert handler.last_turnover_time is None + + # Next day should trigger turnover + time2 = datetime(2025, 11, 23, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(time2) + + +@pytest.mark.unit +def test_ha_restart_after_midnight_with_cached_turnover() -> None: + """ + Test HA restart scenario: cached turnover from yesterday, restart after midnight. + + Scenario: + - Nov 21 23:50: Last turnover marked (before HA shutdown) + - Nov 22 00:30: HA restarts (handler is fresh, but turnover was cached) + - Expected: Turnover should be triggered to catch up + + This simulates: mark_turnover_done() was called on Nov 21, handler state is + restored (simulated by manually setting _last_actual_turnover), then first + check after restart should detect missed midnight. + """ + handler = TibberPricesMidnightHandler() + + # Simulate: Last turnover was on Nov 21 at 23:59:59 (just before midnight) + last_turnover = datetime(2025, 11, 21, 23, 59, 59, tzinfo=ZoneInfo("Europe/Oslo")) + # Manually restore handler state (simulates cache restoration) + handler._last_actual_turnover = last_turnover # noqa: SLF001 - Test setup + + # HA restarts at Nov 22 00:30 (after midnight) + restart_time = datetime(2025, 11, 22, 0, 30, 0, tzinfo=ZoneInfo("Europe/Oslo")) + + # First check after restart - should detect missed midnight + # _last_midnight_check is None (fresh handler), but _last_actual_turnover exists + assert handler.is_turnover_needed(restart_time) is True + + # Perform turnover + handler.mark_turnover_done(restart_time) + + # Second check - should not trigger again + time_2 = datetime(2025, 11, 22, 1, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(time_2) is False + + +@pytest.mark.unit +def test_ha_restart_same_day_with_cached_turnover() -> None: + """ + Test HA restart scenario: cached turnover from today, restart same day. + + Scenario: + - Nov 22 00:05: Turnover happened (after HA started) + - Nov 22 14:00: HA restarts + - Expected: No turnover needed (already done today) + + This ensures we don't trigger duplicate turnover when restarting same day. + """ + handler = TibberPricesMidnightHandler() + + # Simulate: Last turnover was today at 00:05 + last_turnover = datetime(2025, 11, 22, 0, 5, 0, tzinfo=ZoneInfo("Europe/Oslo")) + # Manually restore handler state (simulates cache restoration) + handler._last_actual_turnover = last_turnover # noqa: SLF001 - Test setup + + # HA restarts at 14:00 same day + restart_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + + # First check after restart - should NOT trigger (same day) + assert handler.is_turnover_needed(restart_time) is False + + # Initialize check time for subsequent checks + handler.update_check_time(restart_time) + + # Later check same day - still no turnover + time_2 = datetime(2025, 11, 22, 18, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(time_2) is False + + +@pytest.mark.unit +def test_simultaneous_timer_checks_at_midnight() -> None: + """ + Test race condition: Timer #1 and Timer #2 both check at exactly 00:00:00. + + This is the critical atomic coordination test - both timers detect midnight + simultaneously, but only one should perform turnover. + + Scenario: + - Nov 21 23:45: Both timers initialized + - Nov 22 00:00:00: Both timers check simultaneously + - Expected: First check returns True, second returns False (atomic) + """ + handler = TibberPricesMidnightHandler() + + # Initialize on Nov 21 at 23:45 + init_time = datetime(2025, 11, 21, 23, 45, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(init_time) + + # Both timers wake up at exactly 00:00:00 + midnight = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + + # Timer #1 checks first (or both check "simultaneously" in sequence) + timer1_check = handler.is_turnover_needed(midnight) + assert timer1_check is True # Midnight crossed + + # Timer #1 performs turnover + handler.mark_turnover_done(midnight) + + # Timer #2 checks immediately after (could be microseconds later) + timer2_check = handler.is_turnover_needed(midnight) + assert timer2_check is False # Already done by Timer #1 + + # Verify state: turnover happened exactly once + assert handler.last_turnover_time == midnight + assert handler.last_check_time == midnight + + +@pytest.mark.unit +def test_timer_check_at_00_00_01_after_turnover_at_00_00_00() -> None: + """ + Test edge case: One timer does turnover at 00:00:00, second checks at 00:00:01. + + This ensures that even a 1-second delay doesn't cause duplicate turnover + when both checks happen on the same calendar day. + + Scenario: + - Nov 22 00:00:00: Timer #1 does turnover + - Nov 22 00:00:01: Timer #2 checks (1 second later) + - Expected: Timer #2 should skip (same day) + """ + handler = TibberPricesMidnightHandler() + + # Initialize on Nov 21 + init_time = datetime(2025, 11, 21, 23, 45, 0, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(init_time) + + # Timer #1 checks at exactly 00:00:00 + midnight_00 = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(midnight_00) is True + handler.mark_turnover_done(midnight_00) + + # Timer #2 checks 1 second later + midnight_01 = datetime(2025, 11, 22, 0, 0, 1, tzinfo=ZoneInfo("Europe/Oslo")) + assert handler.is_turnover_needed(midnight_01) is False + + # Both timestamps point to same day - no duplicate + assert handler.last_turnover_time.date() == midnight_01.date() # type: ignore[union-attr] + + +@pytest.mark.unit +def test_rapid_consecutive_checks_same_second() -> None: + """ + Test rapid consecutive checks within the same second at midnight. + + Simulates worst-case race condition where both timers fire within + the same second (e.g., 00:00:00.123 and 00:00:00.456). + + Expected: First check triggers, all subsequent checks skip. + """ + handler = TibberPricesMidnightHandler() + + # Initialize on Nov 21 + init_time = datetime(2025, 11, 21, 23, 59, 59, tzinfo=ZoneInfo("Europe/Oslo")) + handler.update_check_time(init_time) + + # Simulate 3 checks at midnight within the same second + midnight_check1 = datetime(2025, 11, 22, 0, 0, 0, 123000, tzinfo=ZoneInfo("Europe/Oslo")) + midnight_check2 = datetime(2025, 11, 22, 0, 0, 0, 456000, tzinfo=ZoneInfo("Europe/Oslo")) + midnight_check3 = datetime(2025, 11, 22, 0, 0, 0, 789000, tzinfo=ZoneInfo("Europe/Oslo")) + + # First check: turnover needed + assert handler.is_turnover_needed(midnight_check1) is True + handler.mark_turnover_done(midnight_check1) + + # Second and third checks: already done + assert handler.is_turnover_needed(midnight_check2) is False + assert handler.is_turnover_needed(midnight_check3) is False + + # Verify: turnover happened exactly once + assert handler.last_turnover_time == midnight_check1