hass.tibber_prices/tests/test_cache_validity.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

263 lines
8.4 KiB
Python

"""
Unit tests for cache validity checks.
Tests the is_cache_valid() function which determines if cached price data
is still current or needs to be refreshed.
"""
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.cache import (
TibberPricesCacheData,
is_cache_valid,
)
@pytest.mark.unit
def test_cache_valid_same_day() -> None:
"""
Test cache is valid when data is from the same calendar day.
Scenario: Cache from 10:00, current time 15:00 (same day)
Expected: Cache is valid
"""
time_service = Mock()
cache_time = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 15, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2, 3]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time,
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is True
@pytest.mark.unit
def test_cache_invalid_different_day() -> None:
"""
Test cache is invalid when data is from a different calendar day.
Scenario: Cache from yesterday, current time today
Expected: Cache is invalid (date mismatch)
"""
time_service = Mock()
cache_time = datetime(2025, 11, 21, 23, 50, 0, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 10, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2, 3]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time,
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_invalid_no_price_data() -> None:
"""
Test cache is invalid when no price data exists.
Scenario: Cache exists but price_data is None
Expected: Cache is invalid
"""
time_service = Mock()
current_time = datetime(2025, 11, 22, 15, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data=None, # No price data!
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=current_time,
last_user_update=current_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_invalid_no_last_update() -> None:
"""
Test cache is invalid when last_price_update is None.
Scenario: Cache has data but no update timestamp
Expected: Cache is invalid
"""
time_service = Mock()
current_time = datetime(2025, 11, 22, 15, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2, 3]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=None, # No timestamp!
last_user_update=None,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_valid_after_midnight_turnover() -> None:
"""
Test cache validity after midnight turnover with updated timestamp.
Scenario: Midnight turnover occurred, _last_price_update was updated to new day
Expected: Cache is valid (same date as current)
This tests the fix for the "date_mismatch" bug where cache appeared invalid
after midnight despite successful data rotation.
"""
time_service = Mock()
# After midnight turnover, _last_price_update should be set to current time
turnover_time = datetime(2025, 11, 22, 0, 0, 5, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 10, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=turnover_time, # Updated during turnover!
last_user_update=turnover_time,
last_midnight_check=turnover_time,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is True
@pytest.mark.unit
def test_cache_invalid_midnight_crossing_without_update() -> None:
"""
Test cache becomes invalid at midnight if timestamp not updated.
Scenario: HA restarted after midnight, cache still has yesterday's timestamp
Expected: Cache is invalid (would be caught and refreshed)
"""
time_service = Mock()
cache_time = datetime(2025, 11, 21, 23, 55, 0, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 5, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2, 3]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time, # Still yesterday!
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_validity_timezone_aware() -> None:
"""
Test cache validity uses local timezone for date comparison.
Scenario: UTC midnight vs local timezone midnight (different dates)
Expected: Comparison done in local timezone, not UTC
This ensures that midnight turnover happens at local midnight,
not UTC midnight.
"""
time_service = Mock()
# 23:00 UTC on Nov 21 = 00:00 CET on Nov 22 (UTC+1)
cache_time_utc = datetime(2025, 11, 21, 23, 0, 0, tzinfo=ZoneInfo("UTC"))
current_time_utc = datetime(2025, 11, 21, 23, 30, 0, tzinfo=ZoneInfo("UTC"))
# Convert to local timezone (CET = UTC+1)
cache_time_local = cache_time_utc.astimezone(ZoneInfo("Europe/Oslo")) # 00:00 Nov 22
current_time_local = current_time_utc.astimezone(ZoneInfo("Europe/Oslo")) # 00:30 Nov 22
time_service.now.return_value = current_time_utc
time_service.as_local.return_value = current_time_local
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2, 3]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time_utc,
last_user_update=cache_time_utc,
last_midnight_check=None,
)
# Mock as_local for cache_time
def as_local_side_effect(dt: datetime) -> datetime:
if dt == cache_time_utc:
return cache_time_local
return current_time_local
time_service.as_local.side_effect = as_local_side_effect
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
# Both times are Nov 22 in local timezone → same date → valid
assert result is True
@pytest.mark.unit
def test_cache_validity_exact_midnight_boundary() -> None:
"""
Test cache validity exactly at midnight boundary.
Scenario: Cache from 23:59:59, current time 00:00:00
Expected: Cache is invalid (different calendar days)
"""
time_service = Mock()
cache_time = datetime(2025, 11, 21, 23, 59, 59, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"price_info": [1, 2, 3]},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time,
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False