Add Tests For LVA (#312)

* Add Tests
This commit is contained in:
aryanhasgithub authored and GitHub committed 2026-07-18 18:22:25 +02:00
1 parent 4a0695ab57
commit 9b2a8b4b30
24 files changed
+3002 -24

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Less Restrictions for tests
[MESSAGES CONTROL]
disable=
protected-access,
redefined-outer-name,
no-name-in-module,
wrong-import-order,
wrong-import-position,
attribute-defined-outside-init,
no-self-argument,
arguments-renamed,
reimported,
unspecified-encoding,
use-implicit-booleaness-not-comparison,
unused-variable,
consider-using-from-import,
superfluous-parens,
use-dict-literal,
missing-function-docstring,
missing-class-docstring,
import-outside-toplevel,
line-too-long,
unused-import,
too-many-return-statements,
missing-module-docstring,
too-few-public-methods,
too-many-instance-attributes
View File
Whitespace-only changes.
+2 -1
View File
@@ -1,6 +1,7 @@
# Automated testing
tbd
From the root of LVA run:-
```./script/tests```
# Manual testing
View File
Whitespace-only changes.
+108
View File
@@ -0,0 +1,108 @@
"""Shared test fixtures/helpers for the unit test suite."""
import tempfile
from dataclasses import MISSING, fields
from pathlib import Path
from queue import Queue
from unittest.mock import MagicMock, patch
def _auto_value_for(f):
"""Best-effort placeholder for a required (no-default) dataclass field."""
type_str = f.type if isinstance(f.type, str) else getattr(f.type, "__name__", "")
if type_str == "str":
return f"/mock/{f.name}"
if type_str == "Path":
return Path(f"/mock/{f.name}")
if type_str in ("float",):
return 0.0
if type_str in ("int",):
return 0
if type_str in ("bool",):
return False
if type_str.startswith("Set"):
return set()
if type_str.startswith(("List", "list")):
return []
if type_str.startswith(("Dict", "dict")):
return {}
# Fallback for anything else (players, queues, protobuf-ish types, etc.)
return MagicMock()
def make_state(tmp_path=None, **overrides):
"""Build a minimal ServerState, auto-filling any required fields
that aren't explicitly provided via defaults/overrides.
"""
from linux_voice_assistant.models import Preferences, ServerState
if tmp_path is None:
tmp_path = Path(tempfile.mkdtemp())
stop_word = MagicMock()
stop_word.id = "stop"
stop_word.is_active = False
values = {
"name": "lva-test",
"friendly_name": "LVA Test",
"mac_address": "aa:bb:cc:dd:ee:ff",
"ip_address": "192.168.1.1",
"network_interface": "eth0",
"version": "1.0.0",
"esphome_version": "42.0.0",
"audio_queue": Queue(),
"entities": [],
"available_wake_words": {},
"wake_words": {},
"active_wake_words": set(),
"stop_word": stop_word,
"music_player": MagicMock(),
"tts_player": MagicMock(),
"wakeup_sound": "/sounds/wake.flac",
"start_listening_sound": "/sounds/start_listening.flac",
"processing_sound": "/sounds/processing.wav",
"timer_finished_sound": "/sounds/timer.flac",
"mute_sound": "/sounds/mute.flac",
"unmute_sound": "/sounds/unmute.flac",
"button_double_press_sound": "/sounds/double_press.flac",
"button_triple_press_sound": "/sounds/triple_press.flac",
"button_long_press_sound": "/sounds/long_press.flac",
"preferences": Preferences(),
"preferences_path": tmp_path / "preferences.json",
"download_dir": tmp_path / "downloads",
"volume": 1.0,
"mic_volume": 100,
"mic_auto_gain": 0,
"mic_noise_suppression": 0,
}
values.update(overrides)
# Auto-fill any other required (no-default) field we didn't anticipate.
for f in fields(ServerState):
if f.name in values:
continue
no_default = f.default is MISSING and f.default_factory is MISSING # type: ignore[misc]
if no_default:
values[f.name] = _auto_value_for(f)
return ServerState(**values)
def make_satellite(tmp_path=None, state_overrides=None):
"""Build a VoiceSatelliteProtocol with heavy dependencies mocked."""
state = make_state(tmp_path, **(state_overrides or {}))
with (
patch("linux_voice_assistant.satellite.WakeWord1SensitivityNumberEntity", MagicMock()),
patch("linux_voice_assistant.satellite.WakeWord2SensitivityNumberEntity", MagicMock()),
patch("linux_voice_assistant.satellite.StopWordSensitivityNumberEntity", MagicMock()),
):
from linux_voice_assistant.satellite import VoiceSatelliteProtocol
satellite = VoiceSatelliteProtocol(state)
satellite._writelines = MagicMock()
satellite._loop = None
return satellite
+327
View File
@@ -0,0 +1,327 @@
"""Unit tests for APIServer packet parsing and message handling."""
from unittest.mock import MagicMock, patch
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
AuthenticationRequest,
AuthenticationResponse,
DisconnectRequest,
DisconnectResponse,
HelloRequest,
HelloResponse,
PingRequest,
PingResponse,
)
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_packet(msg) -> bytes:
"""Serialize a protobuf message into a plain-text ESPHome packet."""
msg_type = PROTO_TO_MESSAGE_TYPE[msg.__class__]
data = msg.SerializeToString()
packets = make_plain_text_packets([(msg_type, data)])
# make_plain_text_packets returns a list of memoryview/bytes — join into one
if isinstance(packets, (list, tuple)):
return b"".join(bytes(p) for p in packets)
return bytes(packets)
class ConcreteAPIServer:
"""Concrete subclass of APIServer for testing — records handled messages."""
def __init__(self):
from linux_voice_assistant.api_server import APIServer
class _Concrete(APIServer):
def __init__(self_inner):
super().__init__("test-server")
self_inner.handled = []
def handle_message(self_inner, msg):
self_inner.handled.append(msg)
return []
self._cls = _Concrete
self.instance = _Concrete()
@property
def server(self):
return self.instance
def make_server():
"""Return a connected APIServer instance with a mock transport."""
wrapper = ConcreteAPIServer()
server = wrapper.server
transport = MagicMock()
written = []
def capture_writelines(data):
# data may be a list of memoryview/bytes — flatten to a single bytes object
if isinstance(data, (list, tuple)):
written.append(b"".join(bytes(d) for d in data))
else:
written.append(bytes(data))
transport.writelines = capture_writelines
server._transport = transport
server._writelines = capture_writelines
server._written = written
return server
def get_sent_messages(server):
"""Decode all messages the server sent back via writelines."""
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
messages = []
for raw in server._written:
# raw is bytes — parse the plain-text framing manually
pos = 0
while pos < len(raw):
# preamble
if (raw[pos] if isinstance(raw[pos], int) else raw[pos][0]) != 0x00:
break
pos += 1
# length varuint
length = 0
bitpos = 0
while True:
b = raw[pos]
pos += 1
length |= (b & 0x7F) << bitpos
if not (b & 0x80):
break
bitpos += 7
# msg_type varuint
msg_type = 0
bitpos = 0
while True:
b = raw[pos]
pos += 1
msg_type |= (b & 0x7F) << bitpos
if not (b & 0x80):
break
bitpos += 7
# payload
payload = raw[pos : pos + length]
pos += length
msg_cls = MESSAGE_TYPE_TO_PROTO[msg_type]
messages.append(msg_cls.FromString(payload))
return messages
# ---------------------------------------------------------------------------
# connection_made / connection_lost
# ---------------------------------------------------------------------------
class TestConnection:
def test_connection_made_stores_transport(self):
from linux_voice_assistant.api_server import APIServer
class _Concrete(APIServer):
def __init__(self):
super().__init__("test")
def handle_message(self, msg):
return []
server = _Concrete()
transport = MagicMock()
transport.writelines = MagicMock()
loop = MagicMock()
with patch("linux_voice_assistant.api_server.asyncio.get_running_loop", return_value=loop):
server.connection_made(transport)
assert server._transport is transport
def test_connection_lost_clears_transport(self):
server = make_server()
server.connection_lost(None)
assert server._transport is None
assert server._writelines is None
def test_connection_lost_clears_loop(self):
server = make_server()
server._loop = MagicMock()
server.connection_lost(None)
assert server._loop is None
# ---------------------------------------------------------------------------
# HelloRequest → HelloResponse
# ---------------------------------------------------------------------------
class TestHelloHandshake:
def test_hello_request_yields_hello_response(self):
server = make_server()
server.data_received(make_packet(HelloRequest(client_info="test")))
msgs = get_sent_messages(server)
assert any(isinstance(m, HelloResponse) for m in msgs)
def test_hello_response_contains_server_name(self):
server = make_server()
server.data_received(make_packet(HelloRequest(client_info="test")))
msgs = get_sent_messages(server)
hello = next(m for m in msgs if isinstance(m, HelloResponse))
assert hello.name == "test-server"
def test_hello_response_has_api_version(self):
server = make_server()
server.data_received(make_packet(HelloRequest(client_info="test")))
msgs = get_sent_messages(server)
hello = next(m for m in msgs if isinstance(m, HelloResponse))
assert hello.api_version_major == 1
assert hello.api_version_minor == 10
def test_hello_does_not_call_handle_message(self):
server = make_server()
server.data_received(make_packet(HelloRequest(client_info="test")))
assert server.handled == []
# ---------------------------------------------------------------------------
# PingRequest → PingResponse
# ---------------------------------------------------------------------------
class TestPing:
def test_ping_request_yields_ping_response(self):
server = make_server()
server.data_received(make_packet(PingRequest()))
msgs = get_sent_messages(server)
assert any(isinstance(m, PingResponse) for m in msgs)
# ---------------------------------------------------------------------------
# DisconnectRequest → DisconnectResponse
# ---------------------------------------------------------------------------
class TestDisconnect:
def test_disconnect_request_yields_disconnect_response(self):
server = make_server()
server.data_received(make_packet(DisconnectRequest()))
msgs = get_sent_messages(server)
assert any(isinstance(m, DisconnectResponse) for m in msgs)
def test_disconnect_closes_transport(self):
server = make_server()
mock_transport = MagicMock()
server._transport = mock_transport
server.data_received(make_packet(DisconnectRequest()))
mock_transport.close.assert_called_once()
def test_disconnect_clears_transport_reference(self):
server = make_server()
server.data_received(make_packet(DisconnectRequest()))
assert server._transport is None
# ---------------------------------------------------------------------------
# AuthenticationRequest → AuthenticationResponse
# ---------------------------------------------------------------------------
class TestAuthentication:
def test_auth_request_yields_auth_response(self):
server = make_server()
server.data_received(make_packet(AuthenticationRequest()))
msgs = get_sent_messages(server)
assert any(isinstance(m, AuthenticationResponse) for m in msgs)
# ---------------------------------------------------------------------------
# Buffer management
# ---------------------------------------------------------------------------
class TestBufferManagement:
def test_buffer_is_none_after_complete_packet(self):
server = make_server()
server.data_received(make_packet(PingRequest()))
assert server._buffer is None
def test_partial_packet_stays_in_buffer(self):
server = make_server()
full = make_packet(PingRequest())
# Send only half the packet
server.data_received(full[: len(full) // 2])
assert server._buffer is not None
def test_split_packet_reassembled_correctly(self):
server = make_server()
full = make_packet(PingRequest())
half = len(full) // 2
server.data_received(full[:half])
server.data_received(full[half:])
msgs = get_sent_messages(server)
assert any(isinstance(m, PingResponse) for m in msgs)
def test_two_packets_in_one_data_received(self):
server = make_server()
data = make_packet(PingRequest()) + make_packet(PingRequest())
server.data_received(data)
msgs = get_sent_messages(server)
assert sum(1 for m in msgs if isinstance(m, PingResponse)) == 2
def test_buffer_len_tracks_correctly(self):
server = make_server()
full = make_packet(PingRequest())
server.data_received(full[:2])
assert server._buffer_len == 2
def test_buffer_cleared_after_full_packet(self):
server = make_server()
server.data_received(make_packet(PingRequest()))
assert server._buffer_len == 0
# ---------------------------------------------------------------------------
# _read_varuint
# ---------------------------------------------------------------------------
class TestReadVarint:
def _make_server_with_buffer(self, data: bytes):
server = make_server()
server._buffer = data
server._buffer_len = len(data)
server._pos = 0
return server
def test_reads_single_byte_varuint(self):
server = self._make_server_with_buffer(bytes([0x05]))
assert server._read_varuint() == 5
def test_reads_two_byte_varuint(self):
# 300 encoded as varuint = 0xAC 0x02
server = self._make_server_with_buffer(bytes([0xAC, 0x02]))
assert server._read_varuint() == 300
def test_returns_minus_one_on_empty_buffer(self):
server = make_server()
server._buffer = None
assert server._read_varuint() == -1
def test_returns_zero_for_zero_byte(self):
server = self._make_server_with_buffer(bytes([0x00]))
assert server._read_varuint() == 0
+424
View File
@@ -0,0 +1,424 @@
"""Unit tests for ESPHome entity classes."""
from unittest.mock import MagicMock
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRequest,
ListEntitiesSelectResponse,
ListEntitiesSwitchResponse,
MediaPlayerCommandRequest,
MediaPlayerStateResponse,
NumberCommandRequest,
NumberStateResponse,
SelectCommandRequest,
SelectStateResponse,
SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest,
SwitchStateResponse,
)
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_server():
"""Minimal mock APIServer."""
server = MagicMock()
server.state = MagicMock()
return server
def make_media_player(server=None, key=1, initial_volume=1.0):
from linux_voice_assistant.entity import MediaPlayerEntity
server = server or make_server()
return MediaPlayerEntity(
server=server,
key=key,
name="Media Player",
object_id="test_media_player",
music_player=MagicMock(),
announce_player=MagicMock(),
initial_volume=initial_volume,
)
def make_mute_switch(server=None, key=2, initial_muted=False):
from linux_voice_assistant.entity import MuteSwitchEntity
server = server or make_server()
get_muted = MagicMock(return_value=initial_muted)
set_muted = MagicMock()
entity = MuteSwitchEntity(
server=server,
key=key,
name="Mute",
object_id="mute",
get_muted=get_muted,
set_muted=set_muted,
)
entity._get_muted = get_muted
entity._set_muted = set_muted
return entity
def make_mic_setting(server=None, key=3, options=None, value=0.0):
from linux_voice_assistant.entity import MicSettingEntity
server = server or make_server()
get_value = MagicMock(return_value=value)
set_value = MagicMock()
entity = MicSettingEntity(
server=server,
key=key,
name="Mic Gain",
object_id="mic_gain",
get_value=get_value,
set_value=set_value,
min_value=0.0,
max_value=31.0,
options=options,
)
entity._get_value_mock = get_value
entity._set_value_mock = set_value
return entity
# ---------------------------------------------------------------------------
# MediaPlayerEntity
# ---------------------------------------------------------------------------
class TestMediaPlayerEntityInit:
def test_initial_volume_clamped_above_one(self):
entity = make_media_player(initial_volume=1.5)
assert entity.volume == 1.0
def test_initial_volume_clamped_below_zero(self):
entity = make_media_player(initial_volume=-0.5)
assert entity.volume == 0.0
def test_initial_volume_stored(self):
entity = make_media_player(initial_volume=0.7)
assert abs(entity.volume - 0.7) < 0.001
def test_initial_state_is_idle(self):
entity = make_media_player()
assert entity.state == MediaPlayerState.IDLE
def test_not_muted_by_default(self):
entity = make_media_player()
assert entity.muted is False
class TestMediaPlayerEntityListEntities:
def test_list_entities_request_yields_response(self):
entity = make_media_player(key=5)
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert len(msgs) == 1
assert isinstance(msgs[0], ListEntitiesMediaPlayerResponse)
def test_list_entities_response_has_correct_key(self):
entity = make_media_player(key=5)
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert msgs[0].key == 5
def test_list_entities_response_has_correct_object_id(self):
entity = make_media_player()
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert msgs[0].object_id == "test_media_player"
def test_list_entities_supports_pause(self):
entity = make_media_player()
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert msgs[0].supports_pause is True
class TestMediaPlayerEntitySubscribeStates:
def test_subscribe_states_yields_state_response(self):
entity = make_media_player()
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
assert len(msgs) == 1
assert isinstance(msgs[0], MediaPlayerStateResponse)
def test_subscribe_states_has_correct_key(self):
entity = make_media_player(key=7)
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
assert msgs[0].key == 7
class TestMediaPlayerEntityVolume:
def test_apply_volume_sets_both_players(self):
entity = make_media_player(initial_volume=1.0)
entity._apply_volume(0.5, persist=False)
entity.music_player.set_volume.assert_called_with(50)
entity.announce_player.set_volume.assert_called_with(50)
def test_apply_volume_clamps_above_one(self):
entity = make_media_player()
entity._apply_volume(1.5, persist=False)
assert entity.volume == 1.0
def test_apply_volume_clamps_below_zero(self):
entity = make_media_player()
entity._apply_volume(-0.1, persist=False)
assert entity.volume == 0.0
def test_apply_volume_stores_previous_volume(self):
entity = make_media_player(initial_volume=0.8)
entity._apply_volume(0.4, persist=False, remember=True)
assert abs(entity.previous_volume - 0.4) < 0.001
def test_apply_volume_persist_calls_callback(self):
callback = MagicMock()
entity = make_media_player()
entity._on_volume_changed = callback
entity._apply_volume(0.5, persist=True)
callback.assert_called_once_with(0.5)
def test_apply_volume_no_persist_skips_callback(self):
callback = MagicMock()
entity = make_media_player()
entity._on_volume_changed = callback
entity._apply_volume(0.5, persist=False)
callback.assert_not_called()
def test_volume_command_yields_state_response(self):
entity = make_media_player(key=1)
msg = MediaPlayerCommandRequest(key=1, has_volume=True, volume=0.6)
msgs = list(entity.handle_message(msg))
assert any(isinstance(m, MediaPlayerStateResponse) for m in msgs)
def test_volume_command_wrong_key_ignored(self):
entity = make_media_player(key=1)
msg = MediaPlayerCommandRequest(key=99, has_volume=True, volume=0.6)
msgs = list(entity.handle_message(msg))
assert msgs == [] or not any(isinstance(m, MediaPlayerStateResponse) for m in msgs)
class TestMediaPlayerEntityMuteUnmute:
def test_mute_sets_volume_to_zero(self):
entity = make_media_player(initial_volume=0.8)
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
list(entity.handle_message(msg))
assert entity.volume == 0.0
def test_mute_saves_previous_volume(self):
entity = make_media_player(initial_volume=0.8)
entity.volume = 0.8
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
list(entity.handle_message(msg))
assert abs(entity.previous_volume - 0.8) < 0.001
def test_mute_sets_muted_flag(self):
entity = make_media_player(key=1)
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
list(entity.handle_message(msg))
assert entity.muted is True
def test_unmute_restores_previous_volume(self):
entity = make_media_player(key=1, initial_volume=0.8)
entity.previous_volume = 0.8
entity.muted = True
entity.volume = 0.0
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.UNMUTE)
list(entity.handle_message(msg))
assert abs(entity.volume - 0.8) < 0.001
def test_unmute_clears_muted_flag(self):
entity = make_media_player(key=1)
entity.muted = True
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.UNMUTE)
list(entity.handle_message(msg))
assert entity.muted is False
def test_double_mute_does_not_overwrite_previous_volume(self):
entity = make_media_player(key=1, initial_volume=0.9)
entity.volume = 0.9
entity.previous_volume = 0.9
msg_mute = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
list(entity.handle_message(msg_mute))
# Mute again — should not overwrite previous_volume with 0
list(entity.handle_message(msg_mute))
assert abs(entity.previous_volume - 0.9) < 0.001
class TestMediaPlayerEntityPlayback:
def test_pause_command_calls_music_player_pause(self):
entity = make_media_player(key=1)
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.PAUSE)
list(entity.handle_message(msg))
entity.music_player.pause.assert_called_once()
def test_stop_command_calls_music_player_stop(self):
entity = make_media_player(key=1)
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.STOP)
list(entity.handle_message(msg))
entity.music_player.stop.assert_called_once()
def test_play_command_calls_music_player_resume(self):
entity = make_media_player(key=1)
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.PLAY)
list(entity.handle_message(msg))
entity.music_player.resume.assert_called_once()
# ---------------------------------------------------------------------------
# MuteSwitchEntity
# ---------------------------------------------------------------------------
class TestMuteSwitchEntity:
def test_initial_state_synced_from_get_muted(self):
entity = make_mute_switch(initial_muted=True)
assert entity._switch_state is True
def test_switch_command_calls_set_muted(self):
entity = make_mute_switch(key=2)
msg = SwitchCommandRequest(key=2, state=True)
list(entity.handle_message(msg))
entity._set_muted.assert_called_once_with(True)
def test_switch_command_updates_internal_state(self):
entity = make_mute_switch(key=2)
msg = SwitchCommandRequest(key=2, state=True)
list(entity.handle_message(msg))
assert entity._switch_state is True
def test_switch_command_yields_switch_state_response(self):
entity = make_mute_switch(key=2)
msg = SwitchCommandRequest(key=2, state=True)
msgs = list(entity.handle_message(msg))
assert any(isinstance(m, SwitchStateResponse) for m in msgs)
def test_switch_command_wrong_key_ignored(self):
entity = make_mute_switch(key=2)
msg = SwitchCommandRequest(key=99, state=True)
list(entity.handle_message(msg))
entity._set_muted.assert_not_called()
def test_list_entities_request_yields_switch_response(self):
entity = make_mute_switch()
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert any(isinstance(m, ListEntitiesSwitchResponse) for m in msgs)
def test_subscribe_states_yields_switch_state_response(self):
entity = make_mute_switch()
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
assert any(isinstance(m, SwitchStateResponse) for m in msgs)
def test_sync_with_state_updates_switch_state(self):
entity = make_mute_switch(initial_muted=False)
entity._get_muted.return_value = True
entity.sync_with_state()
assert entity._switch_state is True
def test_update_set_muted_replaces_callback(self):
entity = make_mute_switch(key=2)
new_set_muted = MagicMock()
entity.update_set_muted(new_set_muted)
msg = SwitchCommandRequest(key=2, state=True)
list(entity.handle_message(msg))
new_set_muted.assert_called_once_with(True)
def test_update_get_muted_replaces_callback(self):
entity = make_mute_switch()
new_get_muted = MagicMock(return_value=True)
entity.update_get_muted(new_get_muted)
entity.sync_with_state()
assert entity._switch_state is True
# ---------------------------------------------------------------------------
# MicSettingEntity — number mode (no options)
# ---------------------------------------------------------------------------
class TestMicSettingEntityNumber:
def test_list_entities_yields_number_response(self):
entity = make_mic_setting(key=3, options=None)
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert any(isinstance(m, ListEntitiesNumberResponse) for m in msgs)
def test_number_command_calls_set_value(self):
entity = make_mic_setting(key=3, options=None)
msg = NumberCommandRequest(key=3, state=5.0)
list(entity.handle_message(msg))
entity._set_value_mock.assert_called_once_with(5.0)
def test_number_command_yields_number_state_response(self):
entity = make_mic_setting(key=3, options=None)
msg = NumberCommandRequest(key=3, state=5.0)
msgs = list(entity.handle_message(msg))
assert any(isinstance(m, NumberStateResponse) for m in msgs)
def test_number_command_wrong_key_ignored(self):
entity = make_mic_setting(key=3, options=None)
msg = NumberCommandRequest(key=99, state=5.0)
list(entity.handle_message(msg))
entity._set_value_mock.assert_not_called()
def test_subscribe_states_yields_number_state_response(self):
entity = make_mic_setting(key=3, options=None, value=2.0)
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
assert any(isinstance(m, NumberStateResponse) for m in msgs)
def test_sync_with_state_updates_internal_state(self):
entity = make_mic_setting(key=3, options=None, value=0.0)
entity._get_value_mock.return_value = 7.0
entity.sync_with_state()
assert entity._state == 7.0
# ---------------------------------------------------------------------------
# MicSettingEntity — select mode (with options)
# ---------------------------------------------------------------------------
class TestMicSettingEntitySelect:
NOISE_OPTIONS = ["Off", "Low", "Medium", "High", "Max"]
def test_list_entities_yields_select_response(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
msgs = list(entity.handle_message(ListEntitiesRequest()))
assert any(isinstance(m, ListEntitiesSelectResponse) for m in msgs)
def test_select_response_has_correct_options(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
msgs = list(entity.handle_message(ListEntitiesRequest()))
select_msg = next(m for m in msgs if isinstance(m, ListEntitiesSelectResponse))
assert list(select_msg.options) == self.NOISE_OPTIONS
def test_select_command_calls_set_value(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
msg = SelectCommandRequest(key=4, state="High")
list(entity.handle_message(msg))
entity._set_value_mock.assert_called_once_with("High")
def test_select_command_yields_select_state_response(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
msg = SelectCommandRequest(key=4, state="Medium")
msgs = list(entity.handle_message(msg))
assert any(isinstance(m, SelectStateResponse) for m in msgs)
def test_select_command_wrong_key_ignored(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
msg = SelectCommandRequest(key=99, state="Low")
list(entity.handle_message(msg))
entity._set_value_mock.assert_not_called()
def test_subscribe_states_yields_select_state_response(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS, value="Off")
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
assert any(isinstance(m, SelectStateResponse) for m in msgs)
def test_subscribe_states_response_has_string_state(self):
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS, value="Low")
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
select_msg = next(m for m in msgs if isinstance(m, SelectStateResponse))
assert isinstance(select_msg.state, str)
+235
View File
@@ -0,0 +1,235 @@
"""Unit tests for __main__.py — process_audio logic and argument parsing helpers."""
import numpy as np
import pytest
from tests.unit.conftest import make_state
def make_audio_chunk(samples: int = 1024, value: float = 0.0) -> np.ndarray:
return np.full(samples, value, dtype=np.float32)
# ---------------------------------------------------------------------------
# process_audio — mic volume scaling
# ---------------------------------------------------------------------------
class TestProcessAudioMicVolume:
def test_mic_volume_scalar_at_100(self, tmp_path):
"""mic_volume=100 → scalar=1.0 → no attenuation."""
state = make_state(tmp_path)
state.mic_volume = 100
state.satellite = None # no satellite → loop exits immediately
# The scalar clamp: max(0.1, min(1.0, 100/100)) = 1.0
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
assert scalar == pytest.approx(1.0)
def test_mic_volume_scalar_at_50(self, tmp_path):
state = make_state(tmp_path)
state.mic_volume = 50
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
assert scalar == pytest.approx(0.5)
def test_mic_volume_scalar_clamped_above_100(self, tmp_path):
state = make_state(tmp_path)
state.mic_volume = 200
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
assert scalar == pytest.approx(1.0)
def test_mic_volume_scalar_minimum_is_0_1(self, tmp_path):
state = make_state(tmp_path)
state.mic_volume = 0
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
assert scalar == pytest.approx(0.1)
def test_audio_chunk_scaled_by_mic_volume(self):
"""Verify the numpy scaling produces correct output."""
audio = make_audio_chunk(value=0.5)
mic_vol_scalar = 0.5
result = np.clip(audio * mic_vol_scalar, -1.0, 1.0)
assert np.allclose(result, 0.25)
def test_audio_chunk_clipped_to_bounds(self):
"""Values that exceed [-1, 1] after scaling should be clipped."""
audio = make_audio_chunk(value=1.0)
mic_vol_scalar = 2.0 # would push to 2.0 without clip
result = np.clip(audio * mic_vol_scalar, -1.0, 1.0)
assert np.all(result <= 1.0)
# ---------------------------------------------------------------------------
# process_audio — WebRTC integration
# ---------------------------------------------------------------------------
class TestProcessAudioWebRTC:
def test_webrtc_not_created_when_agc_and_ns_zero(self, tmp_path):
"""If both AGC and NS are 0, WebRTCProcessor should never be instantiated."""
state = make_state(tmp_path)
state.preferences.mic_auto_gain = 0
state.preferences.mic_noise_suppression = 0
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
should_create = agc > 0 or ns > 0
assert should_create is False
def test_webrtc_created_when_agc_nonzero(self, tmp_path):
state = make_state(tmp_path)
state.preferences.mic_auto_gain = 5
state.preferences.mic_noise_suppression = 0
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
should_create = agc > 0 or ns > 0
assert should_create is True
def test_webrtc_created_when_ns_nonzero(self, tmp_path):
state = make_state(tmp_path)
state.preferences.mic_auto_gain = 0
state.preferences.mic_noise_suppression = 2
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
should_create = agc > 0 or ns > 0
assert should_create is True
# ---------------------------------------------------------------------------
# process_audio — wake word refractory
# ---------------------------------------------------------------------------
class TestWakeWordRefractory:
def test_refractory_allows_activation_when_none(self):
"""First activation (last_active=None) should always be allowed."""
import time
last_active = None
refractory_seconds = 2.0
now = time.monotonic()
allowed = (last_active is None) or ((now - last_active) > refractory_seconds)
assert allowed is True
def test_refractory_blocks_activation_too_soon(self):
"""Activation within refractory period should be blocked."""
import time
last_active = time.monotonic() # just activated
refractory_seconds = 2.0
now = time.monotonic()
allowed = (last_active is None) or ((now - last_active) > refractory_seconds)
assert allowed is False
def test_refractory_allows_activation_after_period(self):
"""Activation after refractory period should be allowed."""
import time
last_active = time.monotonic() - 3.0 # 3 seconds ago
refractory_seconds = 2.0
now = time.monotonic()
allowed = (last_active is None) or ((now - last_active) > refractory_seconds)
assert allowed is True
# ---------------------------------------------------------------------------
# process_audio — stop word detection logic
# ---------------------------------------------------------------------------
class TestStopWordLogic:
def test_stop_word_only_triggers_when_in_active_set(self, tmp_path):
state = make_state(tmp_path)
# Stop word not in active set → should not trigger stop
state.active_wake_words = set()
stopped = True # simulating detection
should_stop = stopped and (state.stop_word.id in state.active_wake_words) and not state.muted
assert should_stop is False
def test_stop_word_triggers_when_in_active_set_and_not_muted(self, tmp_path):
state = make_state(tmp_path)
state.active_wake_words = {state.stop_word.id}
state.muted = False
stopped = True
should_stop = stopped and (state.stop_word.id in state.active_wake_words) and not state.muted
assert should_stop is True
def test_stop_word_does_not_trigger_when_muted(self, tmp_path):
state = make_state(tmp_path)
state.active_wake_words = {state.stop_word.id}
state.muted = True
stopped = True
should_stop = stopped and (state.stop_word.id in state.active_wake_words) and not state.muted
assert should_stop is False
# ---------------------------------------------------------------------------
# Preferences loading from args
# ---------------------------------------------------------------------------
class TestPreferencesFromArgs:
def test_agc_stored_in_preferences_when_nonzero(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences()
mic_auto_gain = 5
if mic_auto_gain > 0:
prefs.mic_auto_gain = mic_auto_gain
assert prefs.mic_auto_gain == 5
def test_agc_not_stored_when_zero(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences()
mic_auto_gain = 0
if mic_auto_gain > 0:
prefs.mic_auto_gain = mic_auto_gain
assert prefs.mic_auto_gain == 0
def test_ns_stored_in_preferences_when_nonzero(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences()
mic_noise_suppression = 2
if mic_noise_suppression > 0:
prefs.mic_noise_suppression = mic_noise_suppression
assert prefs.mic_noise_suppression == 2
def test_volume_clamped_to_one_when_above(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences(volume=1.5)
initial_volume = prefs.volume if prefs.volume is not None else 1.0
initial_volume = max(0.0, min(1.0, float(initial_volume)))
assert initial_volume == pytest.approx(1.0)
def test_volume_clamped_to_zero_when_below(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences(volume=-0.5)
initial_volume = prefs.volume if prefs.volume is not None else 1.0
initial_volume = max(0.0, min(1.0, float(initial_volume)))
assert initial_volume == pytest.approx(0.0)
def test_volume_defaults_to_one_when_none(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences(volume=None)
initial_volume = prefs.volume if prefs.volume is not None else 1.0
initial_volume = max(0.0, min(1.0, float(initial_volume)))
assert initial_volume == pytest.approx(1.0)
def test_thinking_sound_enabled_by_flag(self):
from linux_voice_assistant.models import Preferences
prefs = Preferences()
enable_thinking_sound = True
if enable_thinking_sound:
prefs.thinking_sound = 1
assert prefs.thinking_sound == 1
+273
View File
@@ -0,0 +1,273 @@
"""Unit tests for shared models."""
import json
from unittest.mock import patch
import pytest
from tests.unit.conftest import make_state as make_server_state
def make_preferences(**kwargs):
from linux_voice_assistant.models import Preferences
return Preferences(**kwargs)
# ---------------------------------------------------------------------------
# WakeWordType
# ---------------------------------------------------------------------------
class TestWakeWordType:
def test_micro_value(self):
from linux_voice_assistant.models import WakeWordType
assert WakeWordType.MICRO_WAKE_WORD == "micro"
def test_open_wake_word_value(self):
from linux_voice_assistant.models import WakeWordType
assert WakeWordType.OPEN_WAKE_WORD == "openWakeWord"
def test_is_string_enum(self):
from linux_voice_assistant.models import WakeWordType
assert isinstance(WakeWordType.MICRO_WAKE_WORD, str)
# ---------------------------------------------------------------------------
# Preferences
# ---------------------------------------------------------------------------
class TestPreferences:
def test_default_active_wake_words_is_empty_list(self):
p = make_preferences()
assert p.active_wake_words == []
def test_default_volume_is_none(self):
p = make_preferences()
assert p.volume is None
def test_default_thinking_sound_is_zero(self):
p = make_preferences()
assert p.thinking_sound == 0
def test_default_mic_auto_gain_is_zero(self):
p = make_preferences()
assert p.mic_auto_gain == 0
def test_default_mic_noise_suppression_is_zero(self):
p = make_preferences()
assert p.mic_noise_suppression == 0
def test_custom_values_stored(self):
p = make_preferences(
volume=0.8,
mic_auto_gain=5,
mic_noise_suppression=2,
thinking_sound=1,
)
assert p.volume == 0.8
assert p.mic_auto_gain == 5
assert p.mic_noise_suppression == 2
assert p.thinking_sound == 1
def test_active_wake_words_are_independent_per_instance(self):
"""Mutable default must not be shared between instances."""
p1 = make_preferences()
p2 = make_preferences()
p1.active_wake_words.append("okay_nabu")
assert p2.active_wake_words == []
# ---------------------------------------------------------------------------
# ServerState.save_preferences()
# ---------------------------------------------------------------------------
class TestSavePreferences:
def test_creates_preferences_file(self, tmp_path):
state = make_server_state(tmp_path)
state.save_preferences()
assert state.preferences_path.exists()
def test_saved_json_is_valid(self, tmp_path):
state = make_server_state(tmp_path)
state.save_preferences()
with open(state.preferences_path) as f:
data = json.load(f)
assert isinstance(data, dict)
def test_saved_json_contains_expected_keys(self, tmp_path):
state = make_server_state(tmp_path)
state.save_preferences()
with open(state.preferences_path) as f:
data = json.load(f)
assert "mic_auto_gain" in data
assert "mic_noise_suppression" in data
assert "volume" in data
assert "thinking_sound" in data
def test_saved_values_match_preferences(self, tmp_path):
from linux_voice_assistant.models import Preferences
prefs = Preferences(volume=0.75, mic_auto_gain=3, mic_noise_suppression=1)
state = make_server_state(tmp_path, preferences=prefs)
state.save_preferences()
with open(state.preferences_path) as f:
data = json.load(f)
assert data["volume"] == 0.75
assert data["mic_auto_gain"] == 3
assert data["mic_noise_suppression"] == 1
def test_creates_parent_directory_if_missing(self, tmp_path):
nested_path = tmp_path / "nested" / "dir" / "preferences.json"
state = make_server_state(tmp_path, preferences_path=nested_path)
state.save_preferences()
assert nested_path.exists()
# ---------------------------------------------------------------------------
# ServerState.persist_volume()
# ---------------------------------------------------------------------------
class TestPersistVolume:
def test_updates_volume_on_state(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(0.5)
assert state.volume == 0.5
def test_updates_volume_on_preferences(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(0.5)
assert state.preferences.volume == 0.5
def test_clamps_above_one(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(1.5)
assert state.volume == 1.0
def test_clamps_below_zero(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(-0.5)
assert state.volume == 0.0
def test_saves_to_file(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(0.6)
with open(state.preferences_path) as f:
data = json.load(f)
assert data["volume"] == pytest.approx(0.6)
def test_skips_save_when_volume_unchanged(self, tmp_path):
from linux_voice_assistant.models import Preferences
prefs = Preferences(volume=0.5)
state = make_server_state(tmp_path, preferences=prefs, volume=0.5)
with patch.object(state, "save_preferences") as mock_save:
state.persist_volume(0.5)
mock_save.assert_not_called()
def test_saves_when_volume_changed(self, tmp_path):
from linux_voice_assistant.models import Preferences
prefs = Preferences(volume=0.5)
state = make_server_state(tmp_path, preferences=prefs, volume=0.5)
with patch.object(state, "save_preferences") as mock_save:
state.persist_volume(0.8)
mock_save.assert_called_once()
def test_boundary_value_zero(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(0.0)
assert state.volume == 0.0
def test_boundary_value_one(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_volume(1.0)
assert state.volume == 1.0
# ---------------------------------------------------------------------------
# ServerState.persist_mic_gain()
# ---------------------------------------------------------------------------
class TestPersistMicGain:
def test_updates_mic_auto_gain_on_state(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_mic_gain(5.0)
assert state.mic_auto_gain == 5
def test_updates_mic_auto_gain_on_preferences(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_mic_gain(5.0)
assert state.preferences.mic_auto_gain == 5
def test_converts_float_to_int(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_mic_gain(7.9)
assert state.mic_auto_gain == 7
def test_skips_save_when_gain_unchanged(self, tmp_path):
state = make_server_state(tmp_path)
state.mic_auto_gain = 3
state.preferences.mic_auto_gain = 3
with patch.object(state, "save_preferences") as mock_save:
state.persist_mic_gain(3.0)
mock_save.assert_not_called()
def test_saves_when_gain_changed(self, tmp_path):
state = make_server_state(tmp_path)
state.mic_auto_gain = 3
state.preferences.mic_auto_gain = 3
with patch.object(state, "save_preferences") as mock_save:
state.persist_mic_gain(10.0)
mock_save.assert_called_once()
# ---------------------------------------------------------------------------
# ServerState.persist_mic_noise()
# ---------------------------------------------------------------------------
class TestPersistMicNoise:
def test_updates_mic_noise_suppression_on_state(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_mic_noise(2.0)
assert state.mic_noise_suppression == 2
def test_updates_mic_noise_suppression_on_preferences(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_mic_noise(2.0)
assert state.preferences.mic_noise_suppression == 2
def test_converts_float_to_int(self, tmp_path):
state = make_server_state(tmp_path)
state.persist_mic_noise(3.9)
assert state.mic_noise_suppression == 3
def test_skips_save_when_noise_unchanged(self, tmp_path):
state = make_server_state(tmp_path)
state.mic_noise_suppression = 2
state.preferences.mic_noise_suppression = 2
with patch.object(state, "save_preferences") as mock_save:
state.persist_mic_noise(2.0)
mock_save.assert_not_called()
def test_saves_when_noise_changed(self, tmp_path):
state = make_server_state(tmp_path)
state.mic_noise_suppression = 2
state.preferences.mic_noise_suppression = 2
with patch.object(state, "save_preferences") as mock_save:
state.persist_mic_noise(4.0)
mock_save.assert_called_once()
+255
View File
@@ -0,0 +1,255 @@
"""Unit tests for MpvMediaPlayer."""
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_player(device=None):
"""Return an MpvMediaPlayer with the internal LibMpvPlayer mocked out."""
from linux_voice_assistant.player.state import PlayerState
mock_lib_player = MagicMock()
mock_lib_player.state.return_value = PlayerState.IDLE
with patch("linux_voice_assistant.mpv_player.LibMpvPlayer", return_value=mock_lib_player):
from linux_voice_assistant.mpv_player import MpvMediaPlayer
player = MpvMediaPlayer(device=device)
player._mock = mock_lib_player
return player
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
class TestInit:
def test_player_initialized_with_device(self):
with patch("linux_voice_assistant.mpv_player.LibMpvPlayer") as mock_cls:
mock_cls.return_value = MagicMock()
from linux_voice_assistant.mpv_player import MpvMediaPlayer
MpvMediaPlayer(device="hw:1,0")
mock_cls.assert_called_once_with(device="hw:1,0")
def test_player_initialized_with_none_device(self):
with patch("linux_voice_assistant.mpv_player.LibMpvPlayer") as mock_cls:
mock_cls.return_value = MagicMock()
from linux_voice_assistant.mpv_player import MpvMediaPlayer
MpvMediaPlayer(device=None)
mock_cls.assert_called_once_with(device=None)
def test_done_callback_starts_none(self):
player = make_player()
assert player._done_callback is None
def test_playlist_starts_empty(self):
player = make_player()
assert player._playlist == []
# ---------------------------------------------------------------------------
# play()
# ---------------------------------------------------------------------------
class TestPlay:
def test_play_single_url_calls_lib_player(self):
player = make_player()
player.play("http://example.com/audio.mp3")
player._mock.play.assert_called_once()
def test_play_single_url_passes_correct_url(self):
player = make_player()
player.play("http://example.com/audio.mp3")
args, kwargs = player._mock.play.call_args
assert args[0] == "http://example.com/audio.mp3"
def test_play_list_plays_first_url(self):
player = make_player()
player.play(["http://a.com/1.mp3", "http://a.com/2.mp3"])
args, kwargs = player._mock.play.call_args
assert args[0] == "http://a.com/1.mp3"
def test_play_list_stores_remaining_in_playlist(self):
player = make_player()
player.play(["http://a.com/1.mp3", "http://a.com/2.mp3", "http://a.com/3.mp3"])
assert player._playlist == ["http://a.com/2.mp3", "http://a.com/3.mp3"]
def test_play_single_url_playlist_is_empty(self):
player = make_player()
player.play("http://example.com/audio.mp3")
assert player._playlist == []
def test_play_stores_done_callback(self):
player = make_player()
cb = MagicMock()
player.play("http://example.com/audio.mp3", done_callback=cb)
assert player._done_callback is cb
def test_play_empty_list_does_not_call_lib_player(self):
player = make_player()
player.play([])
player._mock.play.assert_not_called()
def test_play_while_active_stops_previous(self):
from linux_voice_assistant.player.state import PlayerState
player = make_player()
player._done_callback = MagicMock() # simulate active playback
player._mock.state.return_value = PlayerState.PLAYING
player.play("http://example.com/new.mp3")
player._mock.stop.assert_called_once()
def test_play_while_idle_does_not_stop(self):
from linux_voice_assistant.player.state import PlayerState
player = make_player()
player._mock.state.return_value = PlayerState.IDLE
player._done_callback = None
player.play("http://example.com/new.mp3")
player._mock.stop.assert_not_called()
# ---------------------------------------------------------------------------
# _on_track_finished()
# ---------------------------------------------------------------------------
class TestOnTrackFinished:
def test_plays_next_url_when_playlist_has_items(self):
player = make_player()
player._playlist = ["http://a.com/2.mp3"]
player._on_track_finished()
args, _ = player._mock.play.call_args
assert args[0] == "http://a.com/2.mp3"
def test_invokes_done_callback_when_playlist_empty(self):
player = make_player()
cb = MagicMock()
player._done_callback = cb
player._playlist = []
player._on_track_finished()
cb.assert_called_once()
def test_clears_done_callback_after_invoking(self):
player = make_player()
player._done_callback = MagicMock()
player._playlist = []
player._on_track_finished()
assert player._done_callback is None
def test_no_error_when_done_callback_is_none(self):
player = make_player()
player._done_callback = None
player._playlist = []
player._on_track_finished() # should not raise
# ---------------------------------------------------------------------------
# pause() / resume() / stop()
# ---------------------------------------------------------------------------
class TestPauseResumeStop:
def test_pause_delegates_to_lib_player(self):
player = make_player()
player.pause()
player._mock.pause.assert_called_once()
def test_resume_delegates_to_lib_player(self):
player = make_player()
player.resume()
player._mock.resume.assert_called_once()
def test_stop_delegates_to_lib_player(self):
player = make_player()
player.stop()
player._mock.stop.assert_called_once()
def test_stop_invokes_done_callback(self):
player = make_player()
cb = MagicMock()
player._done_callback = cb
player.stop()
cb.assert_called_once()
def test_stop_clears_done_callback(self):
player = make_player()
player._done_callback = MagicMock()
player.stop()
assert player._done_callback is None
def test_stop_no_error_when_no_callback(self):
player = make_player()
player._done_callback = None
player.stop() # should not raise
# ---------------------------------------------------------------------------
# is_playing
# ---------------------------------------------------------------------------
class TestIsPlaying:
def test_true_when_playing(self):
from linux_voice_assistant.player.state import PlayerState
player = make_player()
player._mock.state.return_value = PlayerState.PLAYING
assert player.is_playing is True
def test_true_when_paused(self):
from linux_voice_assistant.player.state import PlayerState
player = make_player()
player._mock.state.return_value = PlayerState.PAUSED
assert player.is_playing is True
def test_true_when_loading(self):
from linux_voice_assistant.player.state import PlayerState
player = make_player()
player._mock.state.return_value = PlayerState.LOADING
assert player.is_playing is True
def test_false_when_idle(self):
from linux_voice_assistant.player.state import PlayerState
player = make_player()
player._mock.state.return_value = PlayerState.IDLE
assert player.is_playing is False
# ---------------------------------------------------------------------------
# set_volume() / duck() / unduck()
# ---------------------------------------------------------------------------
class TestVolume:
def test_set_volume_delegates_to_lib_player(self):
player = make_player()
player.set_volume(75.0)
player._mock.set_volume.assert_called_once_with(75.0)
def test_duck_delegates_to_lib_player(self):
player = make_player()
player.duck(factor=0.3)
player._mock.duck.assert_called_once_with(0.3)
def test_duck_default_factor(self):
player = make_player()
player.duck()
player._mock.duck.assert_called_once_with(0.5)
def test_unduck_delegates_to_lib_player(self):
player = make_player()
player.unduck()
player._mock.unduck.assert_called_once()
+369
View File
@@ -0,0 +1,369 @@
"""Unit tests for VoiceSatelliteProtocol logic."""
from unittest.mock import MagicMock, patch
import pytest
from tests.unit.conftest import make_satellite, make_state
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
class TestInit:
def test_satellite_stored_on_state(self, tmp_path):
sat = make_satellite(tmp_path)
assert sat.state.satellite is sat
def test_connected_starts_false(self, tmp_path):
sat = make_satellite(tmp_path)
assert sat.state.connected is False
def test_media_player_entity_created(self, tmp_path):
from linux_voice_assistant.entity import MediaPlayerEntity
sat = make_satellite(tmp_path)
assert sat.state.media_player_entity is not None
assert isinstance(sat.state.media_player_entity, MediaPlayerEntity)
def test_mute_switch_entity_created(self, tmp_path):
from linux_voice_assistant.entity import MuteSwitchEntity
sat = make_satellite(tmp_path)
assert sat.state.mute_switch_entity is not None
assert isinstance(sat.state.mute_switch_entity, MuteSwitchEntity)
def test_thinking_sound_entity_created(self, tmp_path):
from linux_voice_assistant.entity import ThinkingSoundEntity
sat = make_satellite(tmp_path)
assert sat.state.thinking_sound_entity is not None
assert isinstance(sat.state.thinking_sound_entity, ThinkingSoundEntity)
def test_mic_gain_entity_created(self, tmp_path):
from linux_voice_assistant.entity import MicSettingEntity
sat = make_satellite(tmp_path)
assert sat.state.mic_gain_entity is not None
assert isinstance(sat.state.mic_gain_entity, MicSettingEntity)
def test_mic_noise_entity_created(self, tmp_path):
from linux_voice_assistant.entity import MicSettingEntity
sat = make_satellite(tmp_path)
assert sat.state.mic_noise_suppression_entity is not None
assert isinstance(sat.state.mic_noise_suppression_entity, MicSettingEntity)
def test_mic_volume_entity_created(self, tmp_path):
from linux_voice_assistant.entity import MicSettingEntity
sat = make_satellite(tmp_path)
assert sat.state.mic_volume_entity is not None
assert isinstance(sat.state.mic_volume_entity, MicSettingEntity)
def test_pipeline_not_active_on_start(self, tmp_path):
sat = make_satellite(tmp_path)
assert sat._pipeline_active is False
def test_not_streaming_audio_on_start(self, tmp_path):
sat = make_satellite(tmp_path)
assert sat._is_streaming_audio is False
def test_not_muted_on_start(self, tmp_path):
sat = make_satellite(tmp_path)
assert sat.state.muted is False
def test_thinking_sound_loaded_from_preferences(self, tmp_path):
state = make_state(tmp_path)
state.preferences.thinking_sound = 1
with (
patch("linux_voice_assistant.satellite.WakeWord1SensitivityNumberEntity", MagicMock()),
patch("linux_voice_assistant.satellite.WakeWord2SensitivityNumberEntity", MagicMock()),
patch("linux_voice_assistant.satellite.StopWordSensitivityNumberEntity", MagicMock()),
):
from linux_voice_assistant.satellite import VoiceSatelliteProtocol
sat = VoiceSatelliteProtocol(state)
assert sat.state.thinking_sound_enabled is True
def test_output_only_sets_limited_features(self, tmp_path):
from aioesphomeapi.model import VoiceAssistantFeature
state = make_state(tmp_path)
state.output_only = True
with (
patch("linux_voice_assistant.satellite.WakeWord1SensitivityNumberEntity", MagicMock()),
patch("linux_voice_assistant.satellite.WakeWord2SensitivityNumberEntity", MagicMock()),
patch("linux_voice_assistant.satellite.StopWordSensitivityNumberEntity", MagicMock()),
):
from linux_voice_assistant.satellite import VoiceSatelliteProtocol
sat = VoiceSatelliteProtocol(state)
assert sat.supported_features & VoiceAssistantFeature.VOICE_ASSISTANT == 0
# ---------------------------------------------------------------------------
# _set_muted()
# ---------------------------------------------------------------------------
class TestSetMuted:
def test_muting_sets_muted_flag(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_muted(True)
assert sat.state.muted is True
def test_unmuting_clears_muted_flag(self, tmp_path):
sat = make_satellite(tmp_path)
sat.state.muted = True
sat._set_muted(False)
assert sat.state.muted is False
def test_muting_stops_tts_player(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_muted(True)
sat.state.tts_player.stop.assert_called()
def test_muting_plays_mute_sound(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_muted(True)
sat.state.tts_player.play.assert_called_with(sat.state.mute_sound)
def test_unmuting_plays_unmute_sound(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_muted(False)
sat.state.tts_player.play.assert_called_with(sat.state.unmute_sound)
def test_muting_stops_audio_streaming(self, tmp_path):
sat = make_satellite(tmp_path)
sat._is_streaming_audio = True
sat._set_muted(True)
assert sat._is_streaming_audio is False
# ---------------------------------------------------------------------------
# _set_thinking_sound_enabled()
# ---------------------------------------------------------------------------
class TestSetThinkingSoundEnabled:
def test_enables_thinking_sound(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_thinking_sound_enabled(True)
assert sat.state.thinking_sound_enabled is True
def test_disables_thinking_sound(self, tmp_path):
sat = make_satellite(tmp_path)
sat.state.thinking_sound_enabled = True
sat._set_thinking_sound_enabled(False)
assert sat.state.thinking_sound_enabled is False
def test_updates_preferences(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_thinking_sound_enabled(True)
assert sat.state.preferences.thinking_sound == 1
def test_saves_preferences_to_file(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_thinking_sound_enabled(True)
assert sat.state.preferences_path.exists()
# ---------------------------------------------------------------------------
# _set_sensitivity_1/2 and _set_stop_sensitivity
# ---------------------------------------------------------------------------
class TestSensitivitySetters:
def test_set_sensitivity_1_updates_threshold(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_sensitivity_1(0.85)
assert sat.state.wake_word_1_threshold == pytest.approx(0.85)
def test_set_sensitivity_1_updates_preferences(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_sensitivity_1(0.85)
assert sat.state.preferences.wake_word_1_sensitivity == pytest.approx(0.85)
def test_set_sensitivity_2_updates_threshold(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_sensitivity_2(0.6)
assert sat.state.wake_word_2_threshold == pytest.approx(0.6)
def test_set_sensitivity_2_updates_preferences(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_sensitivity_2(0.6)
assert sat.state.preferences.wake_word_2_sensitivity == pytest.approx(0.6)
def test_set_stop_sensitivity_updates_threshold(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_stop_sensitivity(0.5)
assert sat.state.stop_word_threshold == pytest.approx(0.5)
def test_set_stop_sensitivity_updates_preferences(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_stop_sensitivity(0.5)
assert sat.state.preferences.stop_word_sensitivity == pytest.approx(0.5)
def test_sensitivity_saves_preferences(self, tmp_path):
sat = make_satellite(tmp_path)
sat._set_sensitivity_1(0.9)
assert sat.state.preferences_path.exists()
# ---------------------------------------------------------------------------
# handle_audio()
# ---------------------------------------------------------------------------
class TestHandleAudio:
def test_does_not_send_when_not_streaming(self, tmp_path):
sat = make_satellite(tmp_path)
sat._is_streaming_audio = False
sat.handle_audio(b"\x00" * 320)
sat._writelines.assert_not_called()
def test_does_not_send_when_muted(self, tmp_path):
sat = make_satellite(tmp_path)
sat._is_streaming_audio = True
sat.state.muted = True
sat.handle_audio(b"\x00" * 320)
sat._writelines.assert_not_called()
def test_sends_when_streaming_and_not_muted(self, tmp_path):
sat = make_satellite(tmp_path)
sat._is_streaming_audio = True
sat.state.muted = False
sat._loop = None
sat.handle_audio(b"\x00" * 320)
sat._writelines.assert_called()
# ---------------------------------------------------------------------------
# play_tts()
# ---------------------------------------------------------------------------
class TestPlayTts:
def test_does_not_play_when_no_url(self, tmp_path):
sat = make_satellite(tmp_path)
sat._tts_url = None
sat.play_tts()
sat.state.tts_player.play.assert_not_called()
def test_does_not_play_when_already_played(self, tmp_path):
sat = make_satellite(tmp_path)
sat._tts_url = "http://example.com/tts.mp3"
sat._tts_played = True
sat.play_tts()
sat.state.tts_player.play.assert_not_called()
def test_plays_tts_url(self, tmp_path):
sat = make_satellite(tmp_path)
sat._tts_url = "http://example.com/tts.mp3"
sat._tts_played = False
sat.play_tts()
sat.state.tts_player.play.assert_called_once()
args, _ = sat.state.tts_player.play.call_args
assert args[0] == "http://example.com/tts.mp3"
def test_sets_tts_played_flag(self, tmp_path):
sat = make_satellite(tmp_path)
sat._tts_url = "http://example.com/tts.mp3"
sat._tts_played = False
sat.play_tts()
assert sat._tts_played is True
def test_adds_stop_word_to_active_wake_words(self, tmp_path):
sat = make_satellite(tmp_path)
sat._tts_url = "http://example.com/tts.mp3"
sat._tts_played = False
sat.play_tts()
assert sat.state.stop_word.id in sat.state.active_wake_words
# ---------------------------------------------------------------------------
# stop()
# ---------------------------------------------------------------------------
class TestStop:
def test_stop_clears_pipeline_active(self, tmp_path):
sat = make_satellite(tmp_path)
sat._pipeline_active = True
sat.stop()
assert sat._pipeline_active is False
def test_stop_discards_stop_word_from_active(self, tmp_path):
sat = make_satellite(tmp_path)
sat.state.active_wake_words.add(sat.state.stop_word.id)
sat.stop()
assert sat.state.stop_word.id not in sat.state.active_wake_words
def test_stop_calls_tts_player_stop(self, tmp_path):
sat = make_satellite(tmp_path)
sat._timer_finished = False
sat.stop()
sat.state.tts_player.stop.assert_called()
# ---------------------------------------------------------------------------
# duck() / unduck()
# ---------------------------------------------------------------------------
class TestDuckUnduck:
def test_duck_calls_music_player_duck(self, tmp_path):
sat = make_satellite(tmp_path)
sat.duck()
sat.state.music_player.duck.assert_called_once()
def test_unduck_calls_music_player_unduck(self, tmp_path):
sat = make_satellite(tmp_path)
sat.unduck()
sat.state.music_player.unduck.assert_called_once()
# ---------------------------------------------------------------------------
# connection_lost()
# ---------------------------------------------------------------------------
class TestConnectionLost:
def test_connection_lost_clears_connected_flag(self, tmp_path):
sat = make_satellite(tmp_path)
sat.state.connected = True
sat.connection_lost(None)
assert sat.state.connected is False
def test_connection_lost_clears_satellite_reference(self, tmp_path):
sat = make_satellite(tmp_path)
sat.connection_lost(None)
assert sat.state.satellite is None
def test_connection_lost_stops_streaming(self, tmp_path):
sat = make_satellite(tmp_path)
sat._is_streaming_audio = True
sat.connection_lost(None)
assert sat._is_streaming_audio is False
def test_connection_lost_clears_pipeline_active(self, tmp_path):
sat = make_satellite(tmp_path)
sat._pipeline_active = True
sat.connection_lost(None)
assert sat._pipeline_active is False
def test_connection_lost_stops_music_player(self, tmp_path):
sat = make_satellite(tmp_path)
sat.connection_lost(None)
sat.state.music_player.stop.assert_called()
def test_connection_lost_stops_tts_player(self, tmp_path):
sat = make_satellite(tmp_path)
sat.connection_lost(None)
sat.state.tts_player.stop.assert_called()
+243
View File
@@ -0,0 +1,243 @@
"""Unit tests for utility functions."""
from pathlib import Path
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# get_version()
# ---------------------------------------------------------------------------
class TestGetVersion:
def setup_method(self):
"""Reset the version cache before each test."""
import linux_voice_assistant.util as util
util._version_cache = None
def test_returns_unknown_when_file_missing(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", side_effect=FileNotFoundError):
result = util.get_version()
assert result == "unknown"
def test_returns_version_string_from_file(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", return_value="1.2.3\n"):
result = util.get_version()
assert result == "1.2.3"
def test_strips_whitespace_from_version(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", return_value=" 2.0.0 \n"):
result = util.get_version()
assert result == "2.0.0"
def test_returns_unknown_for_empty_file(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", return_value=" "):
result = util.get_version()
assert result == "unknown"
def test_caches_result_after_first_call(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", return_value="3.0.0") as mock_read:
util.get_version()
util.get_version()
mock_read.assert_called_once()
def test_returns_cached_value_on_second_call(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", return_value="4.0.0"):
first = util.get_version()
second = util.get_version()
assert first == second == "4.0.0"
def test_returns_unknown_on_permission_error(self):
import linux_voice_assistant.util as util
util._version_cache = None
with patch.object(Path, "read_text", side_effect=PermissionError):
result = util.get_version()
assert result == "unknown"
# ---------------------------------------------------------------------------
# get_esphome_version()
# ---------------------------------------------------------------------------
class TestGetEsphomeVersion:
def setup_method(self):
import linux_voice_assistant.util as util
util._esphome_version_cache = None
def test_returns_version_when_package_installed(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.version", return_value="42.7.0"):
result = util.get_esphome_version()
assert result == "42.7.0"
def test_returns_unknown_when_package_not_installed(self):
from importlib.metadata import PackageNotFoundError
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.version", side_effect=PackageNotFoundError):
result = util.get_esphome_version()
assert result == "unknown"
def test_caches_result_after_first_call(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.version", return_value="1.0.0") as mock_ver:
util.get_esphome_version()
util.get_esphome_version()
mock_ver.assert_called_once()
def test_returns_cached_value_on_second_call(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.version", return_value="5.0.0"):
first = util.get_esphome_version()
second = util.get_esphome_version()
assert first == second == "5.0.0"
# ---------------------------------------------------------------------------
# call_all()
# ---------------------------------------------------------------------------
class TestCallAll:
def test_calls_single_callable(self):
from linux_voice_assistant.util import call_all
mock_fn = MagicMock()
call_all(mock_fn)
mock_fn.assert_called_once()
def test_calls_multiple_callables_in_order(self):
from linux_voice_assistant.util import call_all
calls = []
call_all(
lambda: calls.append(1),
lambda: calls.append(2),
lambda: calls.append(3),
)
assert calls == [1, 2, 3]
def test_skips_none_entries(self):
from linux_voice_assistant.util import call_all
mock_fn = MagicMock()
call_all(None, mock_fn, None)
mock_fn.assert_called_once()
def test_all_none_does_nothing(self):
from linux_voice_assistant.util import call_all
call_all(None, None, None)
def test_empty_args_does_nothing(self):
from linux_voice_assistant.util import call_all
call_all()
def test_none_mixed_with_callables_calls_only_non_none(self):
from linux_voice_assistant.util import call_all
results = []
call_all(None, lambda: results.append("a"), None, lambda: results.append("b"))
assert results == ["a", "b"]
# ---------------------------------------------------------------------------
# get_default_interface() and get_default_ipv4()
# ---------------------------------------------------------------------------
class TestGetDefaultInterface:
def test_returns_interface_name_from_gateway(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
# Set AF_INET before building the dict so the key matches
mock_netifaces.AF_INET = 2
mock_netifaces.default_gateway.return_value = {2: ("192.168.1.1", "eth0")}
result = util.get_default_interface()
assert result == "eth0"
def test_returns_none_when_no_gateway(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
mock_netifaces.default_gateway.return_value = {}
result = util.get_default_interface()
assert result is None
def test_returns_none_when_no_ipv4_gateway(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
mock_netifaces.AF_INET = 2
# Only a non-IPv4 gateway present
mock_netifaces.default_gateway.return_value = {99: ("10.0.0.1", "eth1")}
result = util.get_default_interface()
assert result is None
class TestGetDefaultIpv4:
def test_returns_ip_for_interface(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
mock_netifaces.AF_INET = 2
mock_netifaces.ifaddresses.return_value = {2: [{"addr": "192.168.1.50"}]}
result = util.get_default_ipv4("eth0")
assert result == "192.168.1.50"
def test_returns_none_for_empty_interface(self):
import linux_voice_assistant.util as util
result = util.get_default_ipv4("")
assert result is None
def test_returns_none_for_none_interface(self):
import linux_voice_assistant.util as util
result = util.get_default_ipv4(None)
assert result is None
def test_returns_none_when_no_ipv4_address(self):
import linux_voice_assistant.util as util
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
mock_netifaces.AF_INET = 2
mock_netifaces.ifaddresses.return_value = {}
result = util.get_default_ipv4("eth0")
assert result is None
+287
View File
@@ -0,0 +1,287 @@
"""Unit tests for wake_word.py — find_available_wake_words, load_wake_models, load_stop_model."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_micro_json(wake_word: str = "okay nabu", probability_cutoff: float = 0.7) -> dict:
return {
"type": "micro",
"wake_word": wake_word,
"trained_languages": ["en"],
"micro": {"probability_cutoff": probability_cutoff},
}
def make_oww_json(wake_word: str = "hey jarvis", model_file: str = "hey_jarvis.tflite") -> dict:
return {
"type": "openWakeWord",
"wake_word": wake_word,
"trained_languages": ["en"],
"model": model_file,
"openWakeWord": {"probability_cutoff": 0.5},
}
def write_json(path: Path, data: dict) -> None:
path.write_text(json.dumps(data), encoding="utf-8")
# ---------------------------------------------------------------------------
# find_available_wake_words()
# ---------------------------------------------------------------------------
class TestFindAvailableWakeWords:
def test_finds_micro_wake_word(self, tmp_path):
write_json(tmp_path / "okay_nabu.json", make_micro_json())
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert "okay_nabu" in result
def test_skips_stop_model(self, tmp_path):
write_json(tmp_path / "stop.json", make_micro_json("stop"))
write_json(tmp_path / "okay_nabu.json", make_micro_json())
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert "stop" not in result
assert "okay_nabu" in result
def test_returns_empty_for_empty_directory(self, tmp_path):
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result == {}
def test_returns_empty_for_nonexistent_directory(self, tmp_path):
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path / "does_not_exist"], stop_model_id="stop")
assert result == {}
def test_micro_wake_word_path_is_config_path(self, tmp_path):
write_json(tmp_path / "okay_nabu.json", make_micro_json())
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["okay_nabu"].wake_word_path == tmp_path / "okay_nabu.json"
def test_oww_wake_word_path_is_model_file(self, tmp_path):
write_json(tmp_path / "hey_jarvis.json", make_oww_json(model_file="hey_jarvis.tflite"))
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["hey_jarvis"].wake_word_path == tmp_path / "hey_jarvis.tflite"
def test_probability_cutoff_loaded_from_config(self, tmp_path):
write_json(tmp_path / "okay_nabu.json", make_micro_json(probability_cutoff=0.85))
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["okay_nabu"].probability_cutoff == pytest.approx(0.85)
def test_default_probability_cutoff_when_missing(self, tmp_path):
data = {"type": "micro", "wake_word": "okay nabu", "trained_languages": ["en"]}
write_json(tmp_path / "okay_nabu.json", data)
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["okay_nabu"].probability_cutoff == pytest.approx(0.7)
def test_trained_languages_stored(self, tmp_path):
data = make_micro_json()
data["trained_languages"] = ["en", "de"]
write_json(tmp_path / "okay_nabu.json", data)
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["okay_nabu"].trained_languages == ["en", "de"]
def test_searches_multiple_directories(self, tmp_path):
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
write_json(dir1 / "okay_nabu.json", make_micro_json())
write_json(dir2 / "hey_jarvis.json", make_oww_json())
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([dir1, dir2], stop_model_id="stop")
assert "okay_nabu" in result
assert "hey_jarvis" in result
def test_wake_word_text_stored(self, tmp_path):
write_json(tmp_path / "okay_nabu.json", make_micro_json(wake_word="okay nabu"))
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["okay_nabu"].wake_word == "okay nabu"
def test_type_stored_correctly(self, tmp_path):
from linux_voice_assistant.models import WakeWordType
write_json(tmp_path / "okay_nabu.json", make_micro_json())
from linux_voice_assistant.wake_word import find_available_wake_words
result = find_available_wake_words([tmp_path], stop_model_id="stop")
assert result["okay_nabu"].type == WakeWordType.MICRO_WAKE_WORD
# ---------------------------------------------------------------------------
# load_wake_models()
# ---------------------------------------------------------------------------
class TestLoadWakeModels:
def _make_available(self, tmp_path, model_id="okay_nabu"):
from linux_voice_assistant.models import AvailableWakeWord, WakeWordType
config_path = tmp_path / f"{model_id}.json"
write_json(config_path, make_micro_json())
mock_model = MagicMock()
mock_model.id = model_id
available = MagicMock(spec=AvailableWakeWord)
available.id = model_id
available.type = WakeWordType.MICRO_WAKE_WORD
available.wake_word = "okay nabu"
available.trained_languages = ["en"]
available.wake_word_path = config_path
available.probability_cutoff = 0.7
available.load.return_value = mock_model
return available, mock_model
def test_loads_requested_wake_word(self, tmp_path):
available, mock_model = self._make_available(tmp_path)
from linux_voice_assistant.wake_word import load_wake_models
models, active, fallback = load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
assert "okay_nabu" in models
assert "okay_nabu" in active
def test_falls_back_to_default_when_no_active(self, tmp_path):
available, _ = self._make_available(tmp_path)
from linux_voice_assistant.wake_word import load_wake_models
models, active, fallback = load_wake_models({"okay_nabu": available}, [], "okay_nabu")
assert "okay_nabu" in models
assert fallback is True
def test_fallback_used_is_false_when_requested_loaded(self, tmp_path):
available, _ = self._make_available(tmp_path)
from linux_voice_assistant.wake_word import load_wake_models
_, _, fallback = load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
assert fallback is False
def test_skips_unknown_wake_word_id(self, tmp_path):
available, _ = self._make_available(tmp_path)
from linux_voice_assistant.wake_word import load_wake_models
models, active, _ = load_wake_models({"okay_nabu": available}, ["unknown_word"], "okay_nabu")
assert "unknown_word" not in models
def test_falls_back_to_okay_nabu_when_default_missing(self, tmp_path):
available, _ = self._make_available(tmp_path, "okay_nabu")
from linux_voice_assistant.wake_word import load_wake_models
models, active, _ = load_wake_models({"okay_nabu": available}, [], "nonexistent_default")
assert "okay_nabu" in models
def test_raises_when_no_wake_words_available(self):
from linux_voice_assistant.wake_word import load_wake_models
with pytest.raises(RuntimeError, match="No wake word models available"):
load_wake_models({}, [], "okay_nabu")
def test_loads_multiple_requested_models(self, tmp_path):
available1, _ = self._make_available(tmp_path, "okay_nabu")
available2, _ = self._make_available(tmp_path, "hey_jarvis")
from linux_voice_assistant.wake_word import load_wake_models
models, active, _ = load_wake_models(
{"okay_nabu": available1, "hey_jarvis": available2},
["okay_nabu", "hey_jarvis"],
"okay_nabu",
)
assert "okay_nabu" in models
assert "hey_jarvis" in models
def test_active_set_matches_loaded_models(self, tmp_path):
available, _ = self._make_available(tmp_path)
from linux_voice_assistant.wake_word import load_wake_models
models, active, _ = load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
assert set(models.keys()) == active
def test_load_called_once_per_model(self, tmp_path):
available, _ = self._make_available(tmp_path)
from linux_voice_assistant.wake_word import load_wake_models
load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
available.load.assert_called_once()
# ---------------------------------------------------------------------------
# load_stop_model()
# ---------------------------------------------------------------------------
class TestLoadStopModel:
def test_returns_model_when_found(self, tmp_path):
write_json(tmp_path / "stop.json", make_micro_json("stop"))
mock_model = MagicMock()
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", return_value=mock_model):
from linux_voice_assistant.wake_word import load_stop_model
result = load_stop_model([tmp_path], "stop")
assert result is mock_model
def test_returns_none_when_not_found(self, tmp_path):
from linux_voice_assistant.wake_word import load_stop_model
result = load_stop_model([tmp_path], "stop")
assert result is None
def test_searches_multiple_directories(self, tmp_path):
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
write_json(dir2 / "stop.json", make_micro_json("stop"))
mock_model = MagicMock()
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", return_value=mock_model):
from linux_voice_assistant.wake_word import load_stop_model
result = load_stop_model([dir1, dir2], "stop")
assert result is mock_model
def test_returns_none_when_load_fails(self, tmp_path):
write_json(tmp_path / "stop.json", make_micro_json("stop"))
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", side_effect=Exception("load error")):
from linux_voice_assistant.wake_word import load_stop_model
result = load_stop_model([tmp_path], "stop")
assert result is None
def test_stops_at_first_found(self, tmp_path):
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
write_json(dir1 / "stop.json", make_micro_json("stop"))
write_json(dir2 / "stop.json", make_micro_json("stop"))
mock_model = MagicMock()
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", return_value=mock_model) as mock_load:
from linux_voice_assistant.wake_word import load_stop_model
load_stop_model([dir1, dir2], "stop")
mock_load.assert_called_once()
+195
View File
@@ -0,0 +1,195 @@
"""Unit tests for WebRTCProcessor."""
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
FRAME_SIZE = 320 # 160 samples * 2 bytes (16-bit PCM)
PATCH_TARGET = "webrtc_noise_gain.AudioProcessor"
def make_audio(n_bytes: int, fill: int = 0xAB) -> bytes:
"""Return n_bytes of dummy PCM data."""
return bytes([fill]) * n_bytes
def make_mock_apm(output_fill: int = 0x00):
"""Return a mock AudioProcessor whose Process10ms returns a frame-sized result."""
mock_apm = MagicMock()
mock_result = MagicMock()
mock_result.audio = bytes([output_fill]) * FRAME_SIZE
mock_apm.Process10ms.return_value = mock_result
return mock_apm
@pytest.fixture
def processor():
"""WebRTCProcessor with a mocked AudioProcessor so no C extension is needed."""
mock_apm = make_mock_apm()
with patch(PATCH_TARGET, return_value=mock_apm):
from linux_voice_assistant.webrtc import WebRTCProcessor
proc = WebRTCProcessor(agc_level=3, ns_level=2)
proc._mock_apm = mock_apm
yield proc
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
class TestInit:
def test_frame_size_is_320(self, processor):
assert processor.FRAME_SIZE_BYTES == FRAME_SIZE
def test_agc_stored(self, processor):
assert processor.agc_level == 3
def test_ns_stored(self, processor):
assert processor.ns_level == 2
def test_buffer_starts_empty(self, processor):
assert len(processor._buffer) == 0
def test_audio_processor_constructed_with_correct_levels(self):
mock_apm = make_mock_apm()
with patch(PATCH_TARGET, return_value=mock_apm) as mock_cls:
from linux_voice_assistant.webrtc import WebRTCProcessor
WebRTCProcessor(agc_level=5, ns_level=1)
mock_cls.assert_called_once_with(5, 1)
# ---------------------------------------------------------------------------
# process() — buffering behaviour
# ---------------------------------------------------------------------------
class TestProcessBuffering:
def test_exact_one_frame_returns_processed_bytes(self, processor):
result = processor.process(make_audio(FRAME_SIZE))
assert len(result) == FRAME_SIZE
processor._mock_apm.Process10ms.assert_called_once()
def test_less_than_one_frame_returns_empty(self, processor):
result = processor.process(make_audio(FRAME_SIZE - 1))
assert result == b""
processor._mock_apm.Process10ms.assert_not_called()
def test_two_frames_returns_two_processed_chunks(self, processor):
result = processor.process(make_audio(FRAME_SIZE * 2))
assert len(result) == FRAME_SIZE * 2
assert processor._mock_apm.Process10ms.call_count == 2
def test_partial_input_buffers_remainder(self, processor):
# 500 bytes = 1 full frame (320) + 180 leftover
result = processor.process(make_audio(500))
assert len(result) == FRAME_SIZE
assert len(processor._buffer) == 500 - FRAME_SIZE
def test_accumulated_calls_eventually_flush(self, processor):
# Two calls of 160 bytes each should flush one frame total
processor.process(make_audio(160))
assert processor._mock_apm.Process10ms.call_count == 0
processor.process(make_audio(160))
assert processor._mock_apm.Process10ms.call_count == 1
def test_buffer_drains_in_place(self, processor):
"""After processing, only the remainder stays in the buffer."""
processor.process(make_audio(500))
assert len(processor._buffer) == 180
def test_empty_input_returns_empty(self, processor):
result = processor.process(b"")
assert result == b""
processor._mock_apm.Process10ms.assert_not_called()
def test_output_is_concatenation_of_processed_chunks(self, processor):
"""Each frame gets processed independently and results are joined."""
apm = processor._mock_apm
apm.Process10ms.side_effect = [
MagicMock(audio=b"\x01" * FRAME_SIZE),
MagicMock(audio=b"\x02" * FRAME_SIZE),
]
result = processor.process(make_audio(FRAME_SIZE * 2))
assert result == b"\x01" * FRAME_SIZE + b"\x02" * FRAME_SIZE
def test_multiple_process_calls_accumulate_buffer(self, processor):
"""Remainder from first call is used in second call."""
processor.process(make_audio(200)) # 200 buffered, no flush
processor.process(make_audio(200)) # 400 total, 1 flush, 80 remain
assert processor._mock_apm.Process10ms.call_count == 1
assert len(processor._buffer) == 80
# ---------------------------------------------------------------------------
# update_settings()
# ---------------------------------------------------------------------------
class TestUpdateSettings:
def test_reinitializes_when_agc_changes(self, processor):
new_apm = make_mock_apm()
with patch(PATCH_TARGET, return_value=new_apm):
processor.update_settings(agc_level=10, ns_level=2)
assert processor.apm is new_apm
assert processor.agc_level == 10
def test_reinitializes_when_ns_changes(self, processor):
new_apm = make_mock_apm()
with patch(PATCH_TARGET, return_value=new_apm):
processor.update_settings(agc_level=3, ns_level=4)
assert processor.apm is new_apm
assert processor.ns_level == 4
def test_no_reinitialize_when_settings_unchanged(self, processor):
original_apm = processor.apm
with patch(PATCH_TARGET) as mock_cls:
processor.update_settings(agc_level=3, ns_level=2)
mock_cls.assert_not_called()
assert processor.apm is original_apm
def test_stores_new_agc_level(self, processor):
with patch(PATCH_TARGET, return_value=make_mock_apm()):
processor.update_settings(agc_level=15, ns_level=2)
assert processor.agc_level == 15
def test_stores_new_ns_level(self, processor):
with patch(PATCH_TARGET, return_value=make_mock_apm()):
processor.update_settings(agc_level=3, ns_level=3)
assert processor.ns_level == 3
def test_new_apm_called_with_updated_levels(self, processor):
with patch(PATCH_TARGET, return_value=make_mock_apm()) as mock_cls:
processor.update_settings(agc_level=7, ns_level=1)
mock_cls.assert_called_once_with(7, 1)
# ---------------------------------------------------------------------------
# process() after update_settings()
# ---------------------------------------------------------------------------
class TestProcessAfterUpdate:
def test_buffer_preserved_across_settings_update(self, processor):
"""Buffered bytes should survive a settings change."""
processor.process(make_audio(160)) # half frame, stays buffered
assert len(processor._buffer) == 160
with patch(PATCH_TARGET, return_value=make_mock_apm()):
processor.update_settings(agc_level=10, ns_level=2)
assert len(processor._buffer) == 160
def test_process_uses_new_apm_after_update(self, processor):
new_apm = make_mock_apm(output_fill=0xFF)
with patch(PATCH_TARGET, return_value=new_apm):
processor.update_settings(agc_level=10, ns_level=2)
processor.process(make_audio(FRAME_SIZE))
new_apm.Process10ms.assert_called_once()
+178
View File
@@ -0,0 +1,178 @@
"""Unit tests for HomeAssistantZeroconf."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_zeroconf(**kwargs):
defaults = dict(
port=6053,
mac_address="aa:bb:cc:dd:ee:ff",
host_ip_address="192.168.1.100",
name="lva-test",
)
defaults.update(kwargs)
with patch("linux_voice_assistant.zeroconf.AsyncZeroconf") as mock_zc_cls:
mock_zc = MagicMock()
mock_zc_cls.return_value = mock_zc
from linux_voice_assistant.zeroconf import HomeAssistantZeroconf
instance = HomeAssistantZeroconf(**defaults)
instance._mock_zc = mock_zc
instance._mock_zc_cls = mock_zc_cls
return instance
# ---------------------------------------------------------------------------
# __init__
# ---------------------------------------------------------------------------
class TestInit:
def test_name_stored(self):
zc = make_zeroconf(name="my-lva")
assert zc.name == "my-lva"
def test_port_stored(self):
zc = make_zeroconf(port=1234)
assert zc.port == 1234
def test_mac_address_stored(self):
zc = make_zeroconf(mac_address="11:22:33:44:55:66")
assert zc.mac_address == "11:22:33:44:55:66"
def test_host_ip_stored(self):
zc = make_zeroconf(host_ip_address="10.0.0.1")
assert zc.host_ip_address == "10.0.0.1"
def test_name_defaults_to_mac_when_not_provided(self):
with patch("linux_voice_assistant.zeroconf.AsyncZeroconf"):
from linux_voice_assistant.zeroconf import HomeAssistantZeroconf
zc = HomeAssistantZeroconf(
port=6053,
mac_address="aa:bb:cc:dd:ee:ff",
host_ip_address="192.168.1.1",
name=None,
)
assert zc.name == "aa:bb:cc:dd:ee:ff"
def test_async_zeroconf_instantiated(self):
with patch("linux_voice_assistant.zeroconf.AsyncZeroconf") as mock_cls:
mock_cls.return_value = MagicMock()
from linux_voice_assistant.zeroconf import HomeAssistantZeroconf
HomeAssistantZeroconf(
port=6053,
mac_address="aa:bb:cc:dd:ee:ff",
host_ip_address="192.168.1.1",
)
mock_cls.assert_called_once()
# ---------------------------------------------------------------------------
# register_server()
# ---------------------------------------------------------------------------
class TestRegisterServer:
@pytest.mark.asyncio
async def test_register_service_called(self):
zc = make_zeroconf()
zc._mock_zc.async_register_service = AsyncMock()
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo"):
await zc.register_server()
zc._mock_zc.async_register_service.assert_called_once()
@pytest.mark.asyncio
async def test_service_name_contains_device_name(self):
zc = make_zeroconf(name="lva-aabbccddee")
zc._mock_zc.async_register_service = AsyncMock()
captured = {}
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
await zc.register_server()
# First positional arg is service type, second is full service name
assert "lva-aabbccddee" in captured["args"][1]
@pytest.mark.asyncio
async def test_service_type_is_esphomelib(self):
zc = make_zeroconf()
zc._mock_zc.async_register_service = AsyncMock()
captured = {}
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
await zc.register_server()
assert captured["args"][0] == "_esphomelib._tcp.local."
@pytest.mark.asyncio
async def test_service_properties_contain_mac(self):
zc = make_zeroconf(mac_address="aa:bb:cc:dd:ee:ff")
zc._mock_zc.async_register_service = AsyncMock()
captured = {}
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
await zc.register_server()
props = captured["kwargs"].get("properties", {})
assert "mac" in props
assert props["mac"] == "aa:bb:cc:dd:ee:ff"
@pytest.mark.asyncio
async def test_service_properties_contain_version(self):
zc = make_zeroconf()
zc._mock_zc.async_register_service = AsyncMock()
captured = {}
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
await zc.register_server()
props = captured["kwargs"].get("properties", {})
assert "version" in props
@pytest.mark.asyncio
async def test_service_port_matches(self):
zc = make_zeroconf(port=9999)
zc._mock_zc.async_register_service = AsyncMock()
captured = {}
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
await zc.register_server()
assert captured["kwargs"].get("port") == 9999
@pytest.mark.asyncio
async def test_service_address_matches_host_ip(self):
import socket
zc = make_zeroconf(host_ip_address="10.0.0.5")
zc._mock_zc.async_register_service = AsyncMock()
captured = {}
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
await zc.register_server()
addresses = captured["kwargs"].get("addresses", [])
assert socket.inet_aton("10.0.0.5") in addresses