From 6947056d55c599d36bca9e8a01685e67cc4c5ff0 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sat, 23 Nov 2024 23:08:31 +0000 Subject: [PATCH] First unit test ok --- .../versatile_thermostat/base_thermostat.py | 9 +- .../thermostat_climate_valve.py | 11 +- tests/commons.py | 71 ++++- tests/test_overclimate.py | 2 +- tests/test_overclimate_valve.py | 296 ++++++++++++++++++ tests/test_start.py | 14 +- tests/test_valve.py | 1 + 7 files changed, 383 insertions(+), 21 deletions(-) create mode 100644 tests/test_overclimate_valve.py diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index f8a7187..96ff629 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -138,7 +138,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "target_temperature_step", "is_used_by_central_boiler", "temperature_slope", - "max_on_percent" + "max_on_percent", + "have_valve_regulation", } ) ) @@ -2673,6 +2674,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "temperature_slope": round(self.last_temperature_slope or 0, 3), "hvac_off_reason": self.hvac_off_reason, "max_on_percent": self._max_on_percent, + "have_valve_regulation": self.have_valve_regulation, } _LOGGER.debug( @@ -2691,6 +2693,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) return super().async_write_ha_state() + @property + def have_valve_regulation(self) -> bool: + """True if the Thermostat is regulated by valve""" + return False + @callback def async_registry_entry_updated(self): """update the entity if the config entry have been updated diff --git a/custom_components/versatile_thermostat/thermostat_climate_valve.py b/custom_components/versatile_thermostat/thermostat_climate_valve.py index f07d314..c64305b 100644 --- a/custom_components/versatile_thermostat/thermostat_climate_valve.py +++ b/custom_components/versatile_thermostat/thermostat_climate_valve.py @@ -227,11 +227,12 @@ class ThermostatOverClimateValve(ThermostatOverClimate): return for under in self._underlyings: - await under.set_temperature( - self.target_temperature, - self._attr_max_temp, - self._attr_min_temp, - ) + if self.target_temperature != under.last_sent_temperature: + await under.set_temperature( + self.target_temperature, + self._attr_max_temp, + self._attr_min_temp, + ) for under in self._underlyings_valve_regulation: await under.set_valve_open_percent() diff --git a/tests/commons.py b/tests/commons.py index 51ab62d..060ba71 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -3,6 +3,7 @@ """ Some common resources """ import asyncio import logging +from typing import Any, Dict, Callable from unittest.mock import patch, MagicMock # pylint: disable=unused-import import pytest # pylint: disable=unused-import @@ -1007,12 +1008,50 @@ async def set_climate_preset_temp( ) +# The temperatures to set +default_temperatures_ac_away = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, + "eco_ac": 27.0, + "comfort_ac": 25.0, + "boost_ac": 23.0, + "frost_away": 7.1, + "eco_away": 17.1, + "comfort_away": 19.1, + "boost_away": 21.1, + "eco_ac_away": 27.1, + "comfort_ac_away": 25.1, + "boost_ac_away": 23.1, +} + +default_temperatures_away = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, + "frost_away": 7.1, + "eco_away": 17.1, + "comfort_away": 19.1, + "boost_away": 21.1, +} + +default_temperatures = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, +} + + async def set_all_climate_preset_temp( - hass, vtherm: BaseThermostat, temps: dict, number_entity_base_name: str + hass, vtherm: BaseThermostat, temps: dict | None, number_entity_base_name: str ): """Initialize all temp of preset for a VTherm entity""" + local_temps = temps if temps is not None else default_temperatures # We initialize - for preset_name, value in temps.items(): + for preset_name, value in local_temps.items(): await set_climate_preset_temp(vtherm, preset_name, value) @@ -1028,3 +1067,31 @@ async def set_all_climate_preset_temp( assert temp_entity # Because set_value is not implemented in Number class (really don't understand why...) assert temp_entity.state == value + + +# +# Side effects management +# +SideEffectDict = Dict[str, Any] + + +class SideEffects: + """A class to manage sideEffects for mock""" + + def __init__(self, side_effects: SideEffectDict, default_side_effect: Any): + """Initialise the side effects""" + self._current_side_effects: SideEffectDict = side_effects + self._default_side_effect: Any = default_side_effect + + def get_side_effects(self) -> Callable[[str], Any]: + """returns the method which apply the side effects""" + + def side_effect_method(arg) -> Any: + """Search a side effect definition and return it""" + return self._current_side_effects.get(arg, self._default_side_effect) + + return side_effect_method + + def add_or_update_side_effect(self, key: str, new_value: Any): + """Update the value of a side effect""" + self._current_side_effects[key] = new_value diff --git a/tests/test_overclimate.py b/tests/test_overclimate.py index 8026daf..9bad2a9 100644 --- a/tests/test_overclimate.py +++ b/tests/test_overclimate.py @@ -1,6 +1,6 @@ # pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, too-many-lines -""" Test the Window management """ +""" Test the over_climate Vtherm """ from unittest.mock import patch, call from datetime import datetime, timedelta diff --git a/tests/test_overclimate_valve.py b/tests/test_overclimate_valve.py new file mode 100644 index 0000000..a3b56db --- /dev/null +++ b/tests/test_overclimate_valve.py @@ -0,0 +1,296 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, too-many-lines + +""" Test the over_climate with valve regulation """ +from unittest.mock import patch, call +from datetime import datetime, timedelta + +import logging + +from homeassistant.core import HomeAssistant, State + +from custom_components.versatile_thermostat.thermostat_climate_valve import ( + ThermostatOverClimateValve, +) + +from .commons import * +from .const import * + +logging.getLogger().setLevel(logging.DEBUG) + + +# @pytest.mark.parametrize("expected_lingering_tasks", [True]) +# @pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_valve_mono(hass: HomeAssistant, skip_hass_states_get): + """Test the normal full start of a thermostat in thermostat_over_climate type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_DEVICE_POWER: 1, + CONF_USE_MAIN_CENTRAL_CONFIG: False, + CONF_USE_CENTRAL_MODE: False, + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_STEP_TEMPERATURE: 0.1, + CONF_UNDERLYING_LIST: ["climate.mock_climate"], + CONF_AC_MODE: False, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_VALVE, + CONF_AUTO_REGULATION_DTEMP: 0.5, + CONF_AUTO_REGULATION_PERIOD_MIN: 2, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False, + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.1, + CONF_OPENING_DEGREE_LIST: ["number.mock_opening_degree"], + CONF_CLOSING_DEGREE_LIST: ["number.mock_closing_degree"], + CONF_OFFSET_CALIBRATION_LIST: ["number.mock_offset_calibration"], + } + | MOCK_DEFAULT_FEATURE_CONFIG + | MOCK_DEFAULT_CENTRAL_CONFIG + | MOCK_ADVANCED_CONFIG, + ) + + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}) + + # mock_get_state will be called for each OPENING/CLOSING/OFFSET_CALIBRATION list + + mock_get_state_side_effect = SideEffects( + { + "number.mock_opening_degree": State( + "number.mock_opening_degree", "0", {"min": 0, "max": 100} + ), + "number.mock_closing_degree": State( + "number.mock_closing_degree", "0", {"min": 0, "max": 100} + ), + "number.mock_offset_calibration": State( + "number.mock_offset_calibration", "0", {"min": -12, "max": 12} + ), + }, + State("unknown.entity_id", "unknown"), + ) + + # 1. initialize the VTherm + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # fmt: off + with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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, \ + patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\ + patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state: + # fmt: on + + vtherm: ThermostatOverClimateValve = await create_thermostat(hass, entry, "climate.theoverclimatemockname") + + assert vtherm + vtherm._set_now(now) + assert isinstance(vtherm, ThermostatOverClimateValve) + + assert vtherm.name == "TheOverClimateMockName" + assert vtherm.is_over_climate is True + assert vtherm.have_valve_regulation is True + + assert vtherm.hvac_action is HVACAction.OFF + assert vtherm.hvac_mode is HVACMode.OFF + assert vtherm.target_temperature == vtherm.min_temp + assert vtherm.preset_modes == [ + PRESET_NONE, + PRESET_FROST_PROTECTION, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] + assert vtherm.preset_mode is PRESET_NONE + assert vtherm._security_state is False + assert vtherm._window_state is None + assert vtherm._motion_state is None + assert vtherm._presence_state is None + + assert vtherm.is_device_active is False + assert vtherm.valve_open_percent == 0 + + # 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}, + ), + ] + ) + + mock_find_climate.assert_called_once() + mock_find_climate.assert_has_calls([call.find_underlying_vtherm()]) + + # the underlying set temperature call but no call to valve yet because VTherm is off + assert mock_service_call.call_count == 3 + mock_service_call.assert_has_calls( + [ + call(domain='number', service='set_value', service_data={'value': 0}, target={'entity_id': 'number.mock_opening_degree'}), + call(domain='number', service='set_value', service_data={'value': 100}, target={'entity_id': 'number.mock_closing_degree'}), + # we have no current_temperature yet + # call(domain='number', service='set_value', service_data={'value': 12}, target={'entity_id': 'number.mock_offset_calibration'}), + call("climate","set_temperature",{ + "entity_id": "climate.mock_climate", + "temperature": 15, # temp-min + }, + ), + ] + ) + + assert mock_get_state.call_count > 5 # each temp sensor + each valve + + + # initialize the temps + await set_all_climate_preset_temp(hass, vtherm, None, "theoverclimatemockname") + + await send_temperature_change_event(vtherm, 18, now, True) + await send_ext_temperature_change_event(vtherm, 18, now, True) + + # 2. Starts heating slowly (18 vs 19) + now = now + timedelta(minutes=1) + vtherm._set_now(now) + + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + # fmt: off + with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \ + patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\ + patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state: + # fmt: on + now = now + timedelta(minutes=2) # avoid temporal filter + vtherm._set_now(now) + + await vtherm.async_set_preset_mode(PRESET_COMFORT) + await hass.async_block_till_done() + + assert vtherm.hvac_mode is HVACMode.HEAT + assert vtherm.preset_mode is PRESET_COMFORT + assert vtherm.target_temperature == 19 + assert vtherm.current_temperature == 18 + assert vtherm.valve_open_percent == 40 # 0.3*1 + 0.1*1 + + + assert mock_service_call.call_count == 4 + mock_service_call.assert_has_calls( + [ + call('climate', 'set_temperature', {'entity_id': 'climate.mock_climate', 'temperature': 19.0}), + call(domain='number', service='set_value', service_data={'value': 40}, target={'entity_id': 'number.mock_opening_degree'}), + call(domain='number', service='set_value', service_data={'value': 60}, target={'entity_id': 'number.mock_closing_degree'}), + # 3 = 18 (room) - 15 (current of underlying) + 0 (current offset) + call(domain='number', service='set_value', service_data={'value': 3.0}, target={'entity_id': 'number.mock_offset_calibration'}) + ] + ) + + # set the opening to 40% + mock_get_state_side_effect.add_or_update_side_effect( + "number.mock_opening_degree", + State( + "number.mock_opening_degree", "40", {"min": 0, "max": 100} + )) + + assert vtherm.hvac_action is HVACAction.HEATING + assert vtherm.is_device_active is True + + # 2. Starts heating very slowly (18.9 vs 19) + now = now + timedelta(minutes=2) + vtherm._set_now(now) + # fmt: off + with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \ + patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\ + patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state: + # fmt: on + # set the offset to 3 + mock_get_state_side_effect.add_or_update_side_effect( + "number.mock_offset_calibration", + State( + "number.mock_offset_calibration", "3", {"min": -12, "max": 12} + )) + + await send_temperature_change_event(vtherm, 18.9, now, True) + await hass.async_block_till_done() + + assert vtherm.hvac_mode is HVACMode.HEAT + assert vtherm.preset_mode is PRESET_COMFORT + assert vtherm.target_temperature == 19 + assert vtherm.current_temperature == 18.9 + assert vtherm.valve_open_percent == 13 # 0.3*0.1 + 0.1*1 + + + assert mock_service_call.call_count == 3 + mock_service_call.assert_has_calls( + [ + call(domain='number', service='set_value', service_data={'value': 13}, target={'entity_id': 'number.mock_opening_degree'}), + call(domain='number', service='set_value', service_data={'value': 87}, target={'entity_id': 'number.mock_closing_degree'}), + # 6 = 18 (room) - 15 (current of underlying) + 3 (current offset) + call(domain='number', service='set_value', service_data={'value': 6.899999999999999}, target={'entity_id': 'number.mock_offset_calibration'}) + ] + ) + + # set the opening to 13% + mock_get_state_side_effect.add_or_update_side_effect( + "number.mock_opening_degree", + State( + "number.mock_opening_degree", "13", {"min": 0, "max": 100} + )) + + assert vtherm.hvac_action is HVACAction.HEATING + assert vtherm.is_device_active is True + + # 3. Stop heating 21 > 19 + now = now + timedelta(minutes=2) + vtherm._set_now(now) + # fmt: off + with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \ + patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\ + patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state: + # fmt: on + # set the offset to 3 + mock_get_state_side_effect.add_or_update_side_effect( + "number.mock_offset_calibration", + State( + "number.mock_offset_calibration", "3", {"min": -12, "max": 12} + )) + + await send_temperature_change_event(vtherm, 21, now, True) + await hass.async_block_till_done() + + assert vtherm.hvac_mode is HVACMode.HEAT + assert vtherm.preset_mode is PRESET_COMFORT + assert vtherm.target_temperature == 19 + assert vtherm.current_temperature == 21 + assert vtherm.valve_open_percent == 0 # 0.3* (-2) + 0.1*1 + + + assert mock_service_call.call_count == 3 + mock_service_call.assert_has_calls( + [ + call(domain='number', service='set_value', service_data={'value': 0}, target={'entity_id': 'number.mock_opening_degree'}), + call(domain='number', service='set_value', service_data={'value': 100}, target={'entity_id': 'number.mock_closing_degree'}), + # 6 = 18 (room) - 15 (current of underlying) + 3 (current offset) + call(domain='number', service='set_value', service_data={'value': 9.0}, target={'entity_id': 'number.mock_offset_calibration'}) + ] + ) + + # set the opening to 13% + mock_get_state_side_effect.add_or_update_side_effect( + "number.mock_opening_degree", + State( + "number.mock_opening_degree", "0", {"min": 0, "max": 100} + )) + + assert vtherm.hvac_action is HVACAction.OFF + assert vtherm.is_device_active is False + + + + await hass.async_block_till_done() diff --git a/tests/test_start.py b/tests/test_start.py index 47dca6e..b227bb0 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -58,6 +58,7 @@ async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_s assert entity._motion_state is None assert entity._presence_state is None assert entity._prop_algorithm is not None + assert entity.have_valve_regulation is False # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT assert mock_send_event.call_count == 2 @@ -94,18 +95,6 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_ return_value=fake_underlying_climate, ) as mock_find_climate: entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") - # 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 isinstance(entity, ThermostatOverClimate) @@ -127,6 +116,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_ assert entity._window_state is None assert entity._motion_state is None assert entity._presence_state is None + assert entity.have_valve_regulation is False # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT assert mock_send_event.call_count == 2 diff --git a/tests/test_valve.py b/tests/test_valve.py index 75decbb..70023d4 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -103,6 +103,7 @@ async def test_over_valve_full_start( assert entity._motion_state is None # pylint: disable=protected-access assert entity._presence_state is None # pylint: disable=protected-access assert entity._prop_algorithm is not None # pylint: disable=protected-access + assert entity.have_valve_regulation is False # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT # assert mock_send_event.call_count == 2