"""Tests for the fan module.""" from contextlib import nullcontext from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ( ALL_DEVICE_NAMES, ENTITY_FAN, ENTITY_PEDESTAL_FAN, mock_devices_response, ) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker NoException = nullcontext() @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_fan_state( hass: HomeAssistant, snapshot: SnapshotAssertion, config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Check device registry devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) assert devices == snapshot(name="devices") # Check entity registry entities = [ entity for entity in er.async_entries_for_config_entry( entity_registry, config_entry.entry_id ) if entity.domain == FAN_DOMAIN ] assert entities == snapshot(name="entities") # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) @pytest.mark.parametrize( ("action", "command"), [ (SERVICE_TURN_ON, "pyvesync.devices.vesyncfan.VeSyncTowerFan.turn_on"), (SERVICE_TURN_OFF, "pyvesync.devices.vesyncfan.VeSyncTowerFan.turn_off"), ], ) async def test_turn_on_off_success( hass: HomeAssistant, fan_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test turn_on and turn_off method.""" mock_devices_response(aioclient_mock, "SmartTowerFan") with ( patch(command, new_callable=AsyncMock, return_value=True) as method_mock, ): with patch( "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" ) as update_mock: await hass.services.async_call( FAN_DOMAIN, action, {ATTR_ENTITY_ID: ENTITY_FAN}, blocking=True, ) await hass.async_block_till_done() method_mock.assert_called_once() update_mock.assert_called_once() @pytest.mark.parametrize( ("action", "command"), [ ( SERVICE_TURN_ON, "pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice.turn_on", ), ( SERVICE_TURN_OFF, "pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice.turn_off", ), ], ) async def test_turn_on_off_raises_error( hass: HomeAssistant, fan_config_entry: MockConfigEntry, action: str, command: str, ) -> None: """Test turn_on and turn_off raises errors when fails.""" # returns False indicating failure in which case raises HomeAssistantError. with ( patch( command, return_value=False, ) as method_mock, pytest.raises(HomeAssistantError), ): await hass.services.async_call( FAN_DOMAIN, action, {ATTR_ENTITY_ID: ENTITY_FAN}, blocking=True, ) await hass.async_block_till_done() method_mock.assert_called_once() @pytest.mark.parametrize( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) @pytest.mark.parametrize( ("preset_mode", "patch_target"), [ ("normal", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_normal_mode"), ( "sleep", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_sleep_mode", ), ("turbo", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_turbo_mode"), ("auto", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_auto_mode"), ], ) async def test_set_preset_mode( hass: HomeAssistant, fan_config_entry: MockConfigEntry, api_response: bool, expectation, preset_mode: str, patch_target: str, ) -> None: """Test set_preset_mode method via turn on for test coverage.""" # If VeSyncTowerFan.mode fails (returns False), then HomeAssistantError is raised with ( expectation, patch( patch_target, return_value=api_response, ) as method_mock, ): with patch( "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" ) as update_mock: await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PRESET_MODE: preset_mode}, blocking=True, ) await hass.async_block_till_done() method_mock.assert_called_once() update_mock.assert_called_once() async def test_out_of_range_fan_level( hass: HomeAssistant, config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, ) -> None: """Test that an out-of-range fan level produces an unknown percentage.""" mock_devices_response( aioclient_mock, "CoreBreeze 432S", details_override={"fanSpeedLevel": -1} ) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get(ENTITY_PEDESTAL_FAN) assert state is not None assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_PERCENTAGE] is None async def test_set_preset_mode_eco( hass: HomeAssistant, config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, ) -> None: """Test the eco preset is exposed, reflected, and sets the mode via set_mode.""" mock_devices_response( aioclient_mock, "CoreBreeze 432S", details_override={"workMode": "eco"} ) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get(ENTITY_PEDESTAL_FAN) assert state is not None assert "eco" in state.attributes[ATTR_PRESET_MODES] assert state.attributes[ATTR_PRESET_MODE] == "eco" with ( patch( "pyvesync.devices.vesyncfan.VeSyncPedestalFan.set_mode", new_callable=AsyncMock, return_value=True, ) as method_mock, patch( "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" ) as update_mock, ): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_PEDESTAL_FAN, ATTR_PRESET_MODE: "eco"}, blocking=True, ) await hass.async_block_till_done() method_mock.assert_awaited_once_with("eco") update_mock.assert_called_once() @pytest.mark.parametrize( ("action", "api_response", "expectation"), [ ("true", True, NoException), ("false", True, NoException), ("true", False, pytest.raises(HomeAssistantError)), ], ) async def test_pedestal_fan_oscillation( hass: HomeAssistant, pedestal_fan_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, action: str, api_response: bool, expectation, ) -> None: """Test oscillation on and off for pedestal fans. Pedestal fans (e.g. CoreBreeze 432S / LPF-R432S) expose vertical and horizontal oscillation as separate toggles. The HA oscillate switch should control both axes together. """ with ( expectation, patch( "pyvesync.devices.vesyncfan.VeSyncPedestalFan.toggle_vertical_oscillation", new_callable=AsyncMock, return_value=api_response, ) as vertical_mock, patch( "pyvesync.devices.vesyncfan.VeSyncPedestalFan." "toggle_horizontal_oscillation", new_callable=AsyncMock, return_value=api_response, ) as horizontal_mock, ): with patch( "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" ) as update_mock: await hass.services.async_call( FAN_DOMAIN, "oscillate", {ATTR_ENTITY_ID: ENTITY_PEDESTAL_FAN, "oscillating": action}, blocking=True, ) await hass.async_block_till_done() vertical_mock.assert_called_once() horizontal_mock.assert_called_once() update_mock.assert_called_once() @pytest.mark.parametrize( ("action", "command"), [ ("true", "pyvesync.devices.vesyncfan.VeSyncTowerFan.toggle_oscillation"), ("false", "pyvesync.devices.vesyncfan.VeSyncTowerFan.toggle_oscillation"), ], ) @pytest.mark.parametrize( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) async def test_oscillation_success( hass: HomeAssistant, fan_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, action: str, command: str, api_response: bool, expectation, ) -> None: """Test oscillation on and off.""" mock_devices_response(aioclient_mock, "SmartTowerFan") with ( expectation, patch( command, new_callable=AsyncMock, return_value=api_response ) as method_mock, ): with patch( "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" ) as update_mock: await hass.services.async_call( FAN_DOMAIN, "oscillate", {ATTR_ENTITY_ID: ENTITY_FAN, "oscillating": action}, blocking=True, ) await hass.async_block_till_done() method_mock.assert_called_once() update_mock.assert_called_once()