hass.tibber_prices/tests/test_sensor_timer_assignment.py

459 lines
16 KiB
Python

"""
Test sensor-to-timer assignment correctness.
This tests the CRITICAL mapping between sensor entities and update timers:
- TIME_SENSITIVE sensors → Timer #2 (quarter-hour: :00, :15, :30, :45)
- MINUTE_UPDATE sensors → Timer #3 (minute: :00, :30)
- All other sensors → No timer (only update on API data arrival)
Ensures:
1. Each sensor is assigned to the correct timer
2. Timer constants match sensor definitions
3. No sensors are missing from or incorrectly added to timer groups
"""
from custom_components.tibber_prices.binary_sensor.definitions import (
ENTITY_DESCRIPTIONS as BINARY_SENSOR_ENTITY_DESCRIPTIONS,
)
from custom_components.tibber_prices.coordinator.constants import (
MINUTE_UPDATE_ENTITY_KEYS,
TIME_SENSITIVE_ENTITY_KEYS,
)
from custom_components.tibber_prices.sensor.definitions import ENTITY_DESCRIPTIONS
def test_time_sensitive_sensors_are_valid() -> None:
"""
Test that all TIME_SENSITIVE_ENTITY_KEYS correspond to actual sensors.
Timer #2 (quarter-hour) should only trigger for sensors that exist.
"""
all_sensor_keys = {desc.key for desc in ENTITY_DESCRIPTIONS}
all_binary_sensor_keys = {desc.key for desc in BINARY_SENSOR_ENTITY_DESCRIPTIONS}
all_entity_keys = all_sensor_keys | all_binary_sensor_keys
for entity_key in TIME_SENSITIVE_ENTITY_KEYS:
assert entity_key in all_entity_keys, (
f"TIME_SENSITIVE key '{entity_key}' not found in sensor/binary_sensor definitions"
)
def test_minute_update_sensors_are_valid() -> None:
"""
Test that all MINUTE_UPDATE_ENTITY_KEYS correspond to actual sensors.
Timer #3 (minute) should only trigger for sensors that exist.
"""
all_sensor_keys = {desc.key for desc in ENTITY_DESCRIPTIONS}
all_binary_sensor_keys = {desc.key for desc in BINARY_SENSOR_ENTITY_DESCRIPTIONS}
all_entity_keys = all_sensor_keys | all_binary_sensor_keys
for entity_key in MINUTE_UPDATE_ENTITY_KEYS:
assert entity_key in all_entity_keys, (
f"MINUTE_UPDATE key '{entity_key}' not found in sensor/binary_sensor definitions"
)
def test_no_overlap_between_timer_groups() -> None:
"""
Test that TIME_SENSITIVE and MINUTE_UPDATE groups are mutually exclusive.
A sensor should never be in both timer groups simultaneously.
This would cause duplicate updates and wasted resources.
"""
overlap = TIME_SENSITIVE_ENTITY_KEYS & MINUTE_UPDATE_ENTITY_KEYS
assert not overlap, (
f"Sensors should not be in both TIME_SENSITIVE and MINUTE_UPDATE: {overlap}\n"
"Each sensor should use only ONE timer for updates."
)
def test_interval_sensors_use_quarter_hour_timer() -> None:
"""
Test that interval-based sensors (current/next/previous) use Timer #2.
These sensors need updates every 15 minutes because they reference
specific 15-minute intervals that change at quarter-hour boundaries.
"""
interval_sensors = [
"current_interval_price",
"current_interval_price_base", # Energy Dashboard version (€/kWh)
"next_interval_price",
"previous_interval_price",
"current_interval_price_level",
"next_interval_price_level",
"previous_interval_price_level",
"current_interval_price_rating",
"next_interval_price_rating",
"previous_interval_price_rating",
]
for sensor_key in interval_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Interval sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_rolling_hour_sensors_use_quarter_hour_timer() -> None:
"""
Test that rolling hour sensors (5-interval windows) use Timer #2.
Rolling hour calculations depend on current interval position,
which changes every 15 minutes.
"""
rolling_hour_sensors = [
"current_hour_average_price",
"next_hour_average_price",
"current_hour_price_level",
"next_hour_price_level",
"current_hour_price_rating",
"next_hour_price_rating",
]
for sensor_key in rolling_hour_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Rolling hour sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_future_mean_sensors_use_quarter_hour_timer() -> None:
"""
Test that future N-hour mean sensors use Timer #2.
Future means calculate rolling windows starting from "next interval",
which changes every 15 minutes.
"""
future_mean_sensors = [
"next_avg_1h",
"next_avg_2h",
"next_avg_3h",
"next_avg_4h",
"next_avg_5h",
"next_avg_6h",
"next_avg_8h",
"next_avg_12h",
]
for sensor_key in future_mean_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Future mean sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_trend_sensors_use_quarter_hour_timer() -> None:
"""
Test that price trend sensors use Timer #2.
Trend analysis depends on current interval position and
needs updates at quarter-hour boundaries.
"""
trend_sensors = [
"current_price_trend",
"next_price_trend_change",
"price_trend_1h",
"price_trend_2h",
"price_trend_3h",
"price_trend_4h",
"price_trend_5h",
"price_trend_6h",
"price_trend_8h",
"price_trend_12h",
]
for sensor_key in trend_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Trend sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_window_24h_sensors_use_quarter_hour_timer() -> None:
"""
Test that trailing/leading 24h window sensors use Timer #2.
24h windows are calculated relative to current interval,
which changes every 15 minutes.
"""
window_24h_sensors = [
"trailing_price_average",
"leading_price_average",
"trailing_price_min",
"trailing_price_max",
"leading_price_min",
"leading_price_max",
]
for sensor_key in window_24h_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"24h window sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_period_binary_sensors_use_quarter_hour_timer() -> None:
"""
Test that best/peak price period binary sensors use Timer #2.
Binary sensors check if current time is within a period.
Periods can only change at quarter-hour interval boundaries.
"""
period_binary_sensors = [
"best_price_period",
"peak_price_period",
]
for sensor_key in period_binary_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Period binary sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_period_timestamp_sensors_use_quarter_hour_timer() -> None:
"""
Test that period timestamp sensors (end_time, next_start_time) use Timer #2.
Timestamp sensors report when periods end/start. Since periods can only
change at quarter-hour boundaries (intervals), they only need quarter-hour updates.
"""
timestamp_sensors = [
"best_price_end_time",
"best_price_next_start_time",
"peak_price_end_time",
"peak_price_next_start_time",
]
for sensor_key in timestamp_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Timestamp sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
)
def test_timing_sensors_use_minute_timer() -> None:
"""
Test that countdown/progress timing sensors use Timer #3.
These sensors track time remaining and progress percentage within periods.
They need minute-by-minute updates for accurate countdown displays.
IMPORTANT: Timestamp sensors (end_time, next_start_time) do NOT use Timer #3
because periods can only change at quarter-hour boundaries.
"""
timing_sensors = [
"best_price_remaining_minutes",
"best_price_progress",
"best_price_next_in_minutes", # Corrected from best_price_next_start_minutes
"peak_price_remaining_minutes",
"peak_price_progress",
"peak_price_next_in_minutes", # Corrected from peak_price_next_start_minutes
]
for sensor_key in timing_sensors:
assert sensor_key in MINUTE_UPDATE_ENTITY_KEYS, (
f"Timing sensor '{sensor_key}' should be MINUTE_UPDATE (Timer #3)"
)
# Also verify it's NOT in TIME_SENSITIVE (no double updates)
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
f"Timing sensor '{sensor_key}' should NOT be in TIME_SENSITIVE\n"
"Minute updates are sufficient for countdown/progress tracking."
)
def test_lifecycle_sensor_uses_quarter_hour_timer_with_state_filter() -> None:
"""
Test that data lifecycle status sensor uses Timer #2 WITH state-change filtering.
The lifecycle sensor needs quarter-hour precision for detecting:
- 23:45: turnover_pending (last interval before midnight)
- 00:00: turnover complete (after midnight API update)
- 13:00: searching_tomorrow (when tomorrow data search begins)
To prevent recorder spam, it uses state-change filtering in both:
- _handle_coordinator_update() (Timer #1)
- _handle_time_sensitive_update() (Timer #2)
State is only written to recorder if it actually changed.
This reduces recorder entries from ~96/day to ~10-15/day.
"""
# Lifecycle sensor MUST be in TIME_SENSITIVE_ENTITY_KEYS for quarter-hour precision
assert "data_lifecycle_status" in TIME_SENSITIVE_ENTITY_KEYS, (
"Lifecycle sensor needs quarter-hour updates for precise state transitions\n"
"at 23:45 (turnover_pending), 00:00 (turnover complete), 13:00 (searching_tomorrow).\n"
"State-change filter in _handle_time_sensitive_update() prevents recorder spam."
)
def test_daily_stat_sensors_not_in_timers() -> None:
"""
Test that daily statistic sensors (min/max/avg) do NOT use timers.
Daily stats don't depend on current time - they represent full-day aggregates.
They only need updates when new API data arrives (not time-dependent).
"""
daily_stat_sensors = [
# Today/tomorrow min prices
"daily_min_price_today",
"daily_min_price_tomorrow",
# Today/tomorrow max prices
"daily_max_price_today",
"daily_max_price_tomorrow",
# Today/tomorrow averages
"daily_average_price_today",
"daily_average_price_tomorrow",
# Daily price levels
"daily_price_level_today",
"daily_price_level_tomorrow",
# Daily price ratings
"daily_price_rating_today",
"daily_price_rating_tomorrow",
]
for sensor_key in daily_stat_sensors:
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
f"Daily stat sensor '{sensor_key}' should NOT use Timer #2\n"
"Daily statistics don't depend on current time - only on API data arrival."
)
assert sensor_key not in MINUTE_UPDATE_ENTITY_KEYS, (
f"Daily stat sensor '{sensor_key}' should NOT use Timer #3\n"
"Daily statistics don't need minute-by-minute updates."
)
def test_volatility_sensors_not_in_timers() -> None:
"""
Test that volatility sensors do NOT use timers.
Volatility analyzes price variation over fixed time windows.
Values only change when new API data arrives (not time-dependent).
"""
volatility_sensors = [
"today_volatility_level",
"tomorrow_volatility_level",
"yesterday_volatility_level",
"next_24h_volatility_level",
]
for sensor_key in volatility_sensors:
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
f"Volatility sensor '{sensor_key}' should NOT use Timer #2\n"
"Volatility calculates over fixed time windows - not time-dependent."
)
assert sensor_key not in MINUTE_UPDATE_ENTITY_KEYS, (
f"Volatility sensor '{sensor_key}' should NOT use Timer #3\n"
"Volatility doesn't need minute-by-minute updates."
)
def test_diagnostic_sensors_not_in_timers() -> None:
"""
Test that diagnostic/metadata sensors do NOT use timers.
Diagnostic sensors report static metadata or system state.
They only update when configuration changes or new API data arrives.
"""
diagnostic_sensors = [
"data_last_updated",
"home_id",
"currency_code",
"price_unit",
"grid_company",
"price_level",
"address_line1",
"address_line2",
"address_line3",
"zip_code",
"city",
"country",
"latitude",
"longitude",
"time_zone",
"estimated_annual_consumption",
"subscription_status",
"chart_data_export",
]
for sensor_key in diagnostic_sensors:
# Skip data_lifecycle_status - it needs quarter-hour updates
if sensor_key == "data_lifecycle_status":
continue
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
f"Diagnostic sensor '{sensor_key}' should NOT use Timer #2\nDiagnostic data doesn't depend on current time."
)
assert sensor_key not in MINUTE_UPDATE_ENTITY_KEYS, (
f"Diagnostic sensor '{sensor_key}' should NOT use Timer #3\n"
"Diagnostic data doesn't need minute-by-minute updates."
)
def test_timer_constants_are_comprehensive() -> None:
"""
Test that timer constants account for all time-dependent sensors.
Verifies no time-dependent sensors are missing from timer groups.
This is a safety check to catch sensors that need timers but don't have them.
"""
all_sensor_keys = {desc.key for desc in ENTITY_DESCRIPTIONS}
all_binary_sensor_keys = {desc.key for desc in BINARY_SENSOR_ENTITY_DESCRIPTIONS}
all_entity_keys = all_sensor_keys | all_binary_sensor_keys
sensors_with_timers = TIME_SENSITIVE_ENTITY_KEYS | MINUTE_UPDATE_ENTITY_KEYS
# Expected time-dependent sensor patterns
time_dependent_patterns = [
"current_",
"next_",
"previous_",
"trailing_",
"leading_",
"_remaining_",
"_progress",
"_next_in_", # Corrected from _next_start_
"_end_time",
"_period", # Binary sensors checking if NOW is in period
"price_trend_",
"next_avg_",
]
# Known exceptions that look time-dependent but aren't
known_exceptions = {
"data_last_updated", # Timestamp of last update, not time-dependent
"next_24h_volatility", # Uses fixed 24h window from current time, updated on API data
"best_price_period_duration", # Duration state in hours (static per period), no minute-by-minute timer
"peak_price_period_duration", # Duration state in hours (static per period), no minute-by-minute timer
}
potentially_missing = [
sensor_key
for sensor_key in all_entity_keys
if (
any(pattern in sensor_key for pattern in time_dependent_patterns)
and sensor_key not in sensors_with_timers
and sensor_key not in known_exceptions
)
]
assert not potentially_missing, (
f"These sensors appear time-dependent but aren't in any timer group:\n"
f"{potentially_missing}\n\n"
"If they truly need time-based updates, add them to TIME_SENSITIVE_ENTITY_KEYS\n"
"or MINUTE_UPDATE_ENTITY_KEYS in coordinator/constants.py"
)
def test_timer_group_sizes() -> None:
"""
Test timer group sizes as documentation/regression detection.
This isn't a strict requirement, but significant changes in group sizes
might indicate accidental additions/removals.
"""
# As of Nov 2025
expected_time_sensitive_min = 40 # At least 40 sensors
expected_minute_update = 6 # Exactly 6 timing sensors
assert len(TIME_SENSITIVE_ENTITY_KEYS) >= expected_time_sensitive_min, (
f"Expected at least {expected_time_sensitive_min} TIME_SENSITIVE sensors, got {len(TIME_SENSITIVE_ENTITY_KEYS)}"
)
assert len(MINUTE_UPDATE_ENTITY_KEYS) == expected_minute_update, (
f"Expected exactly {expected_minute_update} MINUTE_UPDATE sensors, got {len(MINUTE_UPDATE_ENTITY_KEYS)}"
)