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_when_recent(self, coord): coord._last_sent_current = 8 coord._last_sent_at = datetime.now(timezone.utc) assert coord._should_send(8) is False def test_same_value_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(8) is True def test_zero_to_zero_never_resends(self, coord): coord._last_sent_current = 0 coord._last_sent_at = datetime.now(timezone.utc) - timedelta(minutes=10) assert coord._should_send(0) 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 # --------------------------------------------------------------------------- # _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