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

290 lines
12 KiB
Python

"""Test connect_segments feature for ApexCharts segment boundaries."""
from datetime import UTC, datetime, timedelta
def make_interval(start_time: datetime, price: float, level: str) -> dict:
"""Create a price interval for testing."""
return {
"startsAt": start_time,
"total": price,
"level": level,
}
def make_day_prices(base_time: datetime) -> list[dict]:
"""Create sample price data with level transitions."""
return [
# First segment: CHEAP at low price
make_interval(base_time, 0.10, "CHEAP"),
make_interval(base_time + timedelta(minutes=15), 0.11, "CHEAP"),
# Transition: CHEAP -> NORMAL (price goes UP)
make_interval(base_time + timedelta(minutes=30), 0.20, "NORMAL"),
make_interval(base_time + timedelta(minutes=45), 0.21, "NORMAL"),
# Transition: NORMAL -> CHEAP (price goes DOWN)
make_interval(base_time + timedelta(minutes=60), 0.12, "CHEAP"),
make_interval(base_time + timedelta(minutes=75), 0.13, "CHEAP"),
]
class TestConnectSegmentsLogic:
"""Test the connect_segments transition logic."""
def test_price_direction_detection_down(self) -> None:
"""Test that price going down is correctly detected."""
current_price = 0.20
next_price = 0.12
assert next_price < current_price, "Should detect price going down"
def test_price_direction_detection_up(self) -> None:
"""Test that price going up is correctly detected."""
current_price = 0.11
next_price = 0.20
assert next_price > current_price, "Should detect price going up"
def test_price_direction_detection_same(self) -> None:
"""Test that same price is handled correctly (treated as up)."""
current_price = 0.15
next_price = 0.15
assert not (next_price < current_price), "Same price should not be treated as 'down'"
def test_sample_data_structure(self) -> None:
"""Test that sample data has expected structure."""
base_time = datetime(2025, 12, 1, 0, 0, tzinfo=UTC)
prices = make_day_prices(base_time)
assert len(prices) == 6, "Should have 6 intervals"
# Check first transition (CHEAP -> NORMAL at index 1->2)
assert prices[1]["level"] == "CHEAP"
assert prices[2]["level"] == "NORMAL"
assert prices[2]["total"] > prices[1]["total"], "Price should go UP at this transition"
# Check second transition (NORMAL -> CHEAP at index 3->4)
assert prices[3]["level"] == "NORMAL"
assert prices[4]["level"] == "CHEAP"
assert prices[4]["total"] < prices[3]["total"], "Price should go DOWN at this transition"
class TestConnectSegmentsOutput:
"""Test the expected output format with connect_segments enabled."""
def test_transition_point_down_has_lower_price(self) -> None:
"""
When price goes DOWN at boundary, the transition point should have the lower price.
This creates a visual line going downward from the current segment level.
"""
current_price = 0.20
next_price = 0.12
# With connect_segments=True and price going down:
# The transition point should use next_price (the lower price)
# This draws the line downward from current segment level
is_price_going_down = next_price < current_price
transition_price = next_price # Use next price when going down
assert is_price_going_down, "Price should be going down"
assert transition_price == 0.12
def test_transition_point_up_has_current_price(self) -> None:
"""
When price goes UP at boundary, the hold point should have current price.
This creates a visual hold at current level before the gap.
"""
current_price = 0.11
next_price = 0.20
# With connect_segments=True and price going up:
# The hold point should use current_price (extend current level)
is_price_going_up = next_price >= current_price
hold_price = current_price # Extend current level when going up
assert is_price_going_up, "Price should be going up"
assert hold_price == 0.11
class TestSegmentBoundaryDetection:
"""Test detection of segment boundaries."""
def test_same_level_no_boundary(self) -> None:
"""Intervals with same level should not create boundary."""
interval_value = "CHEAP"
next_value = "CHEAP"
is_boundary = next_value != interval_value
assert not is_boundary
def test_different_level_creates_boundary(self) -> None:
"""Intervals with different level should create boundary."""
interval_value = "CHEAP"
next_value = "NORMAL"
is_boundary = next_value != interval_value
assert is_boundary
def test_filter_match_check(self) -> None:
"""Test that filter matching works correctly."""
filter_values = ["CHEAP"]
interval_value = "CHEAP"
next_value = "NORMAL"
matches_filter = interval_value in filter_values
assert matches_filter, "CHEAP should match filter"
next_matches = next_value in filter_values
assert not next_matches, "NORMAL should not match filter"
class TestPriceConversion:
"""Test price conversion logic used in connect_segments."""
def test_subunit_currency_conversion(self) -> None:
"""Test conversion to subunit currency (cents/øre)."""
price = 0.12 # EUR
subunit_currency = True
converted = round(price * 100, 2) if subunit_currency else round(price, 4)
assert converted == 12.0, "0.12 EUR should be 12 cents"
def test_base_currency_rounding(self) -> None:
"""Test base currency precision."""
price = 0.123456
subunit_currency = False
converted = round(price * 100, 2) if subunit_currency else round(price, 4)
assert converted == 0.1235, "Should round to 4 decimal places"
def test_custom_rounding(self) -> None:
"""Test custom decimal rounding."""
price = 0.12345
converted = round(price, 4)
round_decimals = 2
final = round(converted, round_decimals)
assert final == 0.12, "Should round to 2 decimal places"
class TestTrailingNullRemoval:
"""Test trailing null value removal for ApexCharts header display (segments mode only)."""
def test_trailing_nulls_removed(self) -> None:
"""Test that trailing null values are removed from chart_data."""
price_field = "price_per_kwh"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
{"start_time": "2025-12-01T00:30:00", price_field: None}, # Trailing null
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null
]
# Simulate the trailing null removal logic
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == 2, "Should have 2 items after removing trailing nulls"
assert chart_data[-1][price_field] == 12.0, "Last item should be the last non-null price"
def test_internal_nulls_preserved(self) -> None:
"""Test that internal null values are preserved for gap visualization."""
price_field = "price_per_kwh"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: None}, # Internal null (gap)
{"start_time": "2025-12-01T00:30:00", price_field: 12.0},
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null
]
# Simulate the trailing null removal logic
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == 3, "Should have 3 items after removing trailing null"
assert chart_data[1][price_field] is None, "Internal null should be preserved"
assert chart_data[-1][price_field] == 12.0, "Last item should be the last non-null price"
def test_no_nulls_unchanged(self) -> None:
"""Test that chart_data without trailing nulls is unchanged."""
price_field = "price_per_kwh"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
]
original_length = len(chart_data)
# Simulate the trailing null removal logic
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == original_length, "Data without trailing nulls should be unchanged"
def test_empty_data_handled(self) -> None:
"""Test that empty chart_data is handled without error."""
price_field = "price_per_kwh"
chart_data: list[dict] = []
# Simulate the trailing null removal logic - should not raise
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert chart_data == [], "Empty data should remain empty"
class TestTrailingNullModeSpecific:
"""Test that trailing null removal respects insert_nulls mode."""
def test_segments_mode_removes_trailing_nulls(self) -> None:
"""Test that insert_nulls='segments' removes trailing nulls for ApexCharts header fix."""
price_field = "price_per_kwh"
insert_nulls = "segments"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
{"start_time": "2025-12-01T00:30:00", price_field: None}, # Trailing null
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null
]
# Simulate the conditional trailing null removal
if insert_nulls == "segments":
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == 2, "Segments mode should remove trailing nulls"
assert chart_data[-1][price_field] == 12.0, "Last item should be last non-null price"
def test_all_mode_preserves_trailing_nulls(self) -> None:
"""Test that insert_nulls='all' preserves trailing nulls (intentional gaps)."""
price_field = "price_per_kwh"
insert_nulls = "all"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
{"start_time": "2025-12-01T00:30:00", price_field: None}, # Intentional gap
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Intentional gap
]
original_length = len(chart_data)
# Simulate the conditional trailing null removal
if insert_nulls == "segments":
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == original_length, "'all' mode should preserve trailing nulls"
assert chart_data[-1][price_field] is None, "Last item should remain null"
def test_none_mode_no_trailing_nulls_expected(self) -> None:
"""Test that insert_nulls='none' has no trailing nulls by design."""
price_field = "price_per_kwh"
insert_nulls = "none"
# In 'none' mode, nulls are never inserted, so no trailing nulls exist
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
]
original_length = len(chart_data)
# Simulate the conditional trailing null removal
if insert_nulls == "segments":
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == original_length, "'none' mode should have no nulls to remove"