1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-01 03:36:05 +01:00
Files
core/tests/components/duco/test_select.py
T
Ronald van der Meer 0adc26cec8 Add ventilation state select to Duco box nodes (#173807)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-18 09:10:40 +02:00

340 lines
10 KiB
Python

"""Tests for the Duco select platform."""
from dataclasses import replace
from unittest.mock import AsyncMock
from duco_connectivity import (
ActionItem,
ActionValueType,
DucoConnectionError,
DucoError,
DucoRateLimitError,
KnownActionName,
Node,
NodeActionItemList,
NodeListActionItemList,
VentilationState,
)
import pytest
from homeassistant.components.select import (
ATTR_OPTION,
ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import setup_platform_integration
from tests.common import MockConfigEntry
_SELECT_ENTITY = "select.living_ventilation_state"
_UNSUPPORTED_SELECT_ENTITY = "select.office_co2_ventilation_state"
def _build_node_actions(
*,
node_id: int = 1,
options: list[str] | None = None,
) -> NodeListActionItemList:
"""Build node action discovery data for select tests."""
return NodeListActionItemList(
nodes=[
NodeActionItemList(
node_id=node_id,
actions=[
ActionItem(
action=KnownActionName.SET_VENTILATION_STATE,
val_type=ActionValueType.ENUM,
enum_values=[] if options is None else options,
)
],
)
]
)
def _build_multi_node_actions(
node_ids: list[int],
*,
options: list[str],
) -> NodeListActionItemList:
"""Build identical node action discovery data for multiple nodes."""
return NodeListActionItemList(
nodes=[
NodeActionItemList(
node_id=node_id,
actions=[
ActionItem(
action=KnownActionName.SET_VENTILATION_STATE,
val_type=ActionValueType.ENUM,
enum_values=options,
)
],
)
for node_id in node_ids
]
)
def _replace_node_state(node: Node, state: str | VentilationState | None) -> Node:
"""Return a copy of the node with an updated ventilation state."""
if state is None:
return replace(node, ventilation=None)
assert node.ventilation is not None
return replace(node, ventilation=replace(node.ventilation, state=state))
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> MockConfigEntry:
"""Set up only the select platform for testing."""
return await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
@pytest.mark.usefixtures("init_integration")
async def test_select_entity_created_with_dynamic_options(
hass: HomeAssistant,
) -> None:
"""Test that select entities are created only for nodes with usable actions."""
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.state == "AUTO"
assert state.attributes[ATTR_OPTIONS] == [
"AUTO",
"CNT1",
"CNT2",
"CNT3",
"MAN1",
"MAN2",
"MAN3",
]
assert hass.states.get(_UNSUPPORTED_SELECT_ENTITY) is None
async def test_select_ignores_non_box_nodes_even_when_actions_exist(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test select discovery ignores non-box nodes that expose the same action."""
mock_duco_client.async_get_node_actions.return_value = _build_multi_node_actions(
[1, 2, 50, 113],
options=["AUTO", "CNT1", "CNT2", "CNT3", "MAN1", "MAN2", "MAN3"],
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
assert hass.states.get(_SELECT_ENTITY) is not None
assert hass.states.get(_UNSUPPORTED_SELECT_ENTITY) is None
@pytest.mark.usefixtures("init_integration")
async def test_select_option_calls_ventilation_state_library_method(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
) -> None:
"""Test that selecting an option uses the typed ventilation state helper."""
mock_duco_client.async_set_ventilation_state = AsyncMock()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "CNT2"},
blocking=True,
)
mock_duco_client.async_set_ventilation_state.assert_called_once_with(1, "CNT2")
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("exception", "match"),
[
pytest.param(DucoError("Unexpected error"), "Failed to set ventilation state"),
pytest.param(DucoRateLimitError(), "daily write limit"),
],
)
async def test_select_option_error(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
exception: Exception,
match: str,
) -> None:
"""Test that a HomeAssistantError is raised on select write failure."""
mock_duco_client.async_set_ventilation_state = AsyncMock(side_effect=exception)
with pytest.raises(HomeAssistantError, match=match):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "CNT2"},
blocking=True,
)
async def test_select_extended_manual_options_allow_normalized_readback(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
) -> None:
"""Test extended manual actions can read back as the normalized manual state."""
mock_duco_client.async_get_node_actions.return_value = _build_node_actions(
options=["AUTO", "MAN1", "MAN1x2", "MAN1x3"]
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.attributes[ATTR_OPTIONS] == ["AUTO", "MAN1", "MAN1x2", "MAN1x3"]
box_node = mock_nodes[0]
mock_duco_client.async_set_ventilation_state = AsyncMock()
mock_duco_client.async_get_nodes.return_value = [
_replace_node_state(box_node, "MAN1"),
*mock_nodes[1:],
]
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "MAN1x2"},
blocking=True,
)
mock_duco_client.async_set_ventilation_state.assert_called_once_with(1, "MAN1x2")
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.state == "MAN1"
async def test_select_auto_option_allows_cnt1_readback(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
) -> None:
"""Test AUTO readback can normalize to CNT1 without treating it as an error."""
mock_duco_client.async_get_node_actions.return_value = _build_node_actions(
options=["AUTO", "CNT1", "CNT2"]
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
box_node = mock_nodes[0]
mock_duco_client.async_set_ventilation_state = AsyncMock()
mock_duco_client.async_get_nodes.return_value = [
_replace_node_state(box_node, "CNT1"),
*mock_nodes[1:],
]
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "AUTO"},
blocking=True,
)
mock_duco_client.async_set_ventilation_state.assert_called_once_with(1, "AUTO")
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.state == "CNT1"
async def test_select_entity_is_added_when_action_discovery_succeeds_later(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test select entities are added when action discovery becomes available later."""
mock_duco_client.async_get_node_actions.side_effect = [
DucoConnectionError("Connection refused"),
_build_node_actions(
options=["AUTO", "CNT1", "CNT2", "CNT3", "MAN1", "MAN2", "MAN3"]
),
]
config_entry = await setup_platform_integration(
hass, mock_config_entry, [Platform.SELECT]
)
assert hass.states.get(_SELECT_ENTITY) is None
await config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.attributes[ATTR_OPTIONS] == [
"AUTO",
"CNT1",
"CNT2",
"CNT3",
"MAN1",
"MAN2",
"MAN3",
]
@pytest.mark.parametrize(
"node_actions",
[
pytest.param(
NodeListActionItemList(nodes=[NodeActionItemList(node_id=1, actions=[])]),
id="missing-action",
),
pytest.param(
_build_node_actions(options=None),
id="missing-enum-values",
),
],
)
async def test_select_missing_action_metadata_does_not_crash(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
node_actions: NodeListActionItemList,
) -> None:
"""Test incomplete action discovery data does not create broken entities."""
mock_duco_client.async_get_node_actions.return_value = node_actions
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
state = hass.states.get(_SELECT_ENTITY)
assert state is None
@pytest.mark.parametrize(
"state",
[
pytest.param("SOMETHING_NEW", id="unknown-state"),
pytest.param(None, id="missing-ventilation"),
],
)
async def test_select_unknown_or_missing_current_state_does_not_crash(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
state: str | None,
) -> None:
"""Test missing or unknown current states stay safe in select properties."""
mock_duco_client.async_get_nodes.return_value = [
_replace_node_state(mock_nodes[0], state),
*mock_nodes[1:],
]
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
entity_state = hass.states.get(_SELECT_ENTITY)
assert entity_state is not None
assert entity_state.state == STATE_UNKNOWN