Files
linux-voice-assistant/tests/unit/test_peripheral_api.py
T
Omar Amin b0c53c41c1 Add unit tests for PeripheralAPIServer (#413)
Adds unit tests for the PeripheralAPIServer, covering initialization, state management, command dispatching, and event emission.
2026-08-26 03:54:40 +03:00

916 lines
32 KiB
Python

"""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