From e29ff0568b24b323165ad1f3bb6456bd41c1fd51 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sat, 7 Oct 2023 10:49:40 +0200 Subject: [PATCH] Issue #82 - unavailable climate goes into security mode --- .../versatile_thermostat/requirements_dev.txt | 2 +- .../versatile_thermostat/tests/commons.py | 20 +++- .../versatile_thermostat/tests/test_bugs.py | 107 +++++++++++++++++- .../tests/test_security.py | 100 ++++++++++++++++ 4 files changed, 225 insertions(+), 4 deletions(-) diff --git a/custom_components/versatile_thermostat/requirements_dev.txt b/custom_components/versatile_thermostat/requirements_dev.txt index 91d35d1f..ed73233f 100644 --- a/custom_components/versatile_thermostat/requirements_dev.txt +++ b/custom_components/versatile_thermostat/requirements_dev.txt @@ -1,2 +1,2 @@ -homeassistant==2023.9.1 +homeassistant==2023.10.1 ffmpeg \ No newline at end of file diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index 6158f848..8b7d690f 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -83,7 +83,7 @@ class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: """Initialize the thermostat.""" super().__init__() @@ -93,10 +93,26 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: self._unique_id = unique_id self._name = name self._attr_hvac_action = HVACAction.OFF - self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_mode = hvac_mode self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] self._attr_temperature_unit = UnitOfTemperature.CELSIUS +class MockUnavailableClimate(ClimateEntity): + """A Mock Climate class used for Underlying climate mode""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the thermostat.""" + + super().__init__() + + self._hass = hass + self._attr_extra_state_attributes = {} + self._unique_id = unique_id + self._name = name + self._attr_hvac_action = None + self._attr_hvac_mode = None + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] + self._attr_temperature_unit = UnitOfTemperature.CELSIUS class MagicMockClimate(MagicMock): """A Magic Mock class for a underlying climate entity""" diff --git a/custom_components/versatile_thermostat/tests/test_bugs.py b/custom_components/versatile_thermostat/tests/test_bugs.py index 060090b5..2b5e38d8 100644 --- a/custom_components/versatile_thermostat/tests/test_bugs.py +++ b/custom_components/versatile_thermostat/tests/test_bugs.py @@ -1,5 +1,5 @@ """ Test the Window management """ -from unittest.mock import patch +from unittest.mock import patch, call from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from datetime import datetime, timedelta @@ -343,3 +343,108 @@ async def test_bug_66( assert entity.window_state == STATE_OFF assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_82( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that when a underlying climate is not available the VTherm doesn't go into security mode""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay + ) + + fake_underlying_climate = MockUnavailableClimate(hass, "mockUniqueId", "MockClimateName", {}) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ) as mock_find_climate: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + def find_my_entity(entity_id) -> ClimateEntity: + """Find my new entity""" + component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + + entity = find_my_entity("climate.theoverclimatemockname") + + assert entity + + assert entity.name == "TheOverClimateMockName" + assert entity._is_over_climate is True + # assert entity.hvac_action is HVACAction.OFF + # assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_mode is None + assert entity.target_temperature == entity.min_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] + ) + + assert mock_find_climate.call_count == 1 + assert mock_find_climate.mock_calls[0] == call() + mock_find_climate.assert_has_calls([call.find_underlying_entity()]) + + # Force security mode + assert entity._last_ext_temperature_mesure is not None + assert entity._last_temperature_mesure is not None + assert (entity._last_temperature_mesure.astimezone(tz) - now).total_seconds() < 1 + assert ( + entity._last_ext_temperature_mesure.astimezone(tz) - now + ).total_seconds() < 1 + + # Tries to turns on the Thermostat + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == None + + # 2. activate security feature when date is expired + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on: + event_timestamp = now - timedelta(minutes=6) + + # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) + await send_temperature_change_event(entity, 15, event_timestamp) + # Should stay False + assert entity.security_state is False + assert entity.preset_mode == 'none' + assert entity._saved_preset_mode == 'none' + + + diff --git a/custom_components/versatile_thermostat/tests/test_security.py b/custom_components/versatile_thermostat/tests/test_security.py index de46c966..a26e9956 100644 --- a/custom_components/versatile_thermostat/tests/test_security.py +++ b/custom_components/versatile_thermostat/tests/test_security.py @@ -189,3 +189,103 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): # Heater is now on assert mock_heater_on.call_count == 1 + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_security_over_climate( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that when a underlying climate is not available the VTherm doesn't go into security mode""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay + ) + + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ) as mock_find_climate: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + def find_my_entity(entity_id) -> ClimateEntity: + """Find my new entity""" + component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + + entity = find_my_entity("climate.theoverclimatemockname") + + assert entity + + assert entity.name == "TheOverClimateMockName" + assert entity._is_over_climate is True + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.HEAT + assert entity.target_temperature == entity.min_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] + ) + + assert mock_find_climate.call_count == 1 + assert mock_find_climate.mock_calls[0] == call() + mock_find_climate.assert_has_calls([call.find_underlying_entity()]) + + # Force security mode + assert entity._last_ext_temperature_mesure is not None + assert entity._last_temperature_mesure is not None + assert (entity._last_temperature_mesure.astimezone(tz) - now).total_seconds() < 1 + assert ( + entity._last_ext_temperature_mesure.astimezone(tz) - now + ).total_seconds() < 1 + + # Tries to turns on the Thermostat + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + + # 2. activate security feature when date is expired + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on: + event_timestamp = now - timedelta(minutes=6) + + await send_temperature_change_event(entity, 15, event_timestamp) + # Should stay False because a climate is never in security mode + assert entity.security_state is False + assert entity.preset_mode == 'none' + assert entity._saved_preset_mode == 'none' \ No newline at end of file