From 7a917c6ff77c5d2301f36aff734ba35ef98b2f00 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 12 Feb 2023 18:50:56 +0100 Subject: [PATCH] Testus --- .../versatile_thermostat/climate.py | 33 ++-- .../versatile_thermostat/tests/commons.py | 30 ++++ .../versatile_thermostat/tests/conftest.py | 28 +++ .../versatile_thermostat/tests/const.py | 58 +++++- .../tests/test_config_flow.py | 134 +++++++++++++- .../versatile_thermostat/tests/test_start.py | 169 ++++++++++++++++++ .../tests/test_unittest.py | 14 -- 7 files changed, 433 insertions(+), 33 deletions(-) create mode 100644 custom_components/versatile_thermostat/tests/commons.py create mode 100644 custom_components/versatile_thermostat/tests/test_start.py delete mode 100644 custom_components/versatile_thermostat/tests/test_unittest.py diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index f9a1904..0fb7f0f 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -546,6 +546,14 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._async_cancel_cycle() self._async_cancel_cycle = None + def find_underlying_climate(self, climate_entity_id) -> ClimateEntity: + """Find the underlying climate entity""" + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if climate_entity_id == entity.entity_id: + return entity + return None + async def async_startup(self): """Triggered on startup, used to get old state and set internal states accordingly""" _LOGGER.debug("%s - Calling async_startup", self) @@ -557,19 +565,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): # Get the underlying thermostat if self._is_over_climate: - component: EntityComponent[ClimateEntity] = self.hass.data[ - CLIMATE_DOMAIN - ] - for entity in component.entities: - if self._climate_entity_id == entity.entity_id: - _LOGGER.info( - "%s - The underlying climate entity: %s have been succesfully found", - self, - entity, - ) - self._underlying_climate = entity - break - if self._underlying_climate is None: + self._underlying_climate = self.find_underlying_climate( + self._climate_entity_id + ) + if self._underlying_climate: + _LOGGER.info( + "%s - The underlying climate entity: %s have been succesfully found", + self, + self._underlying_climate, + ) + else: _LOGGER.error( "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational", self, @@ -924,7 +929,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): else: return None else: - return self.hass.states.is_state(self._heater_entity_id, STATE_ON) + return self._hass.states.is_state(self._heater_entity_id, STATE_ON) @property def current_temperature(self): diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py new file mode 100644 index 0000000..6532061 --- /dev/null +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -0,0 +1,30 @@ +""" Some common resources """ + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import ClimateEntity +from homeassistant.const import UnitOfTemperature + +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ATTR_PRESET_MODE, + HVACMode, + HVACAction, +) + + +class MockClimate(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 = HVACAction.OFF + self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] + self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/custom_components/versatile_thermostat/tests/conftest.py b/custom_components/versatile_thermostat/tests/conftest.py index 01e5545..ea7aad9 100644 --- a/custom_components/versatile_thermostat/tests/conftest.py +++ b/custom_components/versatile_thermostat/tests/conftest.py @@ -18,10 +18,16 @@ from unittest.mock import patch import pytest +from homeassistant.core import HomeAssistant, StateMachine + from custom_components.versatile_thermostat.config_flow import ( VersatileThermostatBaseConfigFlow, ) +from custom_components.versatile_thermostat.climate import ( + VersatileThermostat, +) + pytest_plugins = "pytest_homeassistant_custom_component" @@ -45,8 +51,30 @@ def skip_notifications_fixture(): # This fixture is used to bypass the validate_input function in config_flow +# NOT USED Now (keeped for memory) @pytest.fixture(name="skip_validate_input") def skip_validate_input_fixture(): """Skip the validate_input in config flow""" with patch.object(VersatileThermostatBaseConfigFlow, "validate_input"): yield + + +@pytest.fixture(name="skip_hass_states_get") +def skip_hass_states_get_fixture(): + """Skip the get state in HomeAssistant""" + with patch.object(StateMachine, "get"): + yield + + +@pytest.fixture(name="skip_hass_states_is_state") +def skip_hass_states_is_state_fixture(): + """Skip the is_state in HomeAssistant""" + with patch.object(StateMachine, "is_state", return_value=False): + yield + + +@pytest.fixture(name="skip_send_event") +def skip_send_event_fixture(): + """Skip the send_event in VersatileThermostat""" + with patch.object(VersatileThermostat, "send_event"): + yield diff --git a/custom_components/versatile_thermostat/tests/const.py b/custom_components/versatile_thermostat/tests/const.py index 0b07e0d..2e8e3f2 100644 --- a/custom_components/versatile_thermostat/tests/const.py +++ b/custom_components/versatile_thermostat/tests/const.py @@ -26,6 +26,19 @@ from custom_components.versatile_thermostat.const import ( CONF_USE_MOTION_FEATURE, CONF_USE_POWER_FEATURE, CONF_USE_PRESENCE_FEATURE, + CONF_WINDOW_SENSOR, + CONF_WINDOW_DELAY, + CONF_MOTION_SENSOR, + CONF_MOTION_DELAY, + CONF_MOTION_PRESET, + CONF_NO_MOTION_PRESET, + CONF_POWER_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_DEVICE_POWER, + CONF_PRESET_POWER, + CONF_PRESENCE_SENSOR, + PRESET_AWAY_SUFFIX, + CONF_CLIMATE, ) MOCK_TH_OVER_SWITCH_USER_CONFIG = { @@ -36,7 +49,21 @@ MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_CYCLE_MIN: 5, CONF_TEMP_MIN: 15, CONF_TEMP_MAX: 30, - # Keep all additional optional features to false + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: True, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: True, +} + +MOCK_TH_OVER_CLIMATE_USER_CONFIG = { + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + # Keep default values which are False } MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { @@ -49,6 +76,9 @@ MOCK_TH_OVER_SWITCH_TPI_CONFIG = { CONF_TPI_COEF_EXT: 0.1, } +MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { + CONF_CLIMATE: "climate.mock_climate", +} MOCK_PRESETS_CONFIG = { PRESET_ECO + "_temp": 16, @@ -56,6 +86,32 @@ MOCK_PRESETS_CONFIG = { PRESET_BOOST + "_temp": 18, } +MOCK_WINDOW_CONFIG = { + CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", + CONF_WINDOW_DELAY: 10, +} + +MOCK_MOTION_CONFIG = { + CONF_MOTION_SENSOR: "input_boolean.motion_sensor", + CONF_MOTION_DELAY: 10, + CONF_MOTION_PRESET: PRESET_COMFORT, + CONF_NO_MOTION_PRESET: PRESET_ECO, +} + +MOCK_POWER_CONFIG = { + CONF_POWER_SENSOR: "sensor.power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor", + CONF_DEVICE_POWER: 1, + CONF_PRESET_POWER: 10, +} + +MOCK_PRESENCE_CONFIG = { + CONF_PRESENCE_SENSOR: "person.presence_sensor", + PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 16, + PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17, + PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18, +} + MOCK_ADVANCED_CONFIG = { CONF_MINIMAL_ACTIVATION_DELAY: 10, CONF_SECURITY_DELAY_MIN: 5, diff --git a/custom_components/versatile_thermostat/tests/test_config_flow.py b/custom_components/versatile_thermostat/tests/test_config_flow.py index e0bc764..3195db1 100644 --- a/custom_components/versatile_thermostat/tests/test_config_flow.py +++ b/custom_components/versatile_thermostat/tests/test_config_flow.py @@ -10,11 +10,17 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry, load_f from custom_components.versatile_thermostat.const import DOMAIN from custom_components.versatile_thermostat import VersatileThermostatAPI -from custom_components.versatile_thermostat.tests.const import ( +from .const import ( MOCK_TH_OVER_SWITCH_USER_CONFIG, + MOCK_TH_OVER_CLIMATE_USER_CONFIG, MOCK_TH_OVER_SWITCH_TYPE_CONFIG, + MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG, MOCK_PRESETS_CONFIG, + MOCK_WINDOW_CONFIG, + MOCK_MOTION_CONFIG, + MOCK_POWER_CONFIG, + MOCK_PRESENCE_CONFIG, MOCK_ADVANCED_CONFIG, MOCK_DEFAULT_FEATURE_CONFIG, ) @@ -35,8 +41,8 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == SOURCE_USER -async def test_user_config_flow_over_switch(hass, skip_validate_input): - """Test the config flow with thermostat_over_switch features""" +async def test_user_config_flow_over_switch(hass, skip_hass_states_get): + """Test the config flow with all thermostat_over_switch features""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -72,6 +78,38 @@ async def test_user_config_flow_over_switch(hass, skip_validate_input): result["flow_id"], user_input=MOCK_PRESETS_CONFIG ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "window" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_WINDOW_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "motion" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_MOTION_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "power" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_POWER_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "presence" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PRESENCE_CONFIG + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "advanced" assert result["errors"] == {} @@ -87,11 +125,99 @@ async def test_user_config_flow_over_switch(hass, skip_validate_input): | MOCK_TH_OVER_SWITCH_TYPE_CONFIG | MOCK_TH_OVER_SWITCH_TPI_CONFIG | MOCK_PRESETS_CONFIG + | MOCK_WINDOW_CONFIG + | MOCK_MOTION_CONFIG + | MOCK_POWER_CONFIG + | MOCK_PRESENCE_CONFIG | MOCK_ADVANCED_CONFIG - | MOCK_DEFAULT_FEATURE_CONFIG ) assert result["result"] assert result["result"].domain == DOMAIN assert result["result"].version == 1 assert result["result"].title == "TheOverSwitchMockName" assert isinstance(result["result"], ConfigEntry) + + +async def test_user_config_flow_over_climate(hass, skip_hass_states_get): + """Test the config flow with all thermostat_over_climate features and no additional features""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_CLIMATE_USER_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "type" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_CLIMATE_TYPE_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "presets" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PRESETS_CONFIG + ) + + # assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # assert result["step_id"] == "window" + # assert result["errors"] == {} + + # result = await hass.config_entries.flow.async_configure( + # result["flow_id"], user_input=MOCK_WINDOW_CONFIG + # ) + + # assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # assert result["step_id"] == "motion" + # assert result["errors"] == {} + + # result = await hass.config_entries.flow.async_configure( + # result["flow_id"], user_input=MOCK_MOTION_CONFIG + # ) + + # assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # assert result["step_id"] == "power" + # assert result["errors"] == {} + + # result = await hass.config_entries.flow.async_configure( + # result["flow_id"], user_input=MOCK_POWER_CONFIG + # ) + + # assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # assert result["step_id"] == "presence" + # assert result["errors"] == {} + + # result = await hass.config_entries.flow.async_configure( + # result["flow_id"], user_input=MOCK_PRESENCE_CONFIG + # ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "advanced" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ADVANCED_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["data"] + == MOCK_TH_OVER_CLIMATE_USER_CONFIG + | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_ADVANCED_CONFIG + | MOCK_DEFAULT_FEATURE_CONFIG + ) + assert result["result"] + assert result["result"].domain == DOMAIN + assert result["result"].version == 1 + assert result["result"].title == "TheOverClimateMockName" + assert isinstance(result["result"], ConfigEntry) diff --git a/custom_components/versatile_thermostat/tests/test_start.py b/custom_components/versatile_thermostat/tests/test_start.py new file mode 100644 index 0000000..d193c30 --- /dev/null +++ b/custom_components/versatile_thermostat/tests/test_start.py @@ -0,0 +1,169 @@ +""" Test the normal start of a Thermostat """ +from unittest.mock import patch, call +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.config_entries import ConfigEntryState + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from ..climate import VersatileThermostat + +from ..const import DOMAIN, EventType + +from .const import ( + MOCK_TH_OVER_SWITCH_USER_CONFIG, + MOCK_TH_OVER_CLIMATE_USER_CONFIG, + MOCK_TH_OVER_SWITCH_TYPE_CONFIG, + MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, + MOCK_TH_OVER_SWITCH_TPI_CONFIG, + MOCK_PRESETS_CONFIG, + MOCK_WINDOW_CONFIG, + MOCK_MOTION_CONFIG, + MOCK_POWER_CONFIG, + MOCK_PRESENCE_CONFIG, + MOCK_ADVANCED_CONFIG, + # MOCK_DEFAULT_FEATURE_CONFIG, +) + +from .commons import MockClimate + +FULL_SWITCH_CONFIG = ( + MOCK_TH_OVER_SWITCH_USER_CONFIG + | MOCK_TH_OVER_SWITCH_TYPE_CONFIG + | MOCK_TH_OVER_SWITCH_TPI_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_WINDOW_CONFIG + | MOCK_MOTION_CONFIG + | MOCK_POWER_CONFIG + | MOCK_PRESENCE_CONFIG + | MOCK_ADVANCED_CONFIG +) + +PARTIAL_CLIMATE_CONFIG = ( + MOCK_TH_OVER_CLIMATE_USER_CONFIG + | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_ADVANCED_CONFIG +) + + +async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_state): + """Test the normal full start of a thermostat in thermostat_over_switch type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data=FULL_SWITCH_CONFIG, + ) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event: + 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.theoverswitchmockname") + + assert entity + + assert entity.name == "TheOverSwitchMockName" + assert entity._is_over_climate is False + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + assert entity.target_temperature == entity.min_temp + assert entity.preset_mode is None + assert entity._security_state is False + assert entity._window_state is None + assert entity._motion_state is None + assert entity._presence_state is None + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + + # Impossible to make this work, but it works... + # assert mock_send_event.assert_has_calls( + # [ + # call.send_event(EventType.PRESET_EVENT, {"preset": None}), + # call.send_event( + # EventType.HVAC_MODE_EVENT, + # {"hvac_mode": HVACMode.OFF}, + # ), + # ] + # ) + + +async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_state): + """Test the normal full start of a thermostat in thermostat_over_climate type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data=PARTIAL_CLIMATE_CONFIG, + ) + + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.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.target_temperature == entity.min_temp + assert entity.preset_mode is None + assert entity._security_state is False + assert entity._window_state is None + assert entity._motion_state is None + assert entity._presence_state is None + + # 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": 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("climate.mock_climate") + mock_find_climate.assert_has_calls( + [call.find_underlying_entity("climate.mock_climate")] + ) diff --git a/custom_components/versatile_thermostat/tests/test_unittest.py b/custom_components/versatile_thermostat/tests/test_unittest.py deleted file mode 100644 index f217485..0000000 --- a/custom_components/versatile_thermostat/tests/test_unittest.py +++ /dev/null @@ -1,14 +0,0 @@ -import unittest # The test framework - - -class Test_TestIncrementDecrement(unittest.TestCase): - def test_increment(self): - self.assertEqual(4, 4) - - # This test is designed to fail for demonstration purposes. - def test_decrement(self): - self.assertEqual(3, 3) - - -if __name__ == "__main__": - unittest.main()