diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d84e055d0e..4a7c110946c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -649,6 +649,9 @@ colorthief==0.2.1 # homeassistant.components.compit compit-inext-api==0.3.1 +# homeassistant.components.concord232 +concord232==0.15.1 + # homeassistant.components.xiaomi_miio construct==2.10.68 diff --git a/tests/components/concord232/__init__.py b/tests/components/concord232/__init__.py new file mode 100644 index 00000000000..d1c77331b7d --- /dev/null +++ b/tests/components/concord232/__init__.py @@ -0,0 +1 @@ +"""Tests for the Concord232 integration.""" diff --git a/tests/components/concord232/conftest.py b/tests/components/concord232/conftest.py new file mode 100644 index 00000000000..604617bfc17 --- /dev/null +++ b/tests/components/concord232/conftest.py @@ -0,0 +1,33 @@ +"""Fixtures for the Concord232 integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_concord232_client() -> Generator[MagicMock]: + """Mock the concord232 Client for easier testing.""" + with ( + patch( + "homeassistant.components.concord232.alarm_control_panel.concord232_client.Client", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.concord232.binary_sensor.concord232_client.Client", + new=mock_client_class, + ), + ): + mock_instance = mock_client_class.return_value + + # Set up default return values + mock_instance.list_partitions.return_value = [{"arming_level": "Off"}] + mock_instance.list_zones.return_value = [ + {"number": 1, "name": "Zone 1", "state": "Normal"}, + {"number": 2, "name": "Zone 2", "state": "Normal"}, + ] + + yield mock_instance diff --git a/tests/components/concord232/test_alarm_control_panel.py b/tests/components/concord232/test_alarm_control_panel.py new file mode 100644 index 00000000000..7df2f372529 --- /dev/null +++ b/tests/components/concord232/test_alarm_control_panel.py @@ -0,0 +1,280 @@ +"""Tests for the Concord232 alarm control panel platform.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +import requests + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + AlarmControlPanelState, +) +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + CONF_CODE, + CONF_HOST, + CONF_MODE, + CONF_NAME, + CONF_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_fire_time_changed + +VALID_CONFIG = { + ALARM_DOMAIN: { + "platform": "concord232", + CONF_HOST: "localhost", + CONF_PORT: 5007, + CONF_NAME: "Test Alarm", + } +} + +VALID_CONFIG_WITH_CODE = { + ALARM_DOMAIN: { + "platform": "concord232", + CONF_HOST: "localhost", + CONF_PORT: 5007, + CONF_NAME: "Test Alarm", + CONF_CODE: "1234", + } +} + +VALID_CONFIG_SILENT_MODE = { + ALARM_DOMAIN: { + "platform": "concord232", + CONF_HOST: "localhost", + CONF_PORT: 5007, + CONF_NAME: "Test Alarm", + CONF_MODE: "silent", + } +} + + +async def test_setup_platform( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test platform setup.""" + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_alarm") + assert state is not None + assert state.state == AlarmControlPanelState.DISARMED + + +async def test_setup_platform_connection_error( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test platform setup with connection error.""" + mock_concord232_client.list_partitions.side_effect = ( + requests.exceptions.ConnectionError("Connection failed") + ) + + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.test_alarm") is None + + +async def test_alarm_disarm( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test disarm service.""" + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_alarm"}, + blocking=True, + ) + mock_concord232_client.disarm.assert_called_once_with(None) + + +async def test_alarm_disarm_with_code( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test disarm service with code.""" + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG_WITH_CODE) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_alarm", + ATTR_CODE: "1234", + }, + blocking=True, + ) + mock_concord232_client.disarm.assert_called_once_with("1234") + + +async def test_alarm_disarm_invalid_code( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test disarm service with invalid code.""" + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG_WITH_CODE) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_alarm", + ATTR_CODE: "9999", + }, + blocking=True, + ) + mock_concord232_client.disarm.assert_not_called() + assert "Invalid code given" in caplog.text + + +@pytest.mark.parametrize( + ("service", "expected_arm_call"), + [ + (SERVICE_ALARM_ARM_HOME, "stay"), + (SERVICE_ALARM_ARM_AWAY, "away"), + ], +) +async def test_alarm_arm( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + service: str, + expected_arm_call: str, +) -> None: + """Test arm service.""" + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG_WITH_CODE) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_alarm", + ATTR_CODE: "1234", + }, + blocking=True, + ) + mock_concord232_client.arm.assert_called_once_with(expected_arm_call) + + +async def test_alarm_arm_home_silent_mode( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test arm home service with silent mode.""" + config_with_code = VALID_CONFIG_SILENT_MODE.copy() + config_with_code[ALARM_DOMAIN][CONF_CODE] = "1234" + await async_setup_component(hass, ALARM_DOMAIN, config_with_code) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_ARM_HOME, + { + ATTR_ENTITY_ID: "alarm_control_panel.test_alarm", + ATTR_CODE: "1234", + }, + blocking=True, + ) + mock_concord232_client.arm.assert_called_once_with("stay", "silent") + + +async def test_update_state_disarmed( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test update when alarm is disarmed.""" + mock_concord232_client.list_partitions.return_value = [{"arming_level": "Off"}] + mock_concord232_client.list_zones.return_value = [ + {"number": 1, "name": "Zone 1", "state": "Normal"}, + ] + + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_alarm") + assert state.state == AlarmControlPanelState.DISARMED + + +@pytest.mark.parametrize( + ("arming_level", "expected_state"), + [ + ("Home", AlarmControlPanelState.ARMED_HOME), + ("Away", AlarmControlPanelState.ARMED_AWAY), + ], +) +async def test_update_state_armed( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + freezer: FrozenDateTimeFactory, + arming_level: str, + expected_state: str, +) -> None: + """Test update when alarm is armed.""" + mock_concord232_client.list_partitions.return_value = [ + {"arming_level": arming_level} + ] + mock_concord232_client.partitions = ( + mock_concord232_client.list_partitions.return_value + ) + mock_concord232_client.list_zones.return_value = [ + {"number": 1, "name": "Zone 1", "state": "Normal"}, + ] + + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + # Trigger update + freezer.tick(10) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_alarm") + assert state.state == expected_state + + +async def test_update_connection_error( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update with connection error.""" + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + mock_concord232_client.list_partitions.side_effect = ( + requests.exceptions.ConnectionError("Connection failed") + ) + + freezer.tick(10) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "Unable to connect to" in caplog.text + + +async def test_update_no_partitions( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update when no partitions are available.""" + mock_concord232_client.list_partitions.return_value = [] + + await async_setup_component(hass, ALARM_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + assert "Concord232 reports no partitions" in caplog.text diff --git a/tests/components/concord232/test_binary_sensor.py b/tests/components/concord232/test_binary_sensor.py new file mode 100644 index 00000000000..b13145ed317 --- /dev/null +++ b/tests/components/concord232/test_binary_sensor.py @@ -0,0 +1,201 @@ +"""Tests for the Concord232 binary sensor platform.""" + +from __future__ import annotations + +import datetime +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +import requests + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.concord232.binary_sensor import ( + CONF_EXCLUDE_ZONES, + CONF_ZONE_TYPES, +) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_fire_time_changed + +VALID_CONFIG = { + BINARY_SENSOR_DOMAIN: { + "platform": "concord232", + CONF_HOST: "localhost", + CONF_PORT: 5007, + } +} + +VALID_CONFIG_WITH_EXCLUDE = { + BINARY_SENSOR_DOMAIN: { + "platform": "concord232", + CONF_HOST: "localhost", + CONF_PORT: 5007, + CONF_EXCLUDE_ZONES: [2], + } +} + +VALID_CONFIG_WITH_ZONE_TYPES = { + BINARY_SENSOR_DOMAIN: { + "platform": "concord232", + CONF_HOST: "localhost", + CONF_PORT: 5007, + CONF_ZONE_TYPES: {1: "door", 2: "window"}, + } +} + + +async def test_setup_platform( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test platform setup.""" + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.zone_1") + state2 = hass.states.get("binary_sensor.zone_2") + assert state1 is not None + assert state2 is not None + assert state1.state == "off" + assert state2.state == "off" + + +async def test_setup_platform_connection_error( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test platform setup with connection error.""" + mock_concord232_client.list_zones.side_effect = requests.exceptions.ConnectionError( + "Connection failed" + ) + + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + assert "Unable to connect to Concord232" in caplog.text + assert hass.states.get("binary_sensor.zone_1") is None + + +async def test_setup_with_exclude_zones( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test platform setup with excluded zones.""" + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG_WITH_EXCLUDE) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.zone_1") + state2 = hass.states.get("binary_sensor.zone_2") + assert state1 is not None + assert state2 is None # Zone 2 should be excluded + + +async def test_setup_with_zone_types( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test platform setup with custom zone types.""" + await async_setup_component( + hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG_WITH_ZONE_TYPES + ) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.zone_1") + state2 = hass.states.get("binary_sensor.zone_2") + assert state1 is not None + assert state2 is not None + # Check device class is set correctly + assert state1.attributes.get("device_class") == BinarySensorDeviceClass.DOOR + assert state2.attributes.get("device_class") == BinarySensorDeviceClass.WINDOW + + +async def test_zone_state_faulted( + hass: HomeAssistant, mock_concord232_client: MagicMock +) -> None: + """Test zone state when faulted.""" + mock_concord232_client.list_zones.return_value = [ + {"number": 1, "name": "Zone 1", "state": "Faulted"}, + ] + mock_concord232_client.zones = mock_concord232_client.list_zones.return_value + + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.zone_1") + assert state.state == "on" # Faulted state means on (faulted) + + +@pytest.mark.freeze_time("2023-10-21") +async def test_zone_update_refresh( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that zone updates refresh the client data.""" + mock_concord232_client.list_zones.return_value = [ + {"number": 1, "name": "Zone 1", "state": "Normal"}, + ] + mock_concord232_client.zones = mock_concord232_client.list_zones.return_value + + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.zone_1").state == "off" + + # Update zone state - need to update both return_value and zones attribute + new_zones = [ + {"number": 1, "name": "Zone 1", "state": "Faulted"}, + ] + mock_concord232_client.list_zones.return_value = new_zones + mock_concord232_client.zones = new_zones + + freezer.tick(datetime.timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(datetime.timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.zone_1") + assert state.state == "on" + + +@pytest.mark.parametrize( + ("sensor_name", "entity_id", "expected_device_class"), + [ + ( + "MOTION Sensor", + "binary_sensor.motion_sensor", + BinarySensorDeviceClass.MOTION, + ), + ("SMOKE Sensor", "binary_sensor.smoke_sensor", BinarySensorDeviceClass.SMOKE), + ( + "Unknown Sensor", + "binary_sensor.unknown_sensor", + BinarySensorDeviceClass.OPENING, + ), + ], +) +async def test_device_class( + hass: HomeAssistant, + mock_concord232_client: MagicMock, + sensor_name: str, + entity_id: str, + expected_device_class: BinarySensorDeviceClass, +) -> None: + """Test zone type detection for motion sensor.""" + mock_concord232_client.list_zones.return_value = [ + {"number": 1, "name": sensor_name, "state": "Normal"}, + ] + mock_concord232_client.zones = mock_concord232_client.list_zones.return_value + + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get("device_class") == expected_device_class