This commit is contained in:
Björn Stormwall
2026-06-06 18:42:30 +02:00
commit 108d154b3d
15 changed files with 1203 additions and 0 deletions
View File
+94
View File
@@ -0,0 +1,94 @@
"""Stub out homeassistant before any local imports so tests run without HA installed."""
from __future__ import annotations
import sys
from datetime import timedelta
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------------------
# HA module stubs — must happen before coordinator.py is imported
# ---------------------------------------------------------------------------
class _DataUpdateCoordinator:
"""Minimal stand-in for DataUpdateCoordinator."""
def __init__(self, hass, logger, *, name, update_interval):
self.hass = hass
self.name = name
self.data: dict = {}
class _UpdateFailed(Exception):
pass
_update_coordinator_stub = MagicMock()
_update_coordinator_stub.DataUpdateCoordinator = _DataUpdateCoordinator
_update_coordinator_stub.UpdateFailed = _UpdateFailed
for _mod in [
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.components",
"homeassistant.components.sensor",
"homeassistant.components.switch",
"homeassistant.const",
"homeassistant.helpers",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.entity_platform",
"homeassistant.helpers.restore_state",
"homeassistant.helpers.selector",
"voluptuous",
]:
sys.modules.setdefault(_mod, MagicMock())
sys.modules["homeassistant.helpers.update_coordinator"] = _update_coordinator_stub
# ---------------------------------------------------------------------------
# Shared test fixtures
# ---------------------------------------------------------------------------
from custom_components.easee_solar_charging.coordinator import EaseeSolarCoordinator # noqa: E402
from custom_components.easee_solar_charging.const import ( # noqa: E402
CONF_STOP_GRACE_MINUTES,
DEFAULT_STOP_GRACE_MINUTES,
)
CHARGER_ID = "ehxt9pqp"
SOLAR_SENSOR = "sensor.solar_excess"
class FakeCoordinator(EaseeSolarCoordinator):
"""Coordinator with HA wiring stripped out so logic methods can be unit-tested."""
def __init__(self, options: dict | None = None) -> None:
# Skip DataUpdateCoordinator.__init__ — not needed for logic-only tests.
self.hass = MagicMock()
self.charger_id = CHARGER_ID
self._solar_excess_sensor = SOLAR_SENSOR
self._device_id = None
self._last_sent_current = None
self._last_sent_at = None
self._below_threshold_since = None
self.solar_charging_enabled = True
self.stop_grace_enabled = True
self._entry = MagicMock()
self._entry.options = options if options is not None else {
CONF_STOP_GRACE_MINUTES: DEFAULT_STOP_GRACE_MINUTES,
}
@pytest.fixture
def coord() -> FakeCoordinator:
return FakeCoordinator()
def make_state(value: str) -> MagicMock:
"""Return a minimal HA state mock with the given state string."""
s = MagicMock()
s.state = value
return s
+71
View File
@@ -0,0 +1,71 @@
import pytest
from custom_components.easee_solar_charging.const import (
AMPS_TO_POWER_W,
MAX_CHARGER_CURRENT_A,
MIN_CHARGER_CURRENT_A,
PHASES,
VOLTAGE_V,
solar_excess_to_charger_current,
)
# ---------------------------------------------------------------------------
# solar_excess_to_charger_current
# ---------------------------------------------------------------------------
def test_zero_excess_returns_zero():
assert solar_excess_to_charger_current(0) == 0
def test_just_below_minimum_threshold_returns_zero():
min_power = PHASES * VOLTAGE_V * MIN_CHARGER_CURRENT_A
assert solar_excess_to_charger_current(min_power - 1) == 0
def test_exactly_at_minimum_threshold():
min_power = PHASES * VOLTAGE_V * MIN_CHARGER_CURRENT_A
assert solar_excess_to_charger_current(min_power) == MIN_CHARGER_CURRENT_A
def test_fractional_watts_floor():
# 3 * 230 * 8 = 5520 W → 8 A; adding 229 W is still not enough for 9 A
assert solar_excess_to_charger_current(PHASES * VOLTAGE_V * 8 + 229) == 8
def test_intermediate_value():
assert solar_excess_to_charger_current(PHASES * VOLTAGE_V * 10) == 10
def test_clamps_at_maximum():
assert solar_excess_to_charger_current(999_999) == MAX_CHARGER_CURRENT_A
def test_negative_excess_returns_zero():
assert solar_excess_to_charger_current(-500) == 0
@pytest.mark.parametrize("amps", range(MIN_CHARGER_CURRENT_A, MAX_CHARGER_CURRENT_A + 1))
def test_round_trip(amps):
"""Power for N amps should convert back to exactly N amps."""
assert solar_excess_to_charger_current(AMPS_TO_POWER_W[amps]) == amps
# ---------------------------------------------------------------------------
# AMPS_TO_POWER_W table
# ---------------------------------------------------------------------------
def test_table_covers_full_range():
assert set(AMPS_TO_POWER_W) == set(range(MIN_CHARGER_CURRENT_A, MAX_CHARGER_CURRENT_A + 1))
def test_table_minimum_entry():
assert AMPS_TO_POWER_W[MIN_CHARGER_CURRENT_A] == PHASES * VOLTAGE_V * MIN_CHARGER_CURRENT_A
def test_table_maximum_entry():
assert AMPS_TO_POWER_W[MAX_CHARGER_CURRENT_A] == PHASES * VOLTAGE_V * MAX_CHARGER_CURRENT_A
def test_table_is_strictly_increasing():
values = [AMPS_TO_POWER_W[a] for a in sorted(AMPS_TO_POWER_W)]
assert values == sorted(values) and len(set(values)) == len(values)
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from freezegun import freeze_time
from tests.conftest import FakeCoordinator, make_state
from custom_components.easee_solar_charging.const import (
CONF_STOP_GRACE_MINUTES,
DEFAULT_STOP_GRACE_MINUTES,
EASEE_CONTROLLABLE_STATES,
EASEE_SLEEPING_STATES,
HYSTERESIS_A,
MIN_CHARGER_CURRENT_A,
)
_NOW = "2024-06-01 12:00:00"
# ---------------------------------------------------------------------------
# _should_send
# ---------------------------------------------------------------------------
class TestShouldSend:
def test_first_run_always_sends(self, coord):
assert coord._should_send(8) is True
def test_same_value_no_send(self, coord):
coord._last_sent_current = 8
assert coord._should_send(8) is False
def test_zero_to_nonzero_sends(self, coord):
coord._last_sent_current = 0
assert coord._should_send(8) is True
def test_nonzero_to_zero_sends(self, coord):
coord._last_sent_current = 8
assert coord._should_send(0) is True
def test_outside_deadband_sends_immediately(self, coord):
coord._last_sent_current = 8
assert coord._should_send(8 + HYSTERESIS_A) is True
def test_inside_deadband_suppressed_when_recent(self, coord):
coord._last_sent_current = 8
coord._last_sent_at = datetime.now(timezone.utc)
# 1 A delta — inside dead-band, timestamp is fresh
assert coord._should_send(9) is False
def test_inside_deadband_sends_after_one_minute(self, coord):
coord._last_sent_current = 8
coord._last_sent_at = datetime.now(timezone.utc) - timedelta(minutes=1, seconds=1)
assert coord._should_send(9) is True
def test_inside_deadband_sends_when_no_timestamp(self, coord):
coord._last_sent_current = 8
coord._last_sent_at = None
assert coord._should_send(9) is True
# ---------------------------------------------------------------------------
# _apply_stop_hysteresis
# ---------------------------------------------------------------------------
class TestApplyStopHysteresis:
def test_nonzero_target_passes_through(self, coord):
assert coord._apply_stop_hysteresis(10) == 10
def test_nonzero_target_clears_timer(self, coord):
coord._below_threshold_since = datetime.now(timezone.utc)
coord._apply_stop_hysteresis(10)
assert coord._below_threshold_since is None
def test_zero_starts_grace_period(self, coord):
coord._last_sent_current = 10
with freeze_time(_NOW):
result = coord._apply_stop_hysteresis(0)
assert result == 10
assert coord._below_threshold_since is not None
def test_zero_held_during_grace(self, coord):
coord._last_sent_current = 8
with freeze_time(_NOW):
coord._apply_stop_hysteresis(0)
with freeze_time("2024-06-01 12:04:00"):
result = coord._apply_stop_hysteresis(0)
assert result == 8
def test_zero_applied_after_grace_expires(self, coord):
coord._last_sent_current = 8
with freeze_time(_NOW):
coord._apply_stop_hysteresis(0)
with freeze_time("2024-06-01 12:05:01"):
result = coord._apply_stop_hysteresis(0)
assert result == 0
def test_recovery_before_grace_expires_cancels_timer(self, coord):
coord._last_sent_current = 8
with freeze_time(_NOW):
coord._apply_stop_hysteresis(0)
with freeze_time("2024-06-01 12:02:00"):
result = coord._apply_stop_hysteresis(10)
assert result == 10
assert coord._below_threshold_since is None
def test_disabled_passes_through_immediately(self, coord):
coord.stop_grace_enabled = False
coord._last_sent_current = 8
assert coord._apply_stop_hysteresis(0) == 0
assert coord._below_threshold_since is None
def test_disabled_clears_running_timer(self, coord):
coord._below_threshold_since = datetime.now(timezone.utc)
coord.stop_grace_enabled = False
coord._apply_stop_hysteresis(0)
assert coord._below_threshold_since is None
def test_respects_configured_grace_duration(self):
coord = FakeCoordinator(options={CONF_STOP_GRACE_MINUTES: 2})
coord._last_sent_current = 8
with freeze_time(_NOW):
coord._apply_stop_hysteresis(0)
with freeze_time("2024-06-01 12:02:01"):
result = coord._apply_stop_hysteresis(0)
assert result == 0
def test_no_last_sent_during_grace_returns_zero(self, coord):
# Edge case: grace period started but charger was never commanded
coord._last_sent_current = None
with freeze_time(_NOW):
result = coord._apply_stop_hysteresis(0)
assert result == 0
# ---------------------------------------------------------------------------
# _get_solar_excess_w
# ---------------------------------------------------------------------------
class TestGetSolarExcessW:
def test_valid_numeric_state(self, coord):
coord.hass.states.get.return_value = make_state("3500.5")
assert coord._get_solar_excess_w() == 3500.5
def test_integer_state(self, coord):
coord.hass.states.get.return_value = make_state("5000")
assert coord._get_solar_excess_w() == 5000.0
def test_unavailable_returns_none(self, coord):
coord.hass.states.get.return_value = make_state("unavailable")
assert coord._get_solar_excess_w() is None
def test_unknown_returns_none(self, coord):
coord.hass.states.get.return_value = make_state("unknown")
assert coord._get_solar_excess_w() is None
def test_missing_entity_returns_none(self, coord):
coord.hass.states.get.return_value = None
assert coord._get_solar_excess_w() is None
def test_non_numeric_returns_none(self, coord):
coord.hass.states.get.return_value = make_state("not_a_number")
assert coord._get_solar_excess_w() is None
def test_queries_correct_entity(self, coord):
coord.hass.states.get.return_value = make_state("1000")
coord._get_solar_excess_w()
coord.hass.states.get.assert_called_once_with(coord._solar_excess_sensor)
# ---------------------------------------------------------------------------
# _charger_allows_control
# ---------------------------------------------------------------------------
class TestChargerAllowsControl:
@pytest.mark.parametrize("state", sorted(EASEE_CONTROLLABLE_STATES))
def test_allows_controllable_states(self, coord, state):
coord.hass.states.get.return_value = make_state(state)
assert coord._charger_allows_control() is True
def test_rejects_disconnected(self, coord):
coord.hass.states.get.return_value = make_state("disconnected")
assert coord._charger_allows_control() is False
def test_rejects_error_state(self, coord):
coord.hass.states.get.return_value = make_state("error")
assert coord._charger_allows_control() is False
def test_rejects_missing_entity(self, coord):
coord.hass.states.get.return_value = None
assert coord._charger_allows_control() is False
def test_queries_correct_status_entity(self, coord):
coord.hass.states.get.return_value = make_state("charging")
coord._charger_allows_control()
coord.hass.states.get.assert_called_once_with(
f"sensor.{coord.charger_id.lower()}_status"
)
# ---------------------------------------------------------------------------
# cancel_stop_grace
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# _charger_is_sleeping
# ---------------------------------------------------------------------------
class TestChargerIsSleeping:
@pytest.mark.parametrize("state", sorted(EASEE_SLEEPING_STATES))
def test_detects_sleeping_states(self, coord, state):
coord.hass.states.get.return_value = make_state(state)
assert coord._charger_is_sleeping() is True
@pytest.mark.parametrize("state", sorted(EASEE_CONTROLLABLE_STATES))
def test_not_sleeping_when_controllable(self, coord, state):
coord.hass.states.get.return_value = make_state(state)
assert coord._charger_is_sleeping() is False
def test_not_sleeping_when_entity_missing(self, coord):
coord.hass.states.get.return_value = None
assert coord._charger_is_sleeping() is False
def test_not_sleeping_when_disconnected(self, coord):
coord.hass.states.get.return_value = make_state("disconnected")
assert coord._charger_is_sleeping() is False
def test_cancel_stop_grace_clears_timer(coord):
coord._below_threshold_since = datetime.now(timezone.utc)
coord.cancel_stop_grace()
assert coord._below_threshold_since is None
def test_cancel_stop_grace_is_idempotent(coord):
coord._below_threshold_since = None
coord.cancel_stop_grace()
assert coord._below_threshold_since is None