mirror of
https://github.com/home-assistant/core.git
synced 2026-09-06 21:41:28 +01:00
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Tests for the Duco number platform."""
|
|
|
|
from dataclasses import replace
|
|
from unittest.mock import AsyncMock
|
|
|
|
from duco_connectivity import (
|
|
BypassSupplyTemperatureTarget,
|
|
DucoError,
|
|
DucoRateLimitError,
|
|
)
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
|
|
from homeassistant.components.duco.const import SCAN_INTERVAL
|
|
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE
|
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import entity_registry as er
|
|
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
|
|
|
from . import setup_platform_integration
|
|
|
|
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
|
|
|
_ZONE_1_ENTITY_ID = "number.living_bypass_target_1"
|
|
_ZONE_2_ENTITY_ID = "number.living_bypass_target_2"
|
|
_ZONE_8_ENTITY_ID = "number.living_bypass_target_8"
|
|
|
|
|
|
@pytest.fixture
|
|
async def init_integration(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_duco_client: AsyncMock,
|
|
) -> MockConfigEntry:
|
|
"""Set up only the number platform for testing."""
|
|
return await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
|
|
async def test_bypass_supply_temperature_target_numbers_support_all_exposed_zones(
|
|
hass: HomeAssistant,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_duco_client: AsyncMock,
|
|
) -> None:
|
|
"""Test bypass target controls are created for all exposed zones."""
|
|
mock_bypass_supply_temperature_targets[8] = BypassSupplyTemperatureTarget(
|
|
zone_id=8,
|
|
value=22.0,
|
|
minimum=15.0,
|
|
increment=0.1,
|
|
maximum=25.0,
|
|
)
|
|
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
for entity_id in (
|
|
_ZONE_1_ENTITY_ID,
|
|
_ZONE_2_ENTITY_ID,
|
|
_ZONE_8_ENTITY_ID,
|
|
):
|
|
assert hass.states.get(entity_id) is not None
|
|
|
|
mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with()
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
|
async def test_bypass_supply_temperature_target_number_entities_state(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
snapshot: SnapshotAssertion,
|
|
) -> None:
|
|
"""Test bypass supply temperature target number entity states."""
|
|
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_duco_client")
|
|
async def test_bypass_supply_temperature_targets_missing_skips_number_creation(
|
|
hass: HomeAssistant,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test no number entities are created when bypass targets are unavailable."""
|
|
mock_bypass_supply_temperature_targets.clear()
|
|
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
assert hass.states.get(_ZONE_1_ENTITY_ID) is None
|
|
assert hass.states.get(_ZONE_2_ENTITY_ID) is None
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
async def test_set_bypass_supply_temperature_target(
|
|
hass: HomeAssistant,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_duco_client: AsyncMock,
|
|
) -> None:
|
|
"""Test setting a bypass target refreshes the number from the box."""
|
|
target = mock_bypass_supply_temperature_targets[1]
|
|
|
|
await hass.services.async_call(
|
|
NUMBER_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
|
1, 20.5, target=target
|
|
)
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == "20.5"
|
|
|
|
|
|
async def test_set_bypass_supply_temperature_target_honors_increment_metadata(
|
|
hass: HomeAssistant,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_duco_client: AsyncMock,
|
|
) -> None:
|
|
"""Test bypass target writes follow the API-provided increment metadata."""
|
|
mock_bypass_supply_temperature_targets[1] = replace(
|
|
mock_bypass_supply_temperature_targets[1],
|
|
minimum=10.0,
|
|
increment=0.5,
|
|
maximum=25.5,
|
|
)
|
|
target = mock_bypass_supply_temperature_targets[1]
|
|
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
await hass.services.async_call(
|
|
NUMBER_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
|
1, 20.5, target=target
|
|
)
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError,
|
|
match="supported increment of 0.5 starting at 10.0",
|
|
):
|
|
await hass.services.async_call(
|
|
NUMBER_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.2},
|
|
blocking=True,
|
|
)
|
|
|
|
|
|
async def test_set_bypass_supply_temperature_target_in_fahrenheit_units(
|
|
hass: HomeAssistant,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_duco_client: AsyncMock,
|
|
) -> None:
|
|
"""Test Fahrenheit service writes normalize to the nearest supported Celsius step."""
|
|
hass.config.units = US_CUSTOMARY_SYSTEM
|
|
mock_bypass_supply_temperature_targets[1] = replace(
|
|
mock_bypass_supply_temperature_targets[1],
|
|
minimum=10.0,
|
|
increment=0.5,
|
|
maximum=25.5,
|
|
)
|
|
target = mock_bypass_supply_temperature_targets[1]
|
|
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
await hass.services.async_call(
|
|
NUMBER_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 69.0},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
|
1, 20.5, target=target
|
|
)
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == "68.9"
|
|
|
|
|
|
async def test_set_bypass_supply_temperature_target_stays_within_maximum(
|
|
hass: HomeAssistant,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_duco_client: AsyncMock,
|
|
) -> None:
|
|
"""Test normalization never rounds past a maximum that is not a whole step."""
|
|
hass.config.units = US_CUSTOMARY_SYSTEM
|
|
mock_bypass_supply_temperature_targets[1] = replace(
|
|
mock_bypass_supply_temperature_targets[1],
|
|
minimum=10.0,
|
|
increment=0.5,
|
|
maximum=24.8,
|
|
)
|
|
target = mock_bypass_supply_temperature_targets[1]
|
|
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
await hass.services.async_call(
|
|
NUMBER_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 76.6},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
|
1, 24.5, target=target
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_duco_client")
|
|
async def test_bypass_supply_temperature_target_becomes_unavailable_when_missing(
|
|
hass: HomeAssistant,
|
|
freezer: FrozenDateTimeFactory,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a bypass target becomes unavailable when a bulk read omits it."""
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == "20.0"
|
|
|
|
updated_target = replace(mock_bypass_supply_temperature_targets.pop(1), value=20.5)
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
mock_bypass_supply_temperature_targets[1] = updated_target
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == "20.5"
|
|
|
|
|
|
async def test_bypass_supply_temperature_target_recovers_from_refresh_error(
|
|
hass: HomeAssistant,
|
|
freezer: FrozenDateTimeFactory,
|
|
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_duco_client: AsyncMock,
|
|
) -> None:
|
|
"""Test a bypass target recovers after a transient refresh error."""
|
|
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
|
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == "20.0"
|
|
|
|
mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [
|
|
DucoError("Temporary bypass target failure"),
|
|
mock_bypass_supply_temperature_targets.copy(),
|
|
]
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == "20.0"
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("exception", "match"),
|
|
[
|
|
pytest.param(
|
|
DucoError("Unexpected error"),
|
|
"Failed to set bypass supply target temperature",
|
|
id="duco_error",
|
|
),
|
|
pytest.param(DucoRateLimitError(), "daily write limit", id="rate_limit"),
|
|
],
|
|
)
|
|
async def test_set_bypass_supply_temperature_target_error(
|
|
hass: HomeAssistant,
|
|
mock_duco_client: AsyncMock,
|
|
exception: Exception,
|
|
match: str,
|
|
) -> None:
|
|
"""Test write failures raise translated Home Assistant errors."""
|
|
mock_duco_client.async_set_bypass_supply_temperature_target.side_effect = exception
|
|
|
|
with pytest.raises(HomeAssistantError, match=match):
|
|
await hass.services.async_call(
|
|
NUMBER_DOMAIN,
|
|
SERVICE_SET_VALUE,
|
|
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5},
|
|
blocking=True,
|
|
)
|