From 77df31fa838782f9d082f3d1d7117656e425ffaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Sat, 25 Apr 2026 23:11:44 +0200 Subject: [PATCH] Add climate platform tests for nobo_hub (#169010) --- homeassistant/components/nobo_hub/climate.py | 2 - tests/components/nobo_hub/conftest.py | 14 +- .../nobo_hub/snapshots/test_climate.ambr | 83 +++++++ tests/components/nobo_hub/test_climate.py | 207 ++++++++++++++++++ 4 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 tests/components/nobo_hub/snapshots/test_climate.ambr create mode 100644 tests/components/nobo_hub/test_climate.py diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index b418a1a6e152..4246c8a8db00 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -134,8 +134,6 @@ class NoboZone(NoboBaseEntity, ClimateEntity): if ATTR_TARGET_TEMP_LOW in kwargs: low = round(kwargs[ATTR_TARGET_TEMP_LOW]) high = round(kwargs[ATTR_TARGET_TEMP_HIGH]) - low = min(low, high) - high = max(low, high) await self._nobo.async_update_zone( self._id, temp_comfort_c=high, temp_eco_c=low ) diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index ba31d95400a0..d3df30ec001b 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Nobø Ecohub tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from pynobo import nobo as pynobo_nobo @@ -58,7 +59,17 @@ def connect_exc() -> BaseException | None: @pytest.fixture -def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry: +def config_entry_options() -> dict[str, Any]: + """Return the options stored on the config entry.""" + return {} + + +@pytest.fixture +def mock_config_entry( + ip_address: str, + auto_discovered: bool, + config_entry_options: dict[str, Any], +) -> MockConfigEntry: """Return a mock Nobø Ecohub config entry.""" return MockConfigEntry( domain=DOMAIN, @@ -69,6 +80,7 @@ def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry CONF_IP_ADDRESS: ip_address, CONF_AUTO_DISCOVERED: auto_discovered, }, + options=config_entry_options, ) diff --git a/tests/components/nobo_hub/snapshots/test_climate.ambr b/tests/components/nobo_hub/snapshots/test_climate.ambr new file mode 100644 index 000000000000..349e719ba207 --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_climate.ambr @@ -0,0 +1,83 @@ +# serializer version: 1 +# name: test_climate_entities[climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'none', + 'comfort', + 'eco', + 'away', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '102000013098:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Living room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'comfort', + 'preset_modes': list([ + 'none', + 'comfort', + 'eco', + 'away', + ]), + 'supported_features': , + 'target_temp_high': 21, + 'target_temp_low': 17, + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py new file mode 100644 index 000000000000..26f4329b51ae --- /dev/null +++ b/tests/components/nobo_hub/test_climate.py @@ -0,0 +1,207 @@ +"""Tests for the Nobø Ecohub climate platform.""" + +from unittest.mock import MagicMock + +from pynobo import nobo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.nobo_hub.const import ( + CONF_OVERRIDE_TYPE, + OVERRIDE_TYPE_NOW, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import fire_hub_update + +from tests.common import MockConfigEntry, snapshot_platform + +CLIMATE_ENTITY = "climate.living_room" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only set up the climate platform for these tests.""" + return [Platform.CLIMATE] + + +@pytest.mark.usefixtures("init_integration") +async def test_climate_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """All climate entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("zone_mode", "expected_state", "expected_preset"), + [ + (nobo.API.NAME_OFF, HVACMode.OFF, PRESET_NONE), + (nobo.API.NAME_AWAY, HVACMode.AUTO, PRESET_AWAY), + (nobo.API.NAME_ECO, HVACMode.AUTO, PRESET_ECO), + (nobo.API.NAME_COMFORT, HVACMode.AUTO, PRESET_COMFORT), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_state_maps_zone_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + zone_mode: str, + expected_state: HVACMode, + expected_preset: str, +) -> None: + """Zone modes map to the expected HVAC mode and preset.""" + mock_nobo_hub.get_current_zone_mode.return_value = zone_mode + await fire_hub_update(hass, mock_nobo_hub) + state = hass.states.get(CLIMATE_ENTITY) + assert state.state == expected_state + assert state.attributes[ATTR_PRESET_MODE] == expected_preset + + +@pytest.mark.usefixtures("init_integration") +async def test_state_override_forces_heat( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A non-normal zone override maps to HVACMode.HEAT.""" + # Any non-NORMAL override value suffices; NAME_COMFORT is arbitrary. + mock_nobo_hub.get_zone_override_mode.return_value = nobo.API.NAME_COMFORT + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).state == HVACMode.HEAT + + +@pytest.mark.usefixtures("init_integration") +async def test_current_temperature_unknown_when_missing( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A missing current temperature surfaces as None.""" + mock_nobo_hub.get_current_zone_temperature.return_value = None + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).attributes[ATTR_CURRENT_TEMPERATURE] is None + + +@pytest.mark.parametrize( + ("hvac_mode", "expected_override"), + [ + (HVACMode.AUTO, nobo.API.OVERRIDE_MODE_NORMAL), + (HVACMode.HEAT, nobo.API.OVERRIDE_MODE_COMFORT), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + hvac_mode: HVACMode, + expected_override: str, +) -> None: + """Each HVAC mode maps to the expected zone override.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + expected_override, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.parametrize( + ("preset", "expected_mode"), + [ + (PRESET_NONE, nobo.API.OVERRIDE_MODE_NORMAL), + (PRESET_COMFORT, nobo.API.OVERRIDE_MODE_COMFORT), + (PRESET_ECO, nobo.API.OVERRIDE_MODE_ECO), + (PRESET_AWAY, nobo.API.OVERRIDE_MODE_AWAY), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_preset_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + preset: str, + expected_mode: str, +) -> None: + """Each preset maps to the expected override mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: preset}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + expected_mode, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.parametrize( + "config_entry_options", + [{CONF_OVERRIDE_TYPE: OVERRIDE_TYPE_NOW}], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_preset_with_override_type_now( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """The override_type option flows into the zone override call.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: PRESET_COMFORT}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + nobo.API.OVERRIDE_MODE_COMFORT, + nobo.API.OVERRIDE_TYPE_NOW, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_updates_zone( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Setting target temperatures updates the zone on the hub.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: CLIMATE_ENTITY, + ATTR_TARGET_TEMP_LOW: 16.4, + ATTR_TARGET_TEMP_HIGH: 21.6, + }, + blocking=True, + ) + mock_nobo_hub.async_update_zone.assert_called_once_with( + "1", temp_comfort_c=22, temp_eco_c=16 + )