diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index 2affdf1..c684190 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -21,8 +21,8 @@ class SpeechRecognitionManagerProtocol(Protocol): state: RecognitionState - def start_recognition(self, mode: str = "toggle") -> None: - """Start the speech recognition process.""" + def start_recognition(self, mode: str = "toggle") -> bool: + """Start the speech recognition process. Returns True if listening began.""" ... def stop_recognition(self) -> None: diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 11edca5..69bffec 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -2470,11 +2470,16 @@ class SpeechRecognitionManager: chunk_duration_ms = (1024 / 16000) * 1000 return int(guard_ms / chunk_duration_ms) - def start_recognition(self, mode: str = "toggle"): - """Start the speech recognition process.""" + def start_recognition(self, mode: str = "toggle") -> bool: + """Start the speech recognition process. + + Returns: + True if recognition actually started (state is LISTENING), False if + blocked (wrong state, auto-paused, or model not ready). + """ if self.state != RecognitionState.IDLE: logger.warning(f"Cannot start recognition in current state: {self.state}") - return + return False if self._auto_paused: logger.warning( @@ -2487,7 +2492,7 @@ class SpeechRecognitionManager: "Close that app or remove it from Auto-Pause settings to resume.", "dialog-information", ) - return + return False # Check if model is ready (lazy-reload after idle keep-alive unload) if not self.model_ready: @@ -2501,7 +2506,7 @@ class SpeechRecognitionManager: "Open Settings to check your engine and try again.", "dialog-warning", ) - return + return False else: logger.warning( "Cannot start recognition: model not downloaded. " @@ -2514,7 +2519,7 @@ class SpeechRecognitionManager: "to use dictation.", "dialog-warning", ) - return + return False logger.info("Starting speech recognition") self._update_state(RecognitionState.LISTENING) @@ -2537,6 +2542,7 @@ class SpeechRecognitionManager: self.recognition_thread = threading.Thread(target=self._perform_recognition) self.recognition_thread.daemon = True self.recognition_thread.start() + return True def stop_recognition(self): """Stop the speech recognition process.""" diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index c8ba4aa..89a3274 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -4962,13 +4962,22 @@ class SettingsDialog(Gtk.Dialog): current_config = self.config_manager.get_settings().get("speech_recognition", {}) selected_settings = self.get_selected_settings() + selected_engine = selected_settings.get("engine") + selected_model = selected_settings.get("model_size") + # Compare to the live engine too: UI can match the file while the + # in-memory manager is still on another engine/size (sidecar, pending apply). + live_engine = getattr(self.speech_engine, "engine", None) + live_model = getattr(self.speech_engine, "model_size", None) settings_differ = False - if current_config.get("engine") != selected_settings.get("engine") or current_config.get( - "model_size" - ) != selected_settings.get("model_size"): + if ( + current_config.get("engine") != selected_engine + or current_config.get("model_size") != selected_model + or live_engine != selected_engine + or live_model != selected_model + ): settings_differ = True - elif selected_settings.get("engine") == "vosk": + elif selected_engine == "vosk": if current_config.get("vad_sensitivity") != selected_settings.get( "vad_sensitivity" ) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"): @@ -4981,21 +4990,40 @@ class SettingsDialog(Gtk.Dialog): return self.test_buffer.set_text("Settings applied. Starting test...") + self.connect_to_recognition_manager() + + self._saved_text_callbacks = self.speech_engine.get_text_callbacks() + self.speech_engine.set_text_callbacks([self._test_text_callback]) + + if not self.speech_engine.start_recognition(): + self.speech_engine.set_text_callbacks(self._saved_text_callbacks) + del self._saved_text_callbacks + self.test_output_revealer.set_reveal_child(True) + if getattr(self.speech_engine, "is_auto_paused", False): + self.test_buffer.set_text( + "Dictation is paused. Close the listed app or remove it from " + "Auto-Pause settings." + ) + elif not getattr(self.speech_engine, "model_ready", True): + self.test_buffer.set_text( + "No speech model downloaded. Open the Speech Model page and " + "download a model to use Test Dictation." + ) + else: + self.test_buffer.set_text("Could not start recognition test.") + return + self._test_active = True self.test_button.set_sensitive(False) self.test_button.set_label("Testing… Speak Now!") self.test_output_revealer.set_reveal_child(True) self.test_buffer.set_text("") self._test_result = "" - - self.connect_to_recognition_manager() self.update_recognition_progress("Listening", info="Starting recognition test...") - self._saved_text_callbacks = self.speech_engine.get_text_callbacks() - self.speech_engine.set_text_callbacks([self._test_text_callback]) - - self.speech_engine.start_recognition() - threading.Thread(target=self._stop_test_after_delay, args=(3,)).start() + # Enough room for one utterance plus the configured silence window. + delay = float(selected_settings.get("silence_timeout", 2.0)) + 2.0 + threading.Thread(target=self._stop_test_after_delay, args=(delay,)).start() def _test_text_callback(self, text: str): """Callback specifically for the test recognition.""" @@ -5011,7 +5039,7 @@ class SettingsDialog(Gtk.Dialog): self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0) return False - def _stop_test_after_delay(self, delay: int): + def _stop_test_after_delay(self, delay: float): """Stops the recognition test after a specified delay.""" time.sleep(delay) GLib.idle_add(self._finalize_test) diff --git a/tests/test_auto_pause_monitor.py b/tests/test_auto_pause_monitor.py index 628ea9f..971c3ff 100644 --- a/tests/test_auto_pause_monitor.py +++ b/tests/test_auto_pause_monitor.py @@ -523,7 +523,7 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase): patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"), patch("vocalinux.speech_recognition.recognition_manager._show_notification"), ): - mgr.start_recognition() + self.assertFalse(mgr.start_recognition()) self.assertEqual(mgr.state, RecognitionState.IDLE) # Should not have transitioned to listening diff --git a/tests/test_download_error_reporting.py b/tests/test_download_error_reporting.py index 3c7942c..88c25bf 100644 --- a/tests/test_download_error_reporting.py +++ b/tests/test_download_error_reporting.py @@ -14,7 +14,12 @@ def _load_settings_dialog(): the module as a mock and makes its methods unreachable. Handing the three bases the module subclasses a real class keeps the classes intact, while the rest of GTK stays mocked. + + Restore both sys.modules and the vocalinux.ui.settings_dialog attribute so + 3.9/3.10 mock and later imports see the same module object. """ + import vocalinux.ui as ui_pkg + repository = sys.modules["gi.repository"] bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} saved = sys.modules.pop("vocalinux.ui.settings_dialog", None) @@ -25,6 +30,9 @@ def _load_settings_dialog(): sys.modules.pop("vocalinux.ui.settings_dialog", None) if saved is not None: sys.modules["vocalinux.ui.settings_dialog"] = saved + ui_pkg.settings_dialog = saved + else: + ui_pkg.__dict__.pop("settings_dialog", None) return module diff --git a/tests/test_model_deletion.py b/tests/test_model_deletion.py index d94bc38..6b70984 100644 --- a/tests/test_model_deletion.py +++ b/tests/test_model_deletion.py @@ -92,23 +92,17 @@ def test_whisper_list_and_delete(tmp_path): tiny.write_bytes(b"weights") missing_default = tmp_path / "missing-whisper-cache" - from vocalinux.ui.settings_dialog import ( - _delete_whisper_model, - _list_downloaded_whisper_models, - ) + import vocalinux.ui.settings_dialog as sd + # Patch the same module object we call. String-target patch() on 3.9/3.10 + # follows vocalinux.ui.settings_dialog, which can be a different copy than + # sys.modules after a test-time reload. with ( - patch( - "vocalinux.ui.settings_dialog._get_whisper_cache_dir", - return_value=str(cache), - ), - patch( - "vocalinux.ui.settings_dialog.os.path.expanduser", - return_value=str(missing_default), - ), + patch.object(sd, "_get_whisper_cache_dir", return_value=str(cache)), + patch.object(sd.os.path, "expanduser", return_value=str(missing_default)), ): - assert _list_downloaded_whisper_models() == ["tiny"] - deleted = _delete_whisper_model("tiny") + assert sd._list_downloaded_whisper_models() == ["tiny"] + deleted = sd._delete_whisper_model("tiny") assert deleted == [str(tiny)] assert not tiny.exists() - assert _list_downloaded_whisper_models() == [] + assert sd._list_downloaded_whisper_models() == [] diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py index 92d0ef6..2d912cd 100644 --- a/tests/test_recognition_manager.py +++ b/tests/test_recognition_manager.py @@ -475,7 +475,7 @@ class TestSpeechRecognition(unittest.TestCase): manager.model = None # Should not start and should play error sound - manager.start_recognition() + self.assertFalse(manager.start_recognition()) self.assertEqual(manager.state, RecognitionState.IDLE) mock_audio_feedback.play_error_sound.assert_called() diff --git a/tests/test_recognition_manager_core.py b/tests/test_recognition_manager_core.py index 1a13918..1261404 100644 --- a/tests/test_recognition_manager_core.py +++ b/tests/test_recognition_manager_core.py @@ -474,7 +474,7 @@ class TestStartStopRecognition(unittest.TestCase): """Test successful recognition start.""" manager = SpeechRecognitionManager(engine="vosk") - manager.start_recognition() + self.assertTrue(manager.start_recognition()) self.assertEqual(manager.state, RecognitionState.LISTENING) self.assertTrue(manager.should_record) @@ -492,7 +492,7 @@ class TestStartStopRecognition(unittest.TestCase): # Second start should not create new threads manager.audio_thread = self.threadInstance manager.recognition_thread = self.threadInstance - manager.start_recognition() + self.assertFalse(manager.start_recognition()) # No new threads created self.threadMock.assert_not_called() diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index dd3736f..c695117 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -1774,7 +1774,7 @@ class TestStartStopRecognition(unittest.TestCase): manager.state = RecognitionState.LISTENING with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"): - manager.start_recognition() + assert manager.start_recognition() is False # Should return early without starting threads def test_start_recognition_model_not_ready(self): @@ -1785,7 +1785,7 @@ class TestStartStopRecognition(unittest.TestCase): with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"): with patch("vocalinux.speech_recognition.recognition_manager._show_notification"): - manager.start_recognition() + assert manager.start_recognition() is False assert manager.state == RecognitionState.IDLE # Should not change def test_start_recognition_success(self): @@ -1798,7 +1798,7 @@ class TestStartStopRecognition(unittest.TestCase): with patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"): with patch.object(manager, "_record_audio"): with patch.object(manager, "_perform_recognition"): - manager.start_recognition() + assert manager.start_recognition() is True assert manager.state == RecognitionState.LISTENING assert manager.should_record is True diff --git a/tests/test_recognition_manager_download_buffer.py b/tests/test_recognition_manager_download_buffer.py index 0ac4f05..13ad7ec 100644 --- a/tests/test_recognition_manager_download_buffer.py +++ b/tests/test_recognition_manager_download_buffer.py @@ -584,7 +584,7 @@ class TestStartStopRecognition(unittest.TestCase): manager._model_initialized = True manager.state = RecognitionState.IDLE - manager.start_recognition() + assert manager.start_recognition() is True assert manager.state == RecognitionState.LISTENING assert manager.should_record is True @@ -606,7 +606,7 @@ class TestStartStopRecognition(unittest.TestCase): manager._model_initialized = False manager.state = RecognitionState.IDLE - manager.start_recognition() + assert manager.start_recognition() is False # Should stay idle if model not ready assert manager.state == RecognitionState.IDLE @@ -627,7 +627,7 @@ class TestStartStopRecognition(unittest.TestCase): manager._model_initialized = True manager.state = RecognitionState.LISTENING - manager.start_recognition() + assert manager.start_recognition() is False # Should still be listening (no-op) assert manager.state == RecognitionState.LISTENING diff --git a/tests/test_settings_test_dictation.py b/tests/test_settings_test_dictation.py new file mode 100644 index 0000000..a4399ad --- /dev/null +++ b/tests/test_settings_test_dictation.py @@ -0,0 +1,208 @@ +"""Tests for Settings → Test Dictation start failure vs real no-speech.""" + +import importlib +import sys +from unittest.mock import MagicMock, Mock, patch + +from vocalinux.common_types import RecognitionState + + +def _load_settings_dialog(): + """Import settings_dialog with real bases so unbound methods stay callable. + + conftest swaps gi for a MagicMock, which leaves every ``class X(Gtk.Y)`` as + a mock. Handing the three bases a real class keeps the methods intact. + + Restore both sys.modules and the vocalinux.ui.settings_dialog attribute. + Python 3.9/3.10 mock follows the package attribute, so leaving a private + copy there makes later tests patch a different module than they imported. + """ + import vocalinux.ui as ui_pkg + + repository = sys.modules["gi.repository"] + bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} + saved = sys.modules.pop("vocalinux.ui.settings_dialog", None) + try: + with patch.object(repository, "Gtk", MagicMock(**bases)): + module = importlib.import_module("vocalinux.ui.settings_dialog") + finally: + sys.modules.pop("vocalinux.ui.settings_dialog", None) + if saved is not None: + sys.modules["vocalinux.ui.settings_dialog"] = saved + ui_pkg.settings_dialog = saved + else: + ui_pkg.__dict__.pop("settings_dialog", None) + return module + + +settings_dialog = _load_settings_dialog() +SettingsDialog = settings_dialog.SettingsDialog + + +def _text_buffer(): + """Minimal stand-in for Gtk.TextBuffer used by the test output pane.""" + buf = Mock() + buf._text = "" + + def set_text(text): + buf._text = text + + def get_text(*_args, **_kwargs): + return buf._text + + def insert(_iter, text): + buf._text += text + + buf.set_text.side_effect = set_text + buf.get_text.side_effect = get_text + buf.insert.side_effect = insert + buf.get_start_iter.return_value = Mock() + buf.get_end_iter.return_value = Mock() + buf.get_insert.return_value = Mock() + return buf + + +def _dialog_for_test(*, start_return, model_ready=True, is_auto_paused=False): + dialog = Mock() + dialog._test_active = False + dialog.test_button = Mock() + dialog.test_output_revealer = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_textview = Mock() + dialog.config_manager = Mock() + dialog.config_manager.get_settings.return_value = { + "speech_recognition": { + "engine": "whisper_cpp", + "model_size": "tiny", + "silence_timeout": 2.0, + "vad_sensitivity": 3, + } + } + dialog.get_selected_settings = Mock( + return_value={ + "engine": "whisper_cpp", + "model_size": "tiny", + "silence_timeout": 2.0, + "vad_sensitivity": 3, + } + ) + dialog.apply_settings = Mock(return_value=True) + dialog.connect_to_recognition_manager = Mock() + dialog.update_recognition_progress = Mock() + dialog._test_text_callback = Mock() + dialog._stop_test_after_delay = SettingsDialog._stop_test_after_delay.__get__(dialog) + + engine = Mock() + engine.state = RecognitionState.IDLE + engine.engine = "whisper_cpp" + engine.model_size = "tiny" + engine.start_recognition = Mock(return_value=start_return) + engine.stop_recognition = Mock() + engine.get_text_callbacks = Mock(return_value=[]) + engine.set_text_callbacks = Mock() + engine.model_ready = model_ready + engine.is_auto_paused = is_auto_paused + dialog.speech_engine = engine + return dialog + + +def test_test_dictation_missing_model_skips_listen_timer(): + dialog = _dialog_for_test(start_return=False, model_ready=False) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_not_called() + assert dialog._test_active is False + assert "Speech Model page" in dialog.test_buffer._text + assert "No speech detected" not in dialog.test_buffer._text + dialog.speech_engine.set_text_callbacks.assert_any_call([]) + dialog.test_button.set_label.assert_not_called() + dialog.test_output_revealer.set_reveal_child.assert_called_with(True) + + +def test_test_dictation_auto_paused_message(): + dialog = _dialog_for_test(start_return=False, is_auto_paused=True) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_not_called() + assert "paused" in dialog.test_buffer._text.lower() + assert "No speech detected" not in dialog.test_buffer._text + + +def test_test_dictation_start_failure_generic_message(): + dialog = _dialog_for_test(start_return=False, model_ready=True, is_auto_paused=False) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_not_called() + assert dialog.test_buffer._text == "Could not start recognition test." + + +def test_test_dictation_started_arms_listen_timer(): + dialog = _dialog_for_test(start_return=True) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_called_once() + assert thread_cls.call_args.kwargs["args"] == (4.0,) + assert dialog._test_active is True + dialog.test_button.set_label.assert_called_with("Testing… Speak Now!") + dialog.apply_settings.assert_not_called() + + +def test_test_dictation_reconfigures_when_live_engine_differs_from_ui(): + """UI matches the file but the live manager is still on another engine.""" + dialog = _dialog_for_test(start_return=True, model_ready=True) + dialog.speech_engine.engine = "vosk" + dialog.speech_engine.model_size = "small" + dialog.speech_engine.model_ready = False + + def _apply_and_sync(): + dialog.speech_engine.engine = "whisper_cpp" + dialog.speech_engine.model_size = "tiny" + dialog.speech_engine.model_ready = True + return True + + dialog.apply_settings.side_effect = _apply_and_sync + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + dialog.apply_settings.assert_called_once() + thread_cls.assert_called_once() + assert dialog._test_active is True + + +def test_check_test_result_empty_buffer_is_no_speech(): + dialog = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_buffer.set_text("") + + SettingsDialog._check_test_result(dialog) + + assert dialog.test_buffer._text == "(No speech detected during test)" + + +def test_check_test_result_keeps_captured_text(): + dialog = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_buffer.set_text("hello world") + + SettingsDialog._check_test_result(dialog) + + assert dialog.test_buffer._text == "hello world" + + +def test_append_test_result_shows_callback_text(): + dialog = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_textview = Mock() + + SettingsDialog._append_test_result(dialog, "hello") + + assert dialog.test_buffer._text == "hello"