From fcdd93b4ae117a7fc17c15c1f43881b3fa98d619 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 8 Oct 2023 13:08:34 +0200 Subject: [PATCH] Issue # 114 - add unit test for multi-climate VTherm --- .../versatile_thermostat/climate.py | 5 +- .../versatile_thermostat/tests/commons.py | 7 +- .../versatile_thermostat/tests/test_bugs.py | 24 +- .../tests/test_multiple_switch.py | 229 +++++++++++++++++- .../tests/test_security.py | 18 +- .../versatile_thermostat/underlyings.py | 29 ++- 6 files changed, 284 insertions(+), 28 deletions(-) diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 2552195..249ddc5 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -1566,8 +1566,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _check_switch_initial_state(self): """Prevent the device from keep running if HVAC_MODE_OFF.""" _LOGGER.debug("%s - Calling _check_switch_initial_state", self) - if self.is_over_climate: - return + # We need to do the same check for over_climate underlyings + #if self.is_over_climate: + # return for under in self._underlyings: await under.check_initial_state(self._hvac_mode) diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index b4c1537..1380fea 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -94,7 +94,7 @@ class MockClimate(ClimateEntity): self._attr_extra_state_attributes = {} self._unique_id = unique_id self._name = name - self._attr_hvac_action = HVACAction.OFF + self._attr_hvac_action = HVACAction.OFF if hvac_mode == HVACMode.OFF else HVACAction.HEATING self._attr_hvac_mode = hvac_mode self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] self._attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -106,6 +106,11 @@ class MockClimate(ClimateEntity): self._attr_target_temperature = temperature self.async_write_ha_state() + def async_set_hvac_mode(self, hvac_mode): + """ The hvac mode""" + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + class MockUnavailableClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" diff --git a/custom_components/versatile_thermostat/tests/test_bugs.py b/custom_components/versatile_thermostat/tests/test_bugs.py index c85f993..e082355 100644 --- a/custom_components/versatile_thermostat/tests/test_bugs.py +++ b/custom_components/versatile_thermostat/tests/test_bugs.py @@ -391,8 +391,8 @@ async def test_bug_82( 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.hvac_mode is HVACMode.OFF + # assert entity.hvac_mode is None assert entity.target_temperature == entity.min_temp assert entity.preset_modes == [ PRESET_NONE, @@ -429,7 +429,7 @@ async def test_bug_82( # Tries to turns on the Thermostat await entity.async_set_hvac_mode(HVACMode.HEAT) - assert entity.hvac_mode == None + assert entity.hvac_mode == HVACMode.HEAT # 2. activate security feature when date is expired with patch( @@ -466,6 +466,7 @@ async def test_bug_101( data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay ) + # Underlying is in HEAT mode but should be shutdown at startup fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) with patch( @@ -473,7 +474,9 @@ async def test_bug_101( ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate, - ) as mock_find_climate: + ) as mock_find_climate, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.LOADED @@ -491,8 +494,17 @@ async def test_bug_101( 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.hvac_mode is HVACMode.OFF + # because the underlying is heating. In real life the underlying should be shut-off + assert entity.hvac_action is HVACAction.HEATING + # Underlying should have been shutdown + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.OFF), + ] + ) + assert entity.target_temperature == entity.min_temp assert entity.preset_mode is PRESET_NONE diff --git a/custom_components/versatile_thermostat/tests/test_multiple_switch.py b/custom_components/versatile_thermostat/tests/test_multiple_switch.py index 61940be..a5f21c4 100644 --- a/custom_components/versatile_thermostat/tests/test_multiple_switch.py +++ b/custom_components/versatile_thermostat/tests/test_multiple_switch.py @@ -257,11 +257,14 @@ async def test_multiple_switchs( ) assert entity assert entity.is_over_climate is False + assert entity.nb_underlying_entities == 4 # start heating, in boost mode. We block the control_heating to avoid running a cycle with patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" - ): + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_BOOST) @@ -273,14 +276,16 @@ async def test_multiple_switchs( event_timestamp = now - timedelta(minutes=4) await send_temperature_change_event(entity, 15, event_timestamp) - # Checks that all heaters are off - with patch( - "homeassistant.core.StateMachine.is_state", return_value=False - ) as mock_is_state: + # Checks that all climates are off assert entity._is_device_active is False # Should be call for all Switch - assert mock_is_state.call_count == 4 + assert mock_underlying_set_hvac_mode.call_count == 4 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.HEAT), + ] + ) # Set temperature to a low level with patch( @@ -339,3 +344,215 @@ async def test_multiple_switchs( # The first heater should be turned on but is already on but because call_later is mocked, it is only turned on here assert mock_heater_on.call_count == 1 + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_multiple_climates( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_send_event, +): + """Test that when multiple climates are configured the activation and deactivation is propagated to all climates""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOver4ClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOver4ClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "switch.mock_climate1", + CONF_CLIMATE_2: "switch.mock_climate2", + CONF_CLIMATE_3: "switch.mock_climate3", + CONF_CLIMATE_4: "switch.mock_climate4", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theover4climatemockname" + ) + assert entity + assert entity.is_over_climate is True + assert entity.nb_underlying_entities == 4 + + # start heating, in boost mode. We block the control_heating to avoid running a cycle + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.window_state is None + + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 15, event_timestamp) + + # Should be call for all Switch + assert mock_underlying_set_hvac_mode.call_count == 4 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.HEAT), + ] + ) + assert entity._is_device_active is False + + # Stop heating, in boost mode. We block the control_heating to avoid running a cycle + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + await entity.async_set_hvac_mode(HVACMode.OFF) + + assert entity.hvac_mode is HVACMode.OFF + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.window_state is None + + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 15, event_timestamp) + + # Should be call for all Switch + assert mock_underlying_set_hvac_mode.call_count == 4 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.OFF), + ] + ) + assert entity._is_device_active is False + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_multiple_climates_underlying_changes( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_send_event, +): + """Test that when multiple switch are configured the activation of one underlying climate activate the others""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOver4ClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOver4ClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "switch.mock_climate1", + CONF_CLIMATE_2: "switch.mock_climate2", + CONF_CLIMATE_3: "switch.mock_climate3", + CONF_CLIMATE_4: "switch.mock_climate4", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theover4climatemockname" + ) + assert entity + assert entity.is_over_climate is True + assert entity.nb_underlying_entities == 4 + + # start heating, in boost mode. We block the control_heating to avoid running a cycle + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.window_state is None + + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 15, event_timestamp) + + # Should be call for all Switch + assert mock_underlying_set_hvac_mode.call_count == 4 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.HEAT), + ] + ) + assert entity._is_device_active is False + + # Stop heating on one underlying climate + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + await send_climate_change_event(entity, HVACMode.OFF, HVACMode.HEAT, HVACAction.OFF, HVACAction.HEATING, now) + + # Should be call for all Switch + assert mock_underlying_set_hvac_mode.call_count == 4 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.OFF), + ] + ) + assert entity.hvac_mode == HVACMode.OFF + assert entity._is_device_active is False + + # Start heating on one underlying climate + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode, patch( + # notice that there is no need of return_value=HVACAction.IDLE because this is not a function but a property + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.hvac_action", HVACAction.IDLE + ) as mock_underlying_get_hvac_action: + await send_climate_change_event(entity, HVACMode.HEAT, HVACMode.OFF, HVACAction.IDLE, HVACAction.OFF, now) + + # Should be call for all Switch + assert mock_underlying_set_hvac_mode.call_count == 4 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.HEAT), + ] + ) + assert entity.hvac_mode == HVACMode.HEAT + assert entity.hvac_action == HVACAction.IDLE + assert entity._is_device_active is False + diff --git a/custom_components/versatile_thermostat/tests/test_security.py b/custom_components/versatile_thermostat/tests/test_security.py index a26e995..1b6e126 100644 --- a/custom_components/versatile_thermostat/tests/test_security.py +++ b/custom_components/versatile_thermostat/tests/test_security.py @@ -236,8 +236,10 @@ async def test_security_over_climate( 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 + + # Because the underlying is HEATING. In real life the underlying will be shut-off + assert entity.hvac_action is HVACAction.HEATING + assert entity.hvac_mode is HVACMode.OFF assert entity.target_temperature == entity.min_temp assert entity.preset_modes == [ PRESET_NONE, @@ -252,6 +254,7 @@ async def test_security_over_climate( assert mock_send_event.call_count == 2 mock_send_event.assert_has_calls( [ + # At startup call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), call.send_event( EventType.HVAC_MODE_EVENT, @@ -276,6 +279,17 @@ async def test_security_over_climate( await entity.async_set_hvac_mode(HVACMode.HEAT) assert entity.hvac_mode == HVACMode.HEAT + # One call more + assert mock_send_event.call_count == 3 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.HEAT}, + ), + ] + ) + # 2. activate security feature when date is expired with patch( "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 9aa0cd2..613d9ae 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature from homeassistant.exceptions import ServiceNotFound -from homeassistant.backports.enum import StrEnum +from enum import StrEnum from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE from homeassistant.components.climate import ( ClimateEntity, @@ -131,6 +131,23 @@ class UnderlyingEntity: """Remove the underlying entity""" return + async def check_initial_state(self, hvac_mode: HVACMode): + """Prevent the underlying to be on but thermostat is off""" + if hvac_mode == HVACMode.OFF and self.is_device_active: + _LOGGER.warning( + "%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s", + self, + self._entity_id, + ) + await self.set_hvac_mode(hvac_mode) + elif hvac_mode != HVACMode.OFF and self.is_device_active: + _LOGGER.warning( + "%s - The hvac mode is ON, but the underlying device is not ON. Turning on device %s", + self, + self._entity_id, + ) + await self.set_hvac_mode(hvac_mode) + # override to be able to mock the call def call_later( self, hass: HomeAssistant, delay_sec: int, called_method @@ -193,16 +210,6 @@ class UnderlyingSwitch(UnderlyingEntity): """If the toggleable device is currently active.""" return self._hass.states.is_state(self._entity_id, STATE_ON) - async def check_initial_state(self, hvac_mode: HVACMode): - """Prevent the heater to be on but thermostat is off""" - if hvac_mode == HVACMode.OFF and self.is_device_active: - _LOGGER.warning( - "%s - The hvac mode is OFF, but the switch device is ON. Turning off device %s", - self, - self._entity_id, - ) - await self.turn_off() - async def start_cycle( self, hvac_mode: HVACMode,