"""Tests for the Velux cover platform.""" from unittest.mock import AsyncMock import pytest from pyvlx.exception import PyVLXException from pyvlx.opening_device import ( Awning, DualRollerShutter, GarageDoor, Gate, RollerShutter, Window, ) from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, SERVICE_STOP_COVER_TILT, ) from homeassistant.components.velux import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import update_callback_entity from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform # Apply setup_integration fixture to all tests in this module pytestmark = pytest.mark.usefixtures("setup_integration") @pytest.fixture def platform() -> Platform: """Fixture to specify platform to test.""" return Platform.COVER @pytest.mark.parametrize("mock_pyvlx", ["mock_blind"], indirect=True) async def test_blind_entity_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Snapshot the entity and validate registry metadata.""" await snapshot_platform( hass, entity_registry, snapshot, mock_config_entry.entry_id, ) @pytest.mark.usefixtures("mock_cover_type") @pytest.mark.parametrize( "mock_cover_type", [Awning, DualRollerShutter, GarageDoor, Gate, RollerShutter, Window], indirect=True, ) @pytest.mark.parametrize( "mock_pyvlx", ["mock_cover_type"], indirect=True, ) async def test_cover_entity_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Snapshot the entity and validate entity metadata.""" await snapshot_platform( hass, entity_registry, snapshot, mock_config_entry.entry_id, ) async def test_cover_device_association( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test the cover entity device association.""" entity_entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert len(entity_entries) >= 1 for entry in entity_entries: assert entry.device_id is not None device_entry = device_registry.async_get(entry.device_id) assert device_entry is not None # For dual roller shutters, the unique_id is suffixed with "_upper" or "_lower", # so remove that suffix to get the domain_id for device registry lookup domain_id = entry.unique_id if entry.unique_id.endswith("_upper") or entry.unique_id.endswith("_lower"): domain_id = entry.unique_id.rsplit("_", 1)[0] assert (DOMAIN, domain_id) in device_entry.identifiers assert device_entry.via_device_id is not None via_device_entry = device_registry.async_get(device_entry.via_device_id) assert via_device_entry is not None assert ( DOMAIN, f"gateway_{mock_config_entry.entry_id}", ) in via_device_entry.identifiers async def test_cover_closed( hass: HomeAssistant, mock_window: AsyncMock, ) -> None: """Test the cover closed state.""" test_entity_id = "cover.test_window" # Initial state should be open state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_OPEN # Update mock window position to closed percentage mock_window.position.position_percent = 100 # Also directly set position to closed, so this test should # continue to be green after the lib is fixed mock_window.position.closed = True # Trigger entity state update via registered callback await update_callback_entity(hass, mock_window) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_CLOSED # Window command tests async def test_window_open_close_stop_services( hass: HomeAssistant, mock_window: AsyncMock ) -> None: """Verify open/close/stop services map to device calls with no wait.""" entity_id = "cover.test_window" await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) mock_window.open.assert_awaited_once_with(wait_for_completion=False) await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) mock_window.close.assert_awaited_once_with(wait_for_completion=False) await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True ) mock_window.stop.assert_awaited_once_with(wait_for_completion=False) async def test_window_set_cover_position_inversion( hass: HomeAssistant, mock_window: AsyncMock ) -> None: """HA position is inverted for device's Position.""" entity_id = "cover.test_window" # Call with position 30 (=70% for device) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 30}, blocking=True, ) # Expect device Position 70% args, kwargs = mock_window.set_position.await_args position_obj = args[0] assert position_obj.position_percent == 70 assert kwargs.get("wait_for_completion") is False async def test_window_current_position_and_opening_closing_states( hass: HomeAssistant, mock_window: AsyncMock ) -> None: """Validate current_position and opening/closing state transitions.""" entity_id = "cover.test_window" # device position 30 -> current_position 70 mock_window.position.position_percent = 30 await update_callback_entity(hass, mock_window) state = hass.states.get(entity_id) assert state is not None assert state.attributes.get("current_position") == 70 assert state.state == STATE_OPEN # Opening mock_window.is_opening = True mock_window.is_closing = False await update_callback_entity(hass, mock_window) state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OPENING # Closing mock_window.is_opening = False mock_window.is_closing = True await update_callback_entity(hass, mock_window) state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_CLOSING # Dual roller shutter command tests async def test_dual_roller_shutter_open_close_services( hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock ) -> None: """Verify open/close services map to device calls with correct part.""" dual_entity_id = "cover.test_dual_roller_shutter" upper_entity_id = "cover.test_dual_roller_shutter_upper_shutter" lower_entity_id = "cover.test_dual_roller_shutter_lower_shutter" # Open upper part await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": upper_entity_id}, blocking=True ) mock_dual_roller_shutter.open.assert_awaited_with( curtain="upper", wait_for_completion=False ) # Open lower part await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": lower_entity_id}, blocking=True ) mock_dual_roller_shutter.open.assert_awaited_with( curtain="lower", wait_for_completion=False ) # Open dual await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": dual_entity_id}, blocking=True ) mock_dual_roller_shutter.open.assert_awaited_with( curtain="dual", wait_for_completion=False ) # Close upper part await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": upper_entity_id}, blocking=True ) mock_dual_roller_shutter.close.assert_awaited_with( curtain="upper", wait_for_completion=False ) # Close lower part await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": lower_entity_id}, blocking=True ) mock_dual_roller_shutter.close.assert_awaited_with( curtain="lower", wait_for_completion=False ) # Close dual await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": dual_entity_id}, blocking=True ) mock_dual_roller_shutter.close.assert_awaited_with( curtain="dual", wait_for_completion=False ) async def test_dual_shutter_set_cover_position_inversion( hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock ) -> None: """HA position is inverted for device's Position.""" entity_id = "cover.test_dual_roller_shutter" # Call with position 30 (=70% for device) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 30}, blocking=True, ) # Expect device Position 70% args, kwargs = mock_dual_roller_shutter.set_position.await_args position_obj = args[0] assert position_obj.position_percent == 70 assert kwargs.get("wait_for_completion") is False assert kwargs.get("curtain") == "dual" entity_id = "cover.test_dual_roller_shutter_upper_shutter" # Call with position 30 (=70% for device) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 30}, blocking=True, ) # Expect device Position 70% args, kwargs = mock_dual_roller_shutter.set_position.await_args position_obj = args[0] assert position_obj.position_percent == 70 assert kwargs.get("wait_for_completion") is False assert kwargs.get("curtain") == "upper" entity_id = "cover.test_dual_roller_shutter_lower_shutter" # Call with position 30 (=70% for device) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 30}, blocking=True, ) # Expect device Position 70% args, kwargs = mock_dual_roller_shutter.set_position.await_args position_obj = args[0] assert position_obj.position_percent == 70 assert kwargs.get("wait_for_completion") is False assert kwargs.get("curtain") == "lower" async def test_dual_roller_shutter_position_tests( hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock ) -> None: """Validate current_position and open/closed state.""" entity_id_dual = "cover.test_dual_roller_shutter" entity_id_lower = "cover.test_dual_roller_shutter_lower_shutter" entity_id_upper = "cover.test_dual_roller_shutter_upper_shutter" # device position is inverted (100 - x) mock_dual_roller_shutter.position.position_percent = 29 mock_dual_roller_shutter.position_upper_curtain.position_percent = 28 mock_dual_roller_shutter.position_lower_curtain.position_percent = 27 await update_callback_entity(hass, mock_dual_roller_shutter) state = hass.states.get(entity_id_dual) assert state is not None assert state.attributes.get("current_position") == 71 assert state.state == STATE_OPEN state = hass.states.get(entity_id_upper) assert state is not None assert state.attributes.get("current_position") == 72 assert state.state == STATE_OPEN state = hass.states.get(entity_id_lower) assert state is not None assert state.attributes.get("current_position") == 73 assert state.state == STATE_OPEN mock_dual_roller_shutter.position.closed = True mock_dual_roller_shutter.position_upper_curtain.closed = True mock_dual_roller_shutter.position_lower_curtain.closed = True await update_callback_entity(hass, mock_dual_roller_shutter) state = hass.states.get(entity_id_dual) assert state is not None assert state.state == STATE_CLOSED state = hass.states.get(entity_id_upper) assert state is not None assert state.state == STATE_CLOSED state = hass.states.get(entity_id_lower) assert state is not None assert state.state == STATE_CLOSED # Blind command tests async def test_blind_open_close_stop_tilt_services( hass: HomeAssistant, mock_blind: AsyncMock ) -> None: """Verify tilt services map to orientation calls.""" entity_id = "cover.test_blind" await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) mock_blind.open_orientation.assert_awaited_once_with(wait_for_completion=False) await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) mock_blind.close_orientation.assert_awaited_once_with(wait_for_completion=False) await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) mock_blind.stop_orientation.assert_awaited_once_with(wait_for_completion=False) async def test_blind_set_cover_tilt_position_inversion( hass: HomeAssistant, mock_blind: AsyncMock ) -> None: """HA tilt position is inverted for device orientation.""" entity_id = "cover.test_blind" await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {"entity_id": entity_id, ATTR_TILT_POSITION: 25}, blocking=True, ) call = mock_blind.set_orientation.await_args orientation_obj = call.kwargs.get("orientation") assert orientation_obj is not None assert orientation_obj.position_percent == 75 assert call.kwargs.get("wait_for_completion") is False async def test_blind_current_tilt_position( hass: HomeAssistant, mock_blind: AsyncMock ) -> None: """Validate current_tilt_position attribute reflects inverted orientation.""" entity_id = "cover.test_blind" mock_blind.orientation.position_percent = 10 await update_callback_entity(hass, mock_blind) state = hass.states.get(entity_id) assert state is not None assert state.attributes.get("current_tilt_position") == 90 async def test_non_blind_has_no_tilt_position( hass: HomeAssistant, mock_window: AsyncMock ) -> None: """Non-blind covers should not expose current_tilt_position attribute.""" entity_id = "cover.test_window" await update_callback_entity(hass, mock_window) state = hass.states.get(entity_id) assert state is not None assert "current_tilt_position" not in state.attributes # Exception handling tests @pytest.mark.parametrize( "exception", [PyVLXException("PyVLX error"), OSError("OS error")], ) async def test_cover_command_exception_handling( hass: HomeAssistant, mock_window: AsyncMock, exception: Exception, ) -> None: """Test that exceptions from node commands are wrapped in HomeAssistantError.""" entity_id = "cover.test_window" # Make the close method raise an exception mock_window.close.side_effect = exception with pytest.raises( HomeAssistantError, match="Failed to communicate with Velux device", ): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True, )