Files
linux-voice-assistant/tests/unit/test_models.py
T
d1f5761f75 Restore stop word sensitivity from preferences on startup (#394)
* Restore stop word sensitivity from preferences on startup

The Home Assistant number entity persists preferences.stop_word_sensitivity
whenever it changes, but nothing ever read it back. main() did not pass
stop_word_threshold when constructing ServerState, so it always took the
dataclass default of 0.5, and lowering the sensitivity in Home Assistant
silently reverted at the next restart.

volume and mic_volume are restored at startup, and the two wake word
sensitivities are restored when wake models load. Only the stop word was
missed, so the block that syncs all three sensitivity entities back to Home
Assistant was pushing a default it had never loaded.

Restored alongside volume and clamped the same way. The resolution lives in a
small helper so it can be covered by tests.

* Move the threshold helper to models.py so tests do not import __main__

CI has no PulseAudio daemon. Importing linux_voice_assistant.__main__ pulls in
`import soundcard as sc`, and soundcard/pulseaudio.py instantiates _PulseAudio()
at module scope, which asserts on a live connection. So the four new tests
failed on the runner while passing locally, where a daemon happens to be
running.

That is also why no other test in the suite imports __main__, and why the
existing tests in test_main.py re-implement the logic they check rather than
calling it.

initial_stop_word_threshold() now lives in models.py, which owns both fields it
resolves between: Preferences.stop_word_sensitivity and
ServerState.stop_word_threshold. It imports cleanly, test_models.py already
imports it, and the reference to the ServerState default becomes local.

Behaviour is unchanged. Verified by reproducing the CI environment locally with
PULSE_SERVER pointed at a nonexistent socket: 4 failed before, 298 pass after.

---------

Co-authored-by: ryan felder <7475484+ryanfelder@users.noreply.github.com>
Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-08-18 02:07:33 +03:00

312 lines
11 KiB
Python

"""Unit tests for shared models."""
import json
from unittest.mock import Mock, 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
def test_emits_volume_muted_event_when_volume_zero(self, tmp_path):
from linux_voice_assistant.peripheral_api import LVAEvent
state = make_server_state(tmp_path)
state.peripheral_api = Mock()
state.persist_volume(0.0)
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_CHANGED, {"volume": 0.0})
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_MUTED, {"muted": True})
# ---------------------------------------------------------------------------
# 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()
# ---------------------------------------------------------------------------
# initial_stop_word_threshold()
# ---------------------------------------------------------------------------
class TestInitialStopWordThreshold:
def test_saved_sensitivity_is_restored(self):
from linux_voice_assistant.models import initial_stop_word_threshold
assert initial_stop_word_threshold(0.9) == pytest.approx(0.9)
def test_falls_back_to_server_state_default_when_never_set(self):
from linux_voice_assistant.models import ServerState, initial_stop_word_threshold
assert initial_stop_word_threshold(None) == pytest.approx(ServerState.stop_word_threshold)
def test_clamped_to_one_when_above(self):
from linux_voice_assistant.models import initial_stop_word_threshold
assert initial_stop_word_threshold(1.5) == pytest.approx(1.0)
def test_clamped_to_zero_when_below(self):
from linux_voice_assistant.models import initial_stop_word_threshold
assert initial_stop_word_threshold(-0.5) == pytest.approx(0.0)