init
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user