"""Unit tests for the peripheral WebSocket API (PeripheralAPIServer).""" import asyncio import json from typing import Any, Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest from linux_voice_assistant.peripheral_api import LVACommand, LVAEvent, PeripheralAPIServer from tests.unit.conftest import make_state # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_server(state=None) -> PeripheralAPIServer: """Return a PeripheralAPIServer, optionally attached to a ServerState.""" server = PeripheralAPIServer() if state is not None: server.set_state(state) return server def make_media_entity(key: int = 1, volume: float = 0.5, muted: bool = False): entity = MagicMock() entity.key = key entity.volume = volume entity.muted = muted entity.previous_volume = volume return entity async def dispatch(server: PeripheralAPIServer, command: str, data: Optional[dict] = None) -> None: payload: Dict[str, Any] = {"command": command} if data is not None: payload["data"] = data await server._dispatch_command(json.dumps(payload)) # pylint: disable=protected-access # --------------------------------------------------------------------------- # __init__ / set_state # --------------------------------------------------------------------------- class TestInit: def test_default_host_and_port(self): server = PeripheralAPIServer() assert server._host == "0.0.0.0" assert server._port == 6055 def test_custom_host_and_port(self): server = PeripheralAPIServer(host="127.0.0.1", port=9999) assert server._host == "127.0.0.1" assert server._port == 9999 def test_default_volume_step(self): server = PeripheralAPIServer() assert server._volume_step == PeripheralAPIServer.DEFAULT_VOLUME_STEP def test_custom_volume_step(self): server = PeripheralAPIServer(volume_step=0.1) assert server._volume_step == pytest.approx(0.1) def test_no_clients_on_init(self): server = PeripheralAPIServer() assert server._clients == set() def test_no_state_on_init(self): server = PeripheralAPIServer() assert server._state is None def test_no_current_state_on_init(self): server = PeripheralAPIServer() assert server._current_state is None class TestSetState: def test_attaches_state(self, tmp_path): state = make_state(tmp_path) server = make_server() server.set_state(state) assert server._state is state # --------------------------------------------------------------------------- # start() / stop() # --------------------------------------------------------------------------- class TestStartStop: @pytest.mark.asyncio async def test_start_creates_server(self): server = make_server() fake_server = MagicMock() with patch("websockets.server.serve", AsyncMock(return_value=fake_server)) as mock_serve: await server.start() mock_serve.assert_called_once() assert server._server is fake_server @pytest.mark.asyncio async def test_start_uses_configured_host_and_port(self): server = make_server() server._host = "127.0.0.1" server._port = 1234 with patch("websockets.server.serve", AsyncMock(return_value=MagicMock())) as mock_serve: await server.start() _, args, kwargs = mock_serve.mock_calls[0] assert "127.0.0.1" in args or kwargs.get("host") == "127.0.0.1" @pytest.mark.asyncio async def test_start_sets_loop(self): server = make_server() with patch("websockets.server.serve", AsyncMock(return_value=MagicMock())): await server.start() assert server._loop is asyncio.get_running_loop() @pytest.mark.asyncio async def test_stop_closes_server(self): server = make_server() fake_server = MagicMock() fake_server.wait_closed = AsyncMock() server._server = fake_server await server.stop() fake_server.close.assert_called_once() fake_server.wait_closed.assert_awaited_once() @pytest.mark.asyncio async def test_stop_without_server_does_not_raise(self): server = make_server() await server.stop() # should not raise @pytest.mark.asyncio async def test_stop_cancels_pending_reconnect_task(self): server = make_server() pending_task = MagicMock() server._pending_entity_reconnect_task = pending_task await server.stop() pending_task.cancel.assert_called_once() assert server._pending_entity_reconnect_task is None # --------------------------------------------------------------------------- # _send_snapshot() # --------------------------------------------------------------------------- class TestSendSnapshot: @pytest.mark.asyncio async def test_no_state_does_nothing(self): server = make_server() websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access websocket.send.assert_not_called() @pytest.mark.asyncio async def test_sends_snapshot_payload(self, tmp_path): state = make_state(tmp_path, volume=0.75, muted=False, connected=True) server = make_server(state) websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access sent = json.loads(websocket.send.call_args_list[0].args[0]) assert sent["event"] == "snapshot" assert sent["data"]["volume"] == pytest.approx(0.75) assert sent["data"]["muted"] is False assert sent["data"]["ha_connected"] is True @pytest.mark.asyncio async def test_snapshot_includes_last_conversation_text(self, tmp_path): state = make_state(tmp_path) server = make_server(state) server._last_stt_text = "turn on the lights" # pylint: disable=protected-access server._last_tts_text = "OK" # pylint: disable=protected-access websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access sent = json.loads(websocket.send.call_args_list[0].args[0]) assert sent["data"]["last_stt_text"] == "turn on the lights" assert sent["data"]["last_tts_text"] == "OK" @pytest.mark.asyncio async def test_no_current_state_skips_replay(self, tmp_path): state = make_state(tmp_path) server = make_server(state) websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access assert websocket.send.await_count == 1 @pytest.mark.asyncio async def test_replays_current_state(self, tmp_path): state = make_state(tmp_path) server = make_server(state) server._current_state = LVAEvent.THINKING # pylint: disable=protected-access websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access assert websocket.send.await_count == 2 replayed = json.loads(websocket.send.call_args_list[1].args[0]) assert replayed["event"] == "thinking" @pytest.mark.asyncio async def test_replays_current_state_with_data(self, tmp_path): state = make_state(tmp_path) server = make_server(state) server._current_state = LVAEvent.TIMER_TICKING # pylint: disable=protected-access server._current_state_data = {"id": "1", "seconds_left": 30} # pylint: disable=protected-access websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access replayed = json.loads(websocket.send.call_args_list[1].args[0]) assert replayed["data"]["seconds_left"] == 30 @pytest.mark.asyncio async def test_skips_replay_of_disconnected_when_connected(self, tmp_path): state = make_state(tmp_path, connected=True) server = make_server(state) server._current_state = LVAEvent.DISCONNECTED # pylint: disable=protected-access websocket = AsyncMock() await server._send_snapshot(websocket) # pylint: disable=protected-access # Only the snapshot itself should have been sent assert websocket.send.await_count == 1 # --------------------------------------------------------------------------- # _dispatch_command() — basic parsing # --------------------------------------------------------------------------- class TestDispatchCommandParsing: @pytest.mark.asyncio async def test_invalid_json_does_not_raise(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await server._dispatch_command("not json") # pylint: disable=protected-access @pytest.mark.asyncio async def test_missing_command_is_noop(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await server._dispatch_command(json.dumps({})) # pylint: disable=protected-access @pytest.mark.asyncio async def test_no_state_attached_is_noop(self): server = make_server() await dispatch(server, LVACommand.START_LISTENING.value) # should not raise # --------------------------------------------------------------------------- # _dispatch_command() — voice pipeline # --------------------------------------------------------------------------- class TestDispatchStartListening: @pytest.mark.asyncio async def test_calls_satellite_start_listening(self, tmp_path): state = make_state(tmp_path, muted=False) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.START_LISTENING.value) satellite.start_listening.assert_called_once() @pytest.mark.asyncio async def test_noop_when_muted(self, tmp_path): state = make_state(tmp_path, muted=True) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.START_LISTENING.value) satellite.start_listening.assert_not_called() @pytest.mark.asyncio async def test_noop_when_no_satellite(self, tmp_path): state = make_state(tmp_path, muted=False) state.satellite = None server = make_server(state) await dispatch(server, LVACommand.START_LISTENING.value) # should not raise class TestDispatchStopPipeline: @pytest.mark.asyncio async def test_calls_satellite_stop(self, tmp_path): state = make_state(tmp_path) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.STOP_PIPELINE.value) satellite.stop.assert_called_once() @pytest.mark.asyncio async def test_noop_when_no_satellite(self, tmp_path): state = make_state(tmp_path) state.satellite = None server = make_server(state) await dispatch(server, LVACommand.STOP_PIPELINE.value) # should not raise # --------------------------------------------------------------------------- # _dispatch_command() — microphone mute # --------------------------------------------------------------------------- class TestDispatchMute: @pytest.mark.asyncio async def test_mute_mic_calls_set_muted(self, tmp_path): state = make_state(tmp_path, muted=False) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.MUTE_MIC.value) satellite._set_muted.assert_called_once_with(True) # pylint: disable=protected-access @pytest.mark.asyncio async def test_mute_mic_noop_when_already_muted(self, tmp_path): state = make_state(tmp_path, muted=True) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.MUTE_MIC.value) satellite._set_muted.assert_not_called() # pylint: disable=protected-access @pytest.mark.asyncio async def test_unmute_mic_calls_set_muted(self, tmp_path): state = make_state(tmp_path, muted=True) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.UNMUTE_MIC.value) satellite._set_muted.assert_called_once_with(False) # pylint: disable=protected-access @pytest.mark.asyncio async def test_unmute_mic_noop_when_already_unmuted(self, tmp_path): state = make_state(tmp_path, muted=False) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.UNMUTE_MIC.value) satellite._set_muted.assert_not_called() # pylint: disable=protected-access @pytest.mark.asyncio async def test_mute_mic_pushes_switch_state_to_ha(self, tmp_path): state = make_state(tmp_path, muted=False) state.mute_switch_entity = MagicMock(key=1) satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.MUTE_MIC.value) satellite.send_messages.assert_called_once() # --------------------------------------------------------------------------- # _dispatch_command() — volume # --------------------------------------------------------------------------- class TestDispatchVolume: @pytest.mark.asyncio async def test_volume_up_increases_volume(self, tmp_path): state = make_state(tmp_path, volume=0.5) server = make_server(state) await dispatch(server, LVACommand.VOLUME_UP.value) assert state.volume == pytest.approx(0.5 + PeripheralAPIServer.DEFAULT_VOLUME_STEP) @pytest.mark.asyncio async def test_volume_down_decreases_volume(self, tmp_path): state = make_state(tmp_path, volume=0.5) server = make_server(state) await dispatch(server, LVACommand.VOLUME_DOWN.value) assert state.volume == pytest.approx(0.5 - PeripheralAPIServer.DEFAULT_VOLUME_STEP) @pytest.mark.asyncio async def test_volume_up_clamped_at_one(self, tmp_path): state = make_state(tmp_path, volume=1.0) server = make_server(state) await dispatch(server, LVACommand.VOLUME_UP.value) assert state.volume == pytest.approx(1.0) @pytest.mark.asyncio async def test_volume_down_clamped_at_zero(self, tmp_path): state = make_state(tmp_path, volume=0.0) server = make_server(state) await dispatch(server, LVACommand.VOLUME_DOWN.value) assert state.volume == pytest.approx(0.0) @pytest.mark.asyncio async def test_volume_up_updates_music_and_tts_players(self, tmp_path): state = make_state(tmp_path, volume=0.5) server = make_server(state) await dispatch(server, LVACommand.VOLUME_UP.value) state.music_player.set_volume.assert_called_once() state.tts_player.set_volume.assert_called_once() @pytest.mark.asyncio async def test_volume_up_pushes_media_player_state(self, tmp_path): state = make_state(tmp_path, volume=0.5) state.media_player_entity = make_media_entity() satellite = MagicMock() state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.VOLUME_UP.value) satellite.send_messages.assert_called_once() @pytest.mark.asyncio async def test_set_volume_sets_exact_value(self, tmp_path): state = make_state(tmp_path, volume=0.2) server = make_server(state) await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": 0.9}) assert state.volume == pytest.approx(0.9) @pytest.mark.asyncio async def test_set_volume_clamps_above_one(self, tmp_path): state = make_state(tmp_path, volume=0.2) server = make_server(state) await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": 5.0}) assert state.volume == pytest.approx(1.0) @pytest.mark.asyncio async def test_set_volume_clamps_below_zero(self, tmp_path): state = make_state(tmp_path, volume=0.2) server = make_server(state) await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": -5.0}) assert state.volume == pytest.approx(0.0) @pytest.mark.asyncio async def test_set_volume_ignores_non_numeric(self, tmp_path): state = make_state(tmp_path, volume=0.2) server = make_server(state) await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": "loud"}) assert state.volume == pytest.approx(0.2) @pytest.mark.asyncio async def test_set_volume_accepts_int(self, tmp_path): state = make_state(tmp_path, volume=0.2) server = make_server(state) await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": 1}) assert state.volume == pytest.approx(1.0) # --------------------------------------------------------------------------- # _dispatch_command() — timer # --------------------------------------------------------------------------- class TestDispatchStopTimerRinging: @pytest.mark.asyncio async def test_stops_ringing_timer(self, tmp_path): state = make_state(tmp_path) satellite = MagicMock() satellite._timer_finished = True # pylint: disable=protected-access state.satellite = satellite state.active_wake_words.add(state.stop_word.id) server = make_server(state) await dispatch(server, LVACommand.STOP_TIMER_RINGING.value) assert satellite._timer_finished is False # pylint: disable=protected-access assert state.stop_word.id not in state.active_wake_words state.tts_player.stop.assert_called_once() satellite.unduck.assert_called_once() @pytest.mark.asyncio async def test_noop_when_not_ringing(self, tmp_path): state = make_state(tmp_path) satellite = MagicMock() satellite._timer_finished = False # pylint: disable=protected-access state.satellite = satellite server = make_server(state) await dispatch(server, LVACommand.STOP_TIMER_RINGING.value) state.tts_player.stop.assert_not_called() @pytest.mark.asyncio async def test_noop_when_no_satellite(self, tmp_path): state = make_state(tmp_path) state.satellite = None server = make_server(state) await dispatch(server, LVACommand.STOP_TIMER_RINGING.value) # should not raise # --------------------------------------------------------------------------- # _dispatch_command() — media player # --------------------------------------------------------------------------- class TestDispatchMediaPlayer: @pytest.mark.asyncio async def test_stop_media_player_stops_music(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await dispatch(server, LVACommand.STOP_MEDIA_PLAYER.value) state.music_player.stop.assert_called_once() @pytest.mark.asyncio async def test_pause_media_player_pauses_music(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await dispatch(server, LVACommand.PAUSE_MEDIA_PLAYER.value) state.music_player.pause.assert_called_once() @pytest.mark.asyncio async def test_resume_media_player_resumes_music(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await dispatch(server, LVACommand.RESUME_MEDIA_PLAYER.value) state.music_player.resume.assert_called_once() @pytest.mark.asyncio async def test_stop_media_player_updates_entity_state(self, tmp_path): from aioesphomeapi.model import MediaPlayerState # pylint: disable=import-outside-toplevel state = make_state(tmp_path) state.media_player_entity = make_media_entity() server = make_server(state) await dispatch(server, LVACommand.STOP_MEDIA_PLAYER.value) assert state.media_player_entity.state == MediaPlayerState.IDLE # --------------------------------------------------------------------------- # _dispatch_command() — button events # --------------------------------------------------------------------------- class TestDispatchButtonPress: @pytest.mark.asyncio async def test_single_press_updates_entity(self, tmp_path): state = make_state(tmp_path) state.button_event_sensor_entity = MagicMock() server = make_server(state) await dispatch(server, LVACommand.BUTTON_SINGLE_PRESS.value) state.button_event_sensor_entity.update_state.assert_called_once_with("single_press") @pytest.mark.asyncio async def test_single_press_does_not_play_sound(self, tmp_path): state = make_state(tmp_path) state.button_event_sensor_entity = MagicMock() server = make_server(state) await dispatch(server, LVACommand.BUTTON_SINGLE_PRESS.value) state.tts_player.play.assert_not_called() @pytest.mark.asyncio async def test_double_press_plays_sound_and_updates_entity(self, tmp_path): state = make_state(tmp_path) state.button_event_sensor_entity = MagicMock() server = make_server(state) await dispatch(server, LVACommand.BUTTON_DOUBLE_PRESS.value) state.tts_player.play.assert_called_once_with(state.button_double_press_sound) state.button_event_sensor_entity.update_state.assert_called_once_with("double_press") @pytest.mark.asyncio async def test_triple_press_plays_sound_and_updates_entity(self, tmp_path): state = make_state(tmp_path) state.button_event_sensor_entity = MagicMock() server = make_server(state) await dispatch(server, LVACommand.BUTTON_TRIPLE_PRESS.value) state.tts_player.play.assert_called_once_with(state.button_triple_press_sound) state.button_event_sensor_entity.update_state.assert_called_once_with("triple_press") @pytest.mark.asyncio async def test_long_press_plays_sound_and_updates_entity(self, tmp_path): state = make_state(tmp_path) state.button_event_sensor_entity = MagicMock() server = make_server(state) await dispatch(server, LVACommand.BUTTON_LONG_PRESS.value) state.tts_player.play.assert_called_once_with(state.button_long_press_sound) state.button_event_sensor_entity.update_state.assert_called_once_with("long_press") @pytest.mark.asyncio async def test_button_press_noop_when_entity_not_registered(self, tmp_path): state = make_state(tmp_path) state.button_event_sensor_entity = None server = make_server(state) await dispatch(server, LVACommand.BUTTON_SINGLE_PRESS.value) # should not raise # --------------------------------------------------------------------------- # register_light() / register_button() # --------------------------------------------------------------------------- class TestRegisterLight: def test_missing_object_id_is_ignored(self, tmp_path): state = make_state(tmp_path) server = make_server(state) server._register_light({}, None) # pylint: disable=protected-access assert state.pending_lights == [] def test_adds_light_registration(self, tmp_path): state = make_state(tmp_path) server = make_server(state) server._register_light( # pylint: disable=protected-access {"name": "LEDs", "object_id": "leds", "effects": ["Voice Assistant"]}, None, ) assert len(state.pending_lights) == 1 assert state.pending_lights[0].object_id == "leds" def test_duplicate_object_id_is_ignored(self, tmp_path): state = make_state(tmp_path) server = make_server(state) data = {"name": "LEDs", "object_id": "leds"} server._register_light(data, None) # pylint: disable=protected-access server._register_light(data, None) # pylint: disable=protected-access assert len(state.pending_lights) == 1 def test_calls_register_pending_lights_on_satellite(self, tmp_path): state = make_state(tmp_path) server = make_server(state) satellite = MagicMock() server._register_light({"object_id": "leds"}, satellite) # pylint: disable=protected-access satellite.register_pending_lights.assert_called_once() def test_no_state_is_noop(self): server = make_server() server._register_light({"object_id": "leds"}, None) # pylint: disable=protected-access # should not raise class TestRegisterButton: def test_sets_pending_button(self, tmp_path): state = make_state(tmp_path) server = make_server(state) server._register_button(None) # pylint: disable=protected-access assert state.pending_button is True def test_idempotent_when_already_registered(self, tmp_path): state = make_state(tmp_path) state.pending_button = True server = make_server(state) satellite = MagicMock() server._register_button(satellite) # pylint: disable=protected-access satellite.register_pending_button.assert_not_called() def test_calls_register_pending_button_on_satellite(self, tmp_path): state = make_state(tmp_path) server = make_server(state) satellite = MagicMock() server._register_button(satellite) # pylint: disable=protected-access satellite.register_pending_button.assert_called_once() def test_no_state_is_noop(self): server = make_server() server._register_button(None) # pylint: disable=protected-access # should not raise # --------------------------------------------------------------------------- # _dispatch_command() — register_light / register_button routing # --------------------------------------------------------------------------- class TestDispatchRegistration: @pytest.mark.asyncio async def test_register_light_command_routes_correctly(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await dispatch(server, LVACommand.REGISTER_LIGHT.value, {"object_id": "leds"}) assert len(state.pending_lights) == 1 @pytest.mark.asyncio async def test_register_button_command_routes_correctly(self, tmp_path): state = make_state(tmp_path) server = make_server(state) await dispatch(server, LVACommand.REGISTER_BUTTON.value) assert state.pending_button is True # --------------------------------------------------------------------------- # emit_event() # --------------------------------------------------------------------------- class TestEmitEvent: @pytest.mark.asyncio async def test_broadcasts_to_all_clients(self): server = make_server() ws1, ws2 = AsyncMock(), AsyncMock() server._clients = {ws1, ws2} # pylint: disable=protected-access await server.emit_event(LVAEvent.IDLE) ws1.send.assert_awaited_once() ws2.send.assert_awaited_once() @pytest.mark.asyncio async def test_no_clients_does_not_raise(self): server = make_server() await server.emit_event(LVAEvent.IDLE) # should not raise @pytest.mark.asyncio async def test_payload_includes_data(self): server = make_server() ws = AsyncMock() server._clients = {ws} # pylint: disable=protected-access await server.emit_event(LVAEvent.STT_TEXT, {"text": "hello"}) sent = json.loads(ws.send.call_args.args[0]) assert sent["data"]["text"] == "hello" @pytest.mark.asyncio async def test_dead_client_is_removed(self): server = make_server() dead_ws = AsyncMock() dead_ws.send.side_effect = ConnectionError alive_ws = AsyncMock() server._clients = {dead_ws, alive_ws} # pylint: disable=protected-access await server.emit_event(LVAEvent.IDLE) assert dead_ws not in server._clients # pylint: disable=protected-access assert alive_ws in server._clients # pylint: disable=protected-access @pytest.mark.asyncio async def test_caches_last_stt_text(self): server = make_server() await server.emit_event(LVAEvent.STT_TEXT, {"text": "turn on the lights"}) assert server._last_stt_text == "turn on the lights" # pylint: disable=protected-access @pytest.mark.asyncio async def test_caches_last_tts_text(self): server = make_server() await server.emit_event(LVAEvent.TTS_TEXT, {"text": "OK"}) assert server._last_tts_text == "OK" # pylint: disable=protected-access @pytest.mark.asyncio async def test_listening_clears_cached_text(self): server = make_server() server._last_stt_text = "old" # pylint: disable=protected-access server._last_tts_text = "old" # pylint: disable=protected-access await server.emit_event(LVAEvent.LISTENING) assert server._last_stt_text is None # pylint: disable=protected-access assert server._last_tts_text is None # pylint: disable=protected-access @pytest.mark.asyncio async def test_tracks_current_state_for_state_events(self): server = make_server() await server.emit_event(LVAEvent.TTS_SPEAKING) assert server._current_state == LVAEvent.TTS_SPEAKING # pylint: disable=protected-access @pytest.mark.asyncio async def test_does_not_track_transient_events(self): server = make_server() await server.emit_event(LVAEvent.STT_TEXT, {"text": "hi"}) assert server._current_state is None # pylint: disable=protected-access @pytest.mark.asyncio async def test_timer_updated_refreshes_ticking_data(self): server = make_server() await server.emit_event(LVAEvent.TIMER_TICKING, {"seconds_left": 30}) await server.emit_event(LVAEvent.TIMER_UPDATED, {"seconds_left": 25}) assert server._current_state == LVAEvent.TIMER_TICKING # pylint: disable=protected-access assert server._current_state_data["seconds_left"] == 25 # pylint: disable=protected-access @pytest.mark.asyncio async def test_timer_updated_ignored_when_not_ticking(self): server = make_server() await server.emit_event(LVAEvent.IDLE) await server.emit_event(LVAEvent.TIMER_UPDATED, {"seconds_left": 25}) assert server._current_state == LVAEvent.IDLE # pylint: disable=protected-access # --------------------------------------------------------------------------- # emit_event_sync() # --------------------------------------------------------------------------- class TestEmitEventSync: def test_noop_when_no_loop(self): server = make_server() server.emit_event_sync(LVAEvent.IDLE) # should not raise def test_noop_when_loop_closed(self): server = make_server() loop = MagicMock() loop.is_closed.return_value = True server._loop = loop # pylint: disable=protected-access with patch("asyncio.run_coroutine_threadsafe") as mock_run: server.emit_event_sync(LVAEvent.IDLE) mock_run.assert_not_called() def test_schedules_coroutine_on_loop(self): server = make_server() loop = MagicMock() loop.is_closed.return_value = False server._loop = loop # pylint: disable=protected-access with patch("asyncio.run_coroutine_threadsafe") as mock_run: server.emit_event_sync(LVAEvent.IDLE, {"foo": "bar"}) mock_run.assert_called_once() assert mock_run.call_args.args[1] is loop # --------------------------------------------------------------------------- # _create_media_player_response() # --------------------------------------------------------------------------- class TestCreateMediaPlayerResponse: def test_returns_response_with_entity_fields(self, tmp_path): from aioesphomeapi.model import MediaPlayerState # pylint: disable=import-outside-toplevel state = make_state(tmp_path) state.media_player_entity = make_media_entity(key=3, volume=0.4, muted=True) server = make_server(state) response = server._create_media_player_response(MediaPlayerState.PLAYING) # pylint: disable=protected-access assert response.key == 3 assert response.volume == pytest.approx(0.4) assert response.muted is True assert response.state == MediaPlayerState.PLAYING