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