"""Tests for the Android TV Remote remote platform.""" from unittest.mock import MagicMock, call from androidtvremote2 import ConnectionClosed import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from tests.common import MockConfigEntry REMOTE_ENTITY = "remote.my_android_tv" async def test_remote_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote receives push updates and state is updated.""" new_options = {"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}} mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry(mock_config_entry, options=new_options) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED mock_api._on_is_on_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF) mock_api._on_is_on_updated(True) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) mock_api._on_current_app_updated("activity1") assert ( hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" ) mock_api._on_current_app_updated("com.google.android.youtube.tv") assert ( hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "YouTube" ) mock_api._on_is_available_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) mock_api._on_is_available_updated(True) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) async def test_remote_toggles( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote toggles.""" new_options = {"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}} mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry(mock_config_entry, options=new_options) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "turn_off", {"entity_id": REMOTE_ENTITY}, blocking=True, ) mock_api._on_is_on_updated(False) mock_api.send_key_command.assert_called_with("POWER", "SHORT") await hass.services.async_call( "remote", "turn_on", {"entity_id": REMOTE_ENTITY}, blocking=True, ) mock_api._on_is_on_updated(True) mock_api.send_key_command.assert_called_with("POWER", "SHORT") assert mock_api.send_key_command.call_count == 2 await hass.services.async_call( "remote", "turn_on", {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, blocking=True, ) mock_api.send_key_command.send_launch_app_command("activity1") assert mock_api.send_key_command.call_count == 2 assert mock_api.send_launch_app_command.call_count == 1 await hass.services.async_call( "remote", "turn_on", {"entity_id": REMOTE_ENTITY, "activity": "YouTube"}, blocking=True, ) mock_api.send_key_command.send_launch_app_command("com.google.android.youtube.tv") assert mock_api.send_key_command.call_count == 2 assert mock_api.send_launch_app_command.call_count == 2 async def test_remote_send_command( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test remote.send_command service.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "DPAD_LEFT", "num_repeats": 2, "delay_secs": 0.01, }, blocking=True, ) mock_api.send_key_command.assert_called_with("DPAD_LEFT", "SHORT") assert mock_api.send_key_command.call_count == 2 async def test_remote_send_command_multiple( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test remote.send_command service with multiple commands.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": ["DPAD_LEFT", "DPAD_UP"], "delay_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [ call("DPAD_LEFT", "SHORT"), call("DPAD_UP", "SHORT"), ] async def test_remote_send_command_with_hold_secs( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test remote.send_command service with hold_secs.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "DPAD_RIGHT", "delay_secs": 0.01, "hold_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [ call("DPAD_RIGHT", "START_LONG"), call("DPAD_RIGHT", "END_LONG"), ] @pytest.mark.parametrize( ("command", "expected_call"), [ ("start_long:DPAD_DOWN", call("DPAD_DOWN", "START_LONG")), ("end_long:DPAD_DOWN", call("DPAD_DOWN", "END_LONG")), ("short:DPAD_DOWN", call("DPAD_DOWN", "SHORT")), ("START_LONG:DPAD_DOWN", call("DPAD_DOWN", "START_LONG")), ], ids=["start", "end", "short", "uppercase_prefix"], ) async def test_remote_send_command_with_direction_prefix( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock, command: str, expected_call: object, ) -> None: """Test remote.send_command emits a single directional event for prefixed commands.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": command, "delay_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [expected_call] @pytest.mark.parametrize( "command", [ "text:hello world", "voice:something", "DPAD_DOWN:WITH_COLON", ":leading_colon", ], ids=["text_prefix", "unknown_prefix", "embedded_colon", "leading_colon"], ) async def test_remote_send_command_unknown_prefix_passes_through( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock, command: str, ) -> None: """Test that commands with non-direction colon prefixes are forwarded verbatim. The integration only strips prefixes that match the allowlist; other colon-using conventions (notably the lib's own ``text:`` prefix for keyboard text) must reach the underlying library unchanged so it can apply its own routing. """ mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": command, "delay_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [call(command, "SHORT")] async def test_remote_send_command_direction_prefix_pair( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test that a press-down/release-up pair produces exactly two events. This is the live-press scenario: a UI sends START_LONG on pointerdown and END_LONG on pointerup as separate service calls. Together they must produce no extra SHORT or sleep-driven events. """ mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "start_long:DPAD_CENTER", "delay_secs": 0.01, }, blocking=True, ) await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "end_long:DPAD_CENTER", "delay_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [ call("DPAD_CENTER", "START_LONG"), call("DPAD_CENTER", "END_LONG"), ] async def test_remote_send_command_direction_prefix_with_hold_secs_raises( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test that combining a direction prefix with hold_secs raises.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED with pytest.raises( ServiceValidationError, match='Command "start_long:DPAD_RIGHT" combines a direction prefix with hold_secs', ): await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "start_long:DPAD_RIGHT", "delay_secs": 0.01, "hold_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [] async def test_remote_send_command_empty_key_code_raises( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test that a direction prefix without a key code raises.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED with pytest.raises( ServiceValidationError, match='Command "SHORT:" is missing a key code after the direction prefix', ): await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "SHORT:", "delay_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [] async def test_remote_connection_closed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test commands raise HomeAssistantError if ConnectionClosed.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() with pytest.raises( HomeAssistantError, match="Connection to the Android TV device is closed" ): await hass.services.async_call( "remote", "send_command", { "entity_id": REMOTE_ENTITY, "command": "DPAD_LEFT", "delay_secs": 0.01, }, blocking=True, ) assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] mock_api.send_launch_app_command.side_effect = ConnectionClosed() with pytest.raises( HomeAssistantError, match="Connection to the Android TV device is closed" ): await hass.services.async_call( "remote", "turn_on", {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, blocking=True, ) assert mock_api.send_launch_app_command.mock_calls == [call("activity1")]