mirror of
https://github.com/home-assistant/core.git
synced 2026-07-01 03:36:05 +01:00
0adc26cec8
Co-authored-by: Erwin Douna <e.douna@gmail.com>
340 lines
10 KiB
Python
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
|