hass.tibber_prices/tests/test_sensor_timer_assignment.py
Julian Pawlowski 60e05e0815 refactor(currency)!: rename major/minor to base/subunit currency terminology
Complete terminology migration from confusing "major/minor" to clearer
"base/subunit" currency naming throughout entire codebase, translations,
documentation, tests, and services.

BREAKING CHANGES:

1. **Service API Parameters Renamed**:
   - `get_chartdata`: `minor_currency` → `subunit_currency`
   - `get_apexcharts_yaml`: Updated service_data references from
     `minor_currency: true` to `subunit_currency: true`
   - All automations/scripts using these parameters MUST be updated

2. **Configuration Option Key Changed**:
   - Config entry option: Display mode setting now uses new terminology
   - Internal key: `currency_display_mode` values remain "base"/"subunit"
   - User-facing labels updated in all 5 languages (de, en, nb, nl, sv)

3. **Sensor Entity Key Renamed**:
   - `current_interval_price_major` → `current_interval_price_base`
   - Entity ID changes: `sensor.tibber_home_current_interval_price_major`
     → `sensor.tibber_home_current_interval_price_base`
   - Energy Dashboard configurations MUST update entity references

4. **Function Signatures Changed**:
   - `format_price_unit_major()` → `format_price_unit_base()`
   - `format_price_unit_minor()` → `format_price_unit_subunit()`
   - `get_price_value()`: Parameter `in_euro` deprecated in favor of
     `config_entry` (backward compatible for now)

5. **Translation Keys Renamed**:
   - All language files: Sensor translation key
     `current_interval_price_major` → `current_interval_price_base`
   - Service parameter descriptions updated in all languages
   - Selector options updated: Display mode dropdown values

Changes by Category:

**Core Code (Python)**:
- const.py: Renamed all format_price_unit_*() functions, updated docstrings
- entity_utils/helpers.py: Updated get_price_value() with config-driven
  conversion and backward-compatible in_euro parameter
- sensor/__init__.py: Added display mode filtering for base currency sensor
- sensor/core.py:
  * Implemented suggested_display_precision property for dynamic decimal places
  * Updated native_unit_of_measurement to use get_display_unit_string()
  * Updated all price conversion calls to use config_entry parameter
- sensor/definitions.py: Renamed entity key and updated all
  suggested_display_precision values (2 decimals for most sensors)
- sensor/calculators/*.py: Updated all price conversion calls (8 calculators)
- sensor/helpers.py: Updated aggregate_price_data() signature with config_entry
- sensor/attributes/future.py: Updated future price attributes conversion

**Services**:
- services/chartdata.py: Renamed parameter minor_currency → subunit_currency
  throughout (53 occurrences), updated metadata calculation
- services/apexcharts.py: Updated service_data references in generated YAML
- services/formatters.py: Renamed parameter use_minor_currency →
  use_subunit_currency in aggregate_hourly_exact() and get_period_data()
- sensor/chart_metadata.py: Updated default parameter name

**Translations (5 Languages)**:
- All /translations/*.json:
  * Added new config step "display_settings" with comprehensive explanations
  * Renamed current_interval_price_major → current_interval_price_base
  * Updated service parameter descriptions (subunit_currency)
  * Added selector.currency_display_mode.options with translated labels
- All /custom_translations/*.json:
  * Renamed sensor description keys
  * Updated chart_metadata usage_tips references

**Documentation**:
- docs/user/docs/actions.md: Updated parameter table and feature list
- docs/user/versioned_docs/version-v0.21.0/actions.md: Backported changes

**Tests**:
- Updated 7 test files with renamed parameters and conversion logic:
  * test_connect_segments.py: Renamed minor/major to subunit/base
  * test_period_data_format.py: Updated period price conversion tests
  * test_avg_none_fallback.py: Fixed tuple unpacking for new return format
  * test_best_price_e2e.py: Added config_entry parameter to all calls
  * test_cache_validity.py: Fixed cache data structure (price_info key)
  * test_coordinator_shutdown.py: Added repair_manager mock
  * test_midnight_turnover.py: Added config_entry parameter
  * test_peak_price_e2e.py: Added config_entry parameter, fixed price_avg → price_mean
  * test_percentage_calculations.py: Added config_entry mock

**Coordinator/Period Calculation**:
- coordinator/periods.py: Added config_entry parameter to
  calculate_periods_with_relaxation() calls (2 locations)

Migration Guide:

1. **Update Service Calls in Automations/Scripts**:
   \`\`\`yaml
   # Before:
   service: tibber_prices.get_chartdata
   data:
     minor_currency: true

   # After:
   service: tibber_prices.get_chartdata
   data:
     subunit_currency: true
   \`\`\`

2. **Update Energy Dashboard Configuration**:
   - Settings → Dashboards → Energy
   - Replace sensor entity:
     `sensor.tibber_home_current_interval_price_major` →
     `sensor.tibber_home_current_interval_price_base`

3. **Review Integration Configuration**:
   - Settings → Devices & Services → Tibber Prices → Configure
   - New "Currency Display Settings" step added
   - Default mode depends on currency (EUR → subunit, Scandinavian → base)

Rationale:

The "major/minor" terminology was confusing and didn't clearly communicate:
- **Major** → Unclear if this means "primary" or "large value"
- **Minor** → Easily confused with "less important" rather than "smaller unit"

New terminology is precise and self-explanatory:
- **Base currency** → Standard ISO currency (€, kr, $, £)
- **Subunit currency** → Fractional unit (ct, øre, ¢, p)

This aligns with:
- International terminology (ISO 4217 standard)
- Banking/financial industry conventions
- User expectations from payment processing systems

Impact: Aligns currency terminology with international standards. Users must
update service calls, automations, and Energy Dashboard configuration after
upgrade.

Refs: User feedback session (December 2025) identified terminology confusion
2025-12-11 08:26:30 +00:00

449 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",
"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_avg_sensors_use_quarter_hour_timer() -> None:
"""
Test that future N-hour average sensors use Timer #2.
Future averages calculate rolling windows starting from "next interval",
which changes every 15 minutes.
"""
future_avg_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_avg_sensors:
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
f"Future avg 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() -> None:
"""
Test that data lifecycle status sensor uses Timer #2.
The lifecycle sensor needs quarter-hour updates to detect:
- Turnover pending at 23:45 (quarter-hour boundary)
- Turnover completed after midnight API update
"""
assert "data_lifecycle_status" in TIME_SENSITIVE_ENTITY_KEYS, (
"Lifecycle sensor needs quarter-hour updates to detect turnover_pending\n"
"at 23:45 (last interval before midnight)"
)
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
"current_interval_price_base", # Duplicate of current_interval_price (just different unit)
"best_price_period_duration", # Duration in minutes, doesn't change minute-by-minute
"peak_price_period_duration", # Duration in minutes, doesn't change minute-by-minute
}
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)}"
)