diff --git a/custom_components/irm_kmi/__init__.py b/custom_components/irm_kmi/__init__.py index 8644ac3..39625a7 100644 --- a/custom_components/irm_kmi/__init__.py +++ b/custom_components/irm_kmi/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() except ConfigEntryError: # This happens when the zone is out of Benelux (no forecast available there) + # This should be caught by the config flow anyway return False await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/irm_kmi/config_flow.py b/custom_components/irm_kmi/config_flow.py index e907336..e81a9dc 100644 --- a/custom_components/irm_kmi/config_flow.py +++ b/custom_components/irm_kmi/config_flow.py @@ -1,23 +1,26 @@ """Config flow to set up IRM KMI integration via the UI.""" import logging +import async_timeout import voluptuous as vol from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_ZONE +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import (EntitySelector, EntitySelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode) +from .api import IrmKmiApiClient from .const import (CONF_DARK_MODE, CONF_STYLE, CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST, CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED, - OPTION_STYLE_STD) + OPTION_STYLE_STD, OUT_OF_BENELUX) from .utils import get_config_value _LOGGER = logging.getLogger(__name__) @@ -34,23 +37,47 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Define the user step of the configuration flow.""" - if user_input is not None: + errors = {} + + if user_input: _LOGGER.debug(f"Provided config user is: {user_input}") - await self.async_set_unique_id(user_input[CONF_ZONE]) - self._abort_if_unique_id_configured() + if (zone := self.hass.states.get(user_input[CONF_ZONE])) is None: + errors[CONF_ZONE] = 'zone_not_exist' - state = self.hass.states.get(user_input[CONF_ZONE]) - return self.async_create_entry( - title=state.name if state else "IRM KMI", - data={CONF_ZONE: user_input[CONF_ZONE], - CONF_STYLE: user_input[CONF_STYLE], - CONF_DARK_MODE: user_input[CONF_DARK_MODE], - CONF_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST]}, - ) + # Check if zone is in Benelux + if not errors: + api_data = {} + try: + async with async_timeout.timeout(10): + api_data = await IrmKmiApiClient( + session=async_get_clientsession(self.hass)).get_forecasts_coord( + {'lat': zone.attributes[ATTR_LATITUDE], + 'long': zone.attributes[ATTR_LONGITUDE]} + ) + except Exception: + errors['base'] = "api_error" + + if api_data.get('cityName', None) in OUT_OF_BENELUX: + errors[CONF_ZONE] = 'out_of_benelux' + + if not errors: + await self.async_set_unique_id(user_input[CONF_ZONE]) + self._abort_if_unique_id_configured() + + state = self.hass.states.get(user_input[CONF_ZONE]) + return self.async_create_entry( + title=state.name if state else "IRM KMI", + data={CONF_ZONE: user_input[CONF_ZONE], + CONF_STYLE: user_input[CONF_STYLE], + CONF_DARK_MODE: user_input[CONF_DARK_MODE], + CONF_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST]}, + ) return self.async_show_form( step_id="user", + errors=errors, + description_placeholders={'zone': user_input.get('zone') if user_input is not None else None}, data_schema=vol.Schema({ vol.Required(CONF_ZONE): EntitySelector(EntitySelectorConfig(domain=ZONE_DOMAIN)), diff --git a/custom_components/irm_kmi/const.py b/custom_components/irm_kmi/const.py index 716e7c5..862438c 100644 --- a/custom_components/irm_kmi/const.py +++ b/custom_components/irm_kmi/const.py @@ -57,6 +57,11 @@ CONF_USE_DEPRECATED_FORECAST_OPTIONS: Final = [ OPTION_DEPRECATED_FORECAST_HOURLY ] +REPAIR_SOLUTION: Final = "repair_solution" +REPAIR_OPT_MOVE: Final = "repair_option_move" +REPAIR_OPT_DELETE: Final = "repair_option_delete" +REPAIR_OPTIONS: Final = [REPAIR_OPT_MOVE, REPAIR_OPT_DELETE] + # map ('ww', 'dayNight') tuple from IRM KMI to HA conditions IRM_KMI_TO_HA_CONDITION_MAP: Final = { (0, 'd'): ATTR_CONDITION_SUNNY, diff --git a/custom_components/irm_kmi/coordinator.py b/custom_components/irm_kmi/coordinator.py index 46d4a0a..a70d2b5 100644 --- a/custom_components/irm_kmi/coordinator.py +++ b/custom_components/irm_kmi/coordinator.py @@ -10,13 +10,13 @@ from homeassistant.components.weather import Forecast from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import issue_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, UpdateFailed) from .api import IrmKmiApiClient, IrmKmiApiError -from .const import CONF_DARK_MODE, CONF_STYLE +from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP from .const import (LANGS, OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP) @@ -39,7 +39,7 @@ class IrmKmiCoordinator(DataUpdateCoordinator): # Name of the data. For logging purposes. name="IRM KMI weather", # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(minutes=7), + update_interval=timedelta(seconds=15), ) self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass)) self._zone = get_config_value(entry, CONF_ZONE) @@ -70,17 +70,24 @@ class IrmKmiCoordinator(DataUpdateCoordinator): raise UpdateFailed(f"Error communicating with API: {err}") if api_data.get('cityName', None) in OUT_OF_BENELUX: - if self.data is None: - error_text = f"Zone '{self._zone}' is out of Benelux and forecast is only available in the Benelux" - _LOGGER.error(error_text) - raise ConfigEntryError(error_text) - else: - # TODO create a repair when this triggers - _LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux." - f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix " - f"this") - disable_from_config(self.hass, self._config_entry) - return ProcessedCoordinatorData() + # TODO create a repair when this triggers + _LOGGER.info(f"Config state: {self._config_entry.state}") + _LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux." + f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix " + f"this") + disable_from_config(self.hass, self._config_entry) + + issue_registry.async_create_issue( + self.hass, + DOMAIN, + "zone_moved", + is_fixable=True, + severity=issue_registry.IssueSeverity.ERROR, + translation_key='zone_moved', + data={'config_entry_id': self._config_entry.entry_id, 'zone': self._zone}, + translation_placeholders={'zone': self._zone} + ) + return ProcessedCoordinatorData() return await self.process_api_data(api_data) diff --git a/custom_components/irm_kmi/repairs.py b/custom_components/irm_kmi/repairs.py new file mode 100644 index 0000000..1f0fc56 --- /dev/null +++ b/custom_components/irm_kmi/repairs.py @@ -0,0 +1,93 @@ +import logging + +import async_timeout +import voluptuous as vol +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from custom_components.irm_kmi import async_reload_entry +from custom_components.irm_kmi.api import IrmKmiApiClient +from custom_components.irm_kmi.const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE, + REPAIR_OPT_MOVE, REPAIR_OPTIONS, + REPAIR_SOLUTION) +from custom_components.irm_kmi.utils import modify_from_config + +_LOGGER = logging.getLogger(__name__) + + +class OutOfBeneluxRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict): + self._data: dict = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await (self.async_step_confirm()) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + errors = {} + + config_entry = self.hass.config_entries.async_get_entry(self._data['config_entry_id']) + _LOGGER.info(f"State of config entry: {config_entry.state}") + + if user_input is not None: + if user_input[REPAIR_SOLUTION] == REPAIR_OPT_MOVE: + if (zone := self.hass.states.get(self._data['zone'])) is None: + errors[REPAIR_SOLUTION] = "zone_not_exist" + + if not errors: + api_data = {} + try: + async with async_timeout.timeout(10): + api_data = await IrmKmiApiClient( + session=async_get_clientsession(self.hass)).get_forecasts_coord( + {'lat': zone.attributes[ATTR_LATITUDE], + 'long': zone.attributes[ATTR_LONGITUDE]} + ) + except Exception: + errors[REPAIR_SOLUTION] = 'api_error' + + if api_data.get('cityName', None) in OUT_OF_BENELUX: + errors[REPAIR_SOLUTION] = 'out_of_benelux' + + if not errors: + modify_from_config(self.hass, self._data['config_entry_id'], enable=True) + await async_reload_entry(self.hass, config_entry) + + elif user_input[REPAIR_SOLUTION] == REPAIR_OPT_DELETE: + await self.hass.config_entries.async_remove(self._data['config_entry_id']) + else: + errors[REPAIR_SOLUTION] = "invalid_choice" + + if not errors: + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm", + errors=errors, + description_placeholders={'zone': self._data['zone']}, + data_schema=vol.Schema({ + vol.Required(REPAIR_SOLUTION, default=REPAIR_OPT_MOVE): + SelectSelector(SelectSelectorConfig(options=REPAIR_OPTIONS, + translation_key=REPAIR_SOLUTION)), + })) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> OutOfBeneluxRepairFlow: + """Create flow.""" + return OutOfBeneluxRepairFlow(data) diff --git a/custom_components/irm_kmi/translations/en.json b/custom_components/irm_kmi/translations/en.json index a6cc089..99d79ac 100644 --- a/custom_components/irm_kmi/translations/en.json +++ b/custom_components/irm_kmi/translations/en.json @@ -14,6 +14,11 @@ "use_deprecated_forecast_attribute": "Use the deprecated forecat attribute" } } + }, + "error": { + "out_of_benelux": "{zone} is out of Benelux. Pick a zone in Benelux.", + "api_error": "Could not get data from the API", + "zone_not_exist": "{zone} does not exist" } }, "selector": { @@ -31,6 +36,12 @@ "daily_in_deprecated_forecast": "Use for daily forecast", "hourly_in_use_deprecated_forecast": "Use for hourly forecast" } + }, + "repair_solution": { + "options": { + "repair_option_move": "I moved the zone in Benelux", + "repair_option_delete": "Delete that config entry" + } } }, "options": { @@ -47,8 +58,21 @@ }, "issues": { "zone_moved": { - "title": "Zone moved", - "description": "Hey!" + "title": "{zone} is outside of Benelux", + "fix_flow": { + "step": { + "confirm": { + "title": "Repair: {zone} is outside of Benelux", + "description": "This integration can only get data for location in the Benelux. Move the zone or delete this configuration entry." + } + }, + "error": { + "out_of_benelux": "{zone} is out of Benelux. Move it inside Benelux first.", + "api_error": "Could not get data from the API", + "zone_not_exist": "{zone} does not exist", + "invalid_choice": "The choice is not valid" + } + } } } } diff --git a/requirements.txt b/requirements.txt index 41ab769..3b8d526 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ aiohttp==3.9.1 async_timeout==4.0.3 -homeassistant==2023.12.3 +homeassistant==2024.1.2 voluptuous==0.13.1 pytz==2023.3.post1 svgwrite==1.4.3 \ No newline at end of file diff --git a/requirements_tests.txt b/requirements_tests.txt index a0dabe0..272fe62 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -1,5 +1,6 @@ -homeassistant==2023.12.3 -pytest==7.4.3 -pytest_homeassistant_custom_component==0.13.85 -freezegun==1.2.2 -Pillow==10.1.0 \ No newline at end of file +homeassistant==2024.1.2 +pytest +pytest_homeassistant_custom_component @ git+https://github.com/MatthewFlamm/pytest-homeassistant-custom-component +freezegun +Pillow==10.1.0 +isort \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 5f47d7a..8bd0e5f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,5 @@ [tool:pytest] testpaths = tests norecursedirs = .git -addopts = - --cov=custom_components +addopts = -s -v asyncio_mode = auto \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5ad0d4f..4cdf4a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,8 @@ from homeassistant.const import CONF_ZONE from pytest_homeassistant_custom_component.common import (MockConfigEntry, load_fixture) -from custom_components.irm_kmi.api import IrmKmiApiParametersError +from custom_components.irm_kmi.api import (IrmKmiApiError, + IrmKmiApiParametersError) from custom_components.irm_kmi.const import ( CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD) @@ -60,6 +61,38 @@ def mock_setup_entry() -> Generator[None, None, None]: yield +@pytest.fixture +def mock_get_forecast_in_benelux(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux""" + with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + return_value={'cityName': 'Brussels'}): + yield + + +@pytest.fixture +def mock_get_forecast_out_benelux(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux""" + with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + return_value={'cityName': "Outside the Benelux (Brussels)"}): + yield + + +@pytest.fixture +def mock_get_forecast_api_error(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error""" + with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + side_effet=IrmKmiApiError): + return + + +@pytest.fixture +def mock_get_forecast_api_error_repair(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error""" + with patch("custom_components.irm_kmi.repairs.IrmKmiApiClient.get_forecasts_coord", + side_effet=IrmKmiApiError): + return + + @pytest.fixture() def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked IrmKmi api client.""" @@ -75,7 +108,7 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc @pytest.fixture() -def mock_irm_kmi_api_out_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_irm_kmi_api_coordinator_out_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked IrmKmi api client.""" fixture: str = "forecast_out_of_benelux.json" @@ -88,6 +121,34 @@ def mock_irm_kmi_api_out_benelux(request: pytest.FixtureRequest) -> Generator[No yield irm_kmi +@pytest.fixture() +def mock_irm_kmi_api_repair_in_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked IrmKmi api client.""" + fixture: str = "forecast.json" + + forecast = json.loads(load_fixture(fixture)) + with patch( + "custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True + ) as irm_kmi_api_mock: + irm_kmi = irm_kmi_api_mock.return_value + irm_kmi.get_forecasts_coord.return_value = forecast + yield irm_kmi + + +@pytest.fixture() +def mock_irm_kmi_api_repair_out_of_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked IrmKmi api client.""" + fixture: str = "forecast_out_of_benelux.json" + + forecast = json.loads(load_fixture(fixture)) + with patch( + "custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True + ) as irm_kmi_api_mock: + irm_kmi = irm_kmi_api_mock.return_value + irm_kmi.get_forecasts_coord.return_value = forecast + yield irm_kmi + + @pytest.fixture() def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked IrmKmi api client.""" @@ -99,18 +160,6 @@ def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None yield irm_kmi -@pytest.fixture() -def mock_image_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: - """Return a mocked IrmKmi api client.""" - - with patch( - "custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True - ) as irm_kmi_api_mock: - irm_kmi = irm_kmi_api_mock.return_value - irm_kmi.get_image.side_effect = patched - yield irm_kmi - - @pytest.fixture() def mock_image_and_nl_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked IrmKmi api client.""" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7854279..56bb67a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -13,12 +13,13 @@ from custom_components.irm_kmi import async_migrate_entry from custom_components.irm_kmi.const import ( CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED, - OPTION_STYLE_STD) + OPTION_STYLE_SATELLITE, OPTION_STYLE_STD) async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -43,6 +44,95 @@ async def test_full_user_flow( CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED} +async def test_config_flow_out_benelux_zone( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_out_benelux: MagicMock +) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ZONE: ENTITY_ID_HOME, + CONF_STYLE: OPTION_STYLE_STD, + CONF_DARK_MODE: False}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "user" + assert CONF_ZONE in result2.get('errors') + + +async def test_config_flow_with_api_error( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_api_error: MagicMock +) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ZONE: ENTITY_ID_HOME, + CONF_STYLE: OPTION_STYLE_STD, + CONF_DARK_MODE: False}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "user" + assert 'base' in result2.get('errors') + + +async def test_config_flow_unknown_zone(hass: HomeAssistant) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ZONE: "zone.what", + CONF_STYLE: OPTION_STYLE_STD, + CONF_DARK_MODE: False}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "user" + assert CONF_ZONE in result2.get('errors') + + +async def test_option_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry +) -> None: + mock_config_entry.add_to_hass(hass) + + assert not mock_config_entry.options + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id, data=None) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_STYLE: OPTION_STYLE_SATELLITE, + CONF_DARK_MODE: True, + CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED + } + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_STYLE: OPTION_STYLE_SATELLITE, + CONF_DARK_MODE: True, + CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED + } + + async def test_config_entry_migration(hass: HomeAssistant) -> None: """Ensure that config entry migration takes the configuration to the latest version""" entry = MockConfigEntry( diff --git a/tests/test_init.py b/tests/test_init.py index 5ece1a8..27ec08e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -74,29 +74,3 @@ async def test_config_entry_zone_removed( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert "Zone 'zone.castle' not found" in caplog.text - - -async def test_zone_out_of_benelux( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_irm_kmi_api_out_benelux: AsyncMock -) -> None: - """Test the IRM KMI when configuration zone is out of Benelux""" - mock_config_entry = MockConfigEntry( - title="London", - domain=DOMAIN, - data={CONF_ZONE: "zone.london"}, - unique_id="zone.london", - ) - hass.states.async_set( - "zone.london", - 0, - {"latitude": 51.5072, "longitude": 0.1276}, - ) - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - assert "Zone 'zone.london' is out of Benelux" in caplog.text diff --git a/tests/test_repairs.py b/tests/test_repairs.py new file mode 100644 index 0000000..504c189 --- /dev/null +++ b/tests/test_repairs.py @@ -0,0 +1,163 @@ +import logging +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator +from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE, + REPAIR_OPT_MOVE, REPAIR_SOLUTION) +from custom_components.irm_kmi.repairs import (OutOfBeneluxRepairFlow, + async_create_fix_flow) + +_LOGGER = logging.getLogger(__name__) + + +async def get_repair_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry +) -> OutOfBeneluxRepairFlow: + hass.states.async_set( + "zone.home", + 0, + {"latitude": 50.738681639, "longitude": 4.054077148}, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + coordinator = IrmKmiCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + ir = issue_registry.async_get(hass) + issue = ir.async_get_issue(DOMAIN, "zone_moved") + repair_flow = await async_create_fix_flow(hass, issue.issue_id, issue.data) + repair_flow.hass = hass + return repair_flow + + +async def test_repair_triggers_when_out_of_benelux( + hass: HomeAssistant, + mock_irm_kmi_api_coordinator_out_benelux: MagicMock, + mock_config_entry: MockConfigEntry +) -> None: + hass.states.async_set( + "zone.home", + 0, + {"latitude": 50.738681639, "longitude": 4.054077148}, + ) + + mock_config_entry.add_to_hass(hass) + + coordinator = IrmKmiCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + + ir = issue_registry.async_get(hass) + + issue = ir.async_get_issue(DOMAIN, "zone_moved") + + assert issue is not None + assert issue.data == {'config_entry_id': mock_config_entry.entry_id, 'zone': "zone.home"} + assert issue.translation_key == "zone_moved" + assert issue.is_fixable + assert issue.translation_placeholders == {'zone': "zone.home"} + + +async def test_repair_flow( + hass: HomeAssistant, + mock_irm_kmi_api_coordinator_out_benelux: MagicMock, + mock_irm_kmi_api_repair_in_benelux: MagicMock, + mock_config_entry: MockConfigEntry +) -> None: + repair_flow = await get_repair_flow(hass, mock_config_entry) + result = await repair_flow.async_step_init() + + assert result['type'] == FlowResultType.FORM + assert result['errors'] == {} + assert result['description_placeholders'] == {"zone": "zone.home"} + + user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE} + + result = await repair_flow.async_step_confirm(user_input) + + assert result['type'] == FlowResultType.CREATE_ENTRY + assert result['title'] == "" + assert result['data'] == {} + + +async def test_repair_flow_invalid_choice( + hass: HomeAssistant, + mock_irm_kmi_api_coordinator_out_benelux: MagicMock, + mock_irm_kmi_api_repair_in_benelux: MagicMock, + mock_config_entry: MockConfigEntry +) -> None: + repair_flow = await get_repair_flow(hass, mock_config_entry) + result = await repair_flow.async_step_init() + + assert result['type'] == FlowResultType.FORM + user_input = {REPAIR_SOLUTION: "whut?"} + + result = await repair_flow.async_step_confirm(user_input) + + assert result['type'] == FlowResultType.FORM + assert REPAIR_SOLUTION in result['errors'] + assert result['errors'][REPAIR_SOLUTION] == 'invalid_choice' + + +async def test_repair_flow_api_error( + hass: HomeAssistant, + mock_irm_kmi_api_coordinator_out_benelux: MagicMock, + mock_get_forecast_api_error_repair: MagicMock, + mock_config_entry: MockConfigEntry +) -> None: + repair_flow = await get_repair_flow(hass, mock_config_entry) + result = await repair_flow.async_step_init() + + assert result['type'] == FlowResultType.FORM + user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE} + + result = await repair_flow.async_step_confirm(user_input) + + assert result['type'] == FlowResultType.FORM + assert REPAIR_SOLUTION in result['errors'] + assert result['errors'][REPAIR_SOLUTION] == 'api_error' + + +async def test_repair_flow_out_of_benelux( + hass: HomeAssistant, + mock_irm_kmi_api_coordinator_out_benelux: MagicMock, + mock_irm_kmi_api_repair_out_of_benelux: MagicMock, + mock_config_entry: MockConfigEntry +) -> None: + repair_flow = await get_repair_flow(hass, mock_config_entry) + result = await repair_flow.async_step_init() + + assert result['type'] == FlowResultType.FORM + user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE} + + result = await repair_flow.async_step_confirm(user_input) + + assert result['type'] == FlowResultType.FORM + assert REPAIR_SOLUTION in result['errors'] + assert result['errors'][REPAIR_SOLUTION] == 'out_of_benelux' + + +async def test_repair_flow_delete_entry( + hass: HomeAssistant, + mock_irm_kmi_api_coordinator_out_benelux: MagicMock, + mock_config_entry: MockConfigEntry +) -> None: + repair_flow = await get_repair_flow(hass, mock_config_entry) + result = await repair_flow.async_step_init() + + assert result['type'] == FlowResultType.FORM + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].entry_id == mock_config_entry.entry_id + + user_input = {REPAIR_SOLUTION: REPAIR_OPT_DELETE} + result = await repair_flow.async_step_confirm(user_input) + + assert result['type'] == FlowResultType.CREATE_ENTRY + assert result['title'] == "" + assert result['data'] == {} + assert len(hass.config_entries.async_entries(DOMAIN)) == 0