From 9b5cdf686fe096cb9c70b787ddb218180afeb5dc Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 21 Oct 2026 01:20:14 -0700 Subject: [PATCH] fix(dictation-pad): keep Test Dictation results and move pad routing to Settings (#1005) * fix(dictation-pad): keep Test Dictation results and move pad routing to Settings Keep late recognition callbacks on the test path. Move Dictation Pad routing to Settings. Fixes #983 Fixes #986 * fix(recognition): ignore stop sentinels in pending transcription Ignore stop signals when checking queued transcription work. Add regression coverage for a real stop path leaving a sentinel behind. Fixes #983 * fix(recognition): don't count orphaned queued audio as pending transcription * fix(recognition): count superseded workers as pending transcription * test(auto-pause): give the bare manager its worker-tracking state --- src/vocalinux/common_types.py | 4 + .../speech_recognition/recognition_manager.py | 25 +++ src/vocalinux/ui/dictation_pad.py | 48 +---- src/vocalinux/ui/settings_dialog.py | 89 ++++++--- src/vocalinux/ui/tray_indicator.py | 5 +- tests/test_auto_pause_monitor.py | 3 + tests/test_dictation_pad.py | 39 +++- tests/test_recognition_manager_advanced.py | 189 ++++++++++++++++++ tests/test_settings_test_dictation.py | 129 +++++++++++- tests/test_tray_indicator.py | 17 ++ 10 files changed, 462 insertions(+), 86 deletions(-) diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index cf1656e..777abec 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -98,6 +98,10 @@ class SpeechRecognitionManagerProtocol(Protocol): """Stop the speech recognition process.""" ... + def has_pending_transcription(self) -> bool: + """Return True while the recognition worker may still deliver text to text callbacks.""" + ... + def register_state_callback(self, callback: Callable[[RecognitionState], None]) -> None: """Register a callback for state changes.""" ... diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index a241717..9f13124 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -454,6 +454,8 @@ class SpeechRecognitionManager: self.state = RecognitionState.IDLE self.audio_thread: Optional[threading.Thread] = None self.recognition_thread: Optional[threading.Thread] = None + self._recognition_workers: list[threading.Thread] = [] + self._recognition_workers_lock = threading.Lock() self.model: Any = None self.recognizer = None # Added for VOSK self.command_processor = CommandProcessor(language=self.language) @@ -3359,6 +3361,7 @@ class SpeechRecognitionManager: target = self._reload_and_recognize if reload_in_background else self._perform_recognition self.recognition_thread = threading.Thread(target=target) self.recognition_thread.daemon = True + self._track_recognition_worker(self.recognition_thread) self.recognition_thread.start() # After the threads exist: a start that returned early must not duck. self._arm_playback_duck() @@ -3443,6 +3446,28 @@ class SpeechRecognitionManager: self._recognition_mode = "toggle" self._update_state(RecognitionState.IDLE) + def _track_recognition_worker(self, worker: threading.Thread) -> None: + """Remember a recognition worker until it exits so late text is still pending.""" + with self._recognition_workers_lock: + self._recognition_workers = [w for w in self._recognition_workers if w.is_alive()] + self._recognition_workers.append(worker) + + def has_pending_transcription(self) -> bool: + """Return True while any recognition worker may still deliver text to text_callbacks. + + A worker from an earlier session can outlive stop_recognition()'s bounded + joins and still be transcribing after a new session replaced + ``recognition_thread``. Audio left in the queue after every worker exited + has no consumer outside a buffered reload (tracked by + ``_buffered_reload_session``), so it never counts. + """ + with self._recognition_workers_lock: + workers = list(self._recognition_workers) + if self.recognition_thread is not None: + workers.append(self.recognition_thread) + current = threading.current_thread() + return any(worker is not current and worker.is_alive() for worker in workers) + def _reload_and_recognize(self) -> None: """Reload an idle model while the recording thread captures speech.""" try: diff --git a/src/vocalinux/ui/dictation_pad.py b/src/vocalinux/ui/dictation_pad.py index 901ae13..95efd69 100644 --- a/src/vocalinux/ui/dictation_pad.py +++ b/src/vocalinux/ui/dictation_pad.py @@ -209,10 +209,8 @@ class DictationPad: self._window: Any = None self._textview: Any = None self._buffer: Any = None - self._capture_check: Any = None self._copy_button: Any = None self._gtk_ready = False - self._syncing_capture_check = False self._syncing_widget = False # Compositor-side shelving state. _window_iconified is tracked from # window-state-event (X11 minimize); _surface_shelved is detected by @@ -281,18 +279,6 @@ class DictationPad: outer.set_margin_bottom(12) window.add(outer) - self._capture_check = Gtk.CheckButton( - label="Dictate into this window instead of injecting into other apps" - ) - self._capture_check.set_tooltip_text( - "Fallback for desktops where text injection does not work " - "(e.g. Wayland): dictated text lands in this box, then you copy " - "it out by hand." - ) - self._capture_check.set_active(self.controller.enabled) - self._capture_check.connect("toggled", self._on_capture_toggled) - outer.pack_start(self._capture_check, False, False, 0) - scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.set_hexpand(True) @@ -369,10 +355,10 @@ class DictationPad: return self.controller.last_segment def show_pad(self) -> None: - """Show the pad window, resyncing capture state from live config.""" + """Show the pad window, refreshing its routing state from live config.""" if not self._gtk_ready or self._window is None: return - self._sync_capture_check() + self._capture_enabled() if not self._window.get_visible(): self._window.show_all() elif self._surface_shelved: @@ -400,9 +386,8 @@ class DictationPad: pass def set_capture_enabled(self, enabled: bool) -> None: - """Programmatically flip capture mode (keeps the checkbox in sync).""" + """Update the controller's routing state without persisting it.""" self.controller.set_enabled(enabled) - self._sync_capture_check() def destroy(self) -> None: """Tear down the window and any pending feedback timer.""" @@ -628,19 +613,6 @@ class DictationPad: self.controller.set_enabled(enabled) return enabled - def _sync_capture_check(self) -> None: - """Mirror the persisted capture setting onto the checkbox.""" - if self._capture_check is None: - return - try: - enabled = self._capture_enabled() - self._syncing_capture_check = True - self._capture_check.set_active(enabled) - except _ui_errors() as e: - logger.debug("Could not sync dictation pad toggle: %s", e) - finally: - self._syncing_capture_check = False - def _on_delete_event(self, *_args: Any) -> bool: """Hide on close instead of destroying — the buffer is session-scoped.""" if self._window is not None: @@ -694,20 +666,6 @@ class DictationPad: logger.debug("Could not repaint dictation pad: %s", e) return False - def _on_capture_toggled(self, widget: Any) -> None: - """Persist the pad's own capture checkbox.""" - if self._syncing_capture_check: - return - enabled = bool(widget.get_active()) - self.controller.set_enabled(enabled) - if self._config_manager is not None: - try: - self._config_manager.set("text_injection", "dictate_to_pad", enabled) - self._config_manager.save_settings() - except (AttributeError, TypeError) as e: - logger.warning("Could not save dictation pad setting: %s", e) - logger.info("Dictation pad capture %s", "enabled" if enabled else "disabled") - def _on_copy_all_clicked(self, *_args: Any) -> None: """Copy the entire buffer to the clipboard and flash a confirmation.""" # The widget's text may include the user's own in-pad edits; fall back diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 29a6e57..ff83c47 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2494,6 +2494,7 @@ class SettingsDialog(AboutSectionMixin): language_shortcuts_update_callback: Optional[Callable[[], None]] = None, history_update_callback: Optional[Callable[[], None]] = None, history_clear_callback: Optional[Callable[[], None]] = None, + dictation_pad_open_callback: Optional[Callable[[], None]] = None, ) -> None: super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0) # Force window decorations (title-bar close) on all WMs. An in-window @@ -2513,6 +2514,7 @@ class SettingsDialog(AboutSectionMixin): self.language_shortcuts_update_callback = language_shortcuts_update_callback self.history_update_callback = history_update_callback self.history_clear_callback = history_clear_callback + self.dictation_pad_open_callback = dictation_pad_open_callback # Per-language shortcut rows (#805): a dict of row widgets per binding, # populated by _build_language_shortcuts_section. self._language_shortcut_rows: list[dict] = [] @@ -4347,6 +4349,29 @@ class SettingsDialog(AboutSectionMixin): logger.info(f"Dictate to pad {'enabled' if enabled else 'disabled'}") return False + def _add_dictation_pad_open_row(self, group: PreferencesGroup) -> None: + """Add the optional Dictation Pad launcher row.""" + self.dictation_pad_open_button: Optional[Gtk.Button] = None + if self.dictation_pad_open_callback is None: + return + button = Gtk.Button(label="Open") + button.connect("clicked", self._on_dictation_pad_open_clicked) + group.add_row( + PreferenceRow( + title="Open Dictation Pad", + subtitle="Show the Dictation Pad window", + widget=button, + keywords=("pad", "window", "open"), + ) + ) + self.dictation_pad_open_button = button + + def _on_dictation_pad_open_clicked(self, _button: Gtk.Button) -> None: + """Open the Dictation Pad when its optional launcher is available.""" + callback = self.dictation_pad_open_callback + if callback is not None: + callback() + def _on_auto_capitalize_toggled(self, widget, state): """Handle toggle of the auto-capitalize switch.""" if _handlers_suppressed(self): @@ -4921,16 +4946,16 @@ class SettingsDialog(AboutSectionMixin): self.dictation_pad_switch = _add_switch_row( output_group, - title="Dictation Pad", - subtitle="Capture dictation in an in-app text box instead of other apps", + title="Dictate into Dictation Pad", + subtitle="Send dictation to the Dictation Pad window instead of other apps", keywords=("wayland", "pad", "fallback", "text box"), tooltip=( - "Type dictation into Vocalinux's own Dictation Pad window instead " - "of injecting it into other apps. The pad opens from the tray menu; " - "copy text out of it by hand. Useful on Wayland, where injecting " + "Send dictation to the Dictation Pad instead of injecting it into " + "other apps. Copy text out by hand. Useful on Wayland, where injecting " "keystrokes into other windows is restricted." ), ) + self._add_dictation_pad_open_row(output_group) self.append_trailing_space_switch = _add_switch_row( output_group, @@ -9255,21 +9280,26 @@ class SettingsDialog(AboutSectionMixin): time.sleep(delay) GLib.idle_add(self._finalize_test) - def _finalize_test(self): + def _test_engine_busy(self) -> bool: + """Return whether recognition can still deliver text to the test callback.""" + state = getattr(self.speech_engine, "state", RecognitionState.IDLE) + has_pending_transcription = getattr(self.speech_engine, "has_pending_transcription", None) + return ( + state in (RecognitionState.LISTENING, RecognitionState.PROCESSING) + or getattr(self.speech_engine, "_buffered_reload_session", False) + or (callable(has_pending_transcription) and has_pending_transcription() is True) + ) + + def _finalize_test(self) -> bool: """Finalize the test state and UI updates.""" if not self._test_active: return False self.speech_engine.stop_recognition() - # Buffered reload's stop_recognition() returns while PROCESSING; the - # worker may still deliver the test utterance via current text_callbacks. - # Keep the test callbacks installed (and block auto-apply / re-test) - # until recognition reaches IDLE so we never restore the live injector - # early. Use GLib polling — never join the worker on the GTK thread. - state = getattr(self.speech_engine, "state", RecognitionState.IDLE) - buffered = getattr(self.speech_engine, "_buffered_reload_session", False) - if state in (RecognitionState.LISTENING, RecognitionState.PROCESSING) or buffered: + # Keep test callbacks installed until no worker can deliver the test + # utterance. Use GLib polling rather than joining on the GTK thread. + if self._test_engine_busy(): self.test_button.set_sensitive(False) self.test_button.set_label("Processing…") self.update_recognition_progress("Processing") @@ -9278,8 +9308,7 @@ class SettingsDialog(AboutSectionMixin): GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks) return False - # Non-buffered stop already joined to IDLE; brief settle for any - # GLib.idle_add text delivery still in flight. + # Briefly settle for any GLib.idle_add text delivery already in flight. GLib.timeout_add(500, self._restore_callbacks_and_check_result) self._test_active = False @@ -9293,12 +9322,12 @@ class SettingsDialog(AboutSectionMixin): def _wait_for_idle_then_restore_callbacks(self) -> bool: """Poll until recognition is idle, then restore callbacks / check result. - Returns True so GLib.timeout_add keeps scheduling while the buffered - reload worker is still PROCESSING. + Returns True so GLib.timeout_add keeps scheduling while recognition + may still deliver text. On the ~3 minute timeout, never restore live injector callbacks while a - buffered reload worker may still deliver text (privacy-adjacent Test - Dictation inject-into-app race). Prefer cancelling via + recognition worker may still deliver text (privacy-adjacent Test + Dictation inject-into-app race). Prefer cancelling a buffered reload via ``_cancel_reload_recording`` (off the GTK thread — it joins) and only then restoring. If cancel is unavailable or fails, keep polling with test callbacks and ``_test_active`` so live injectors are not restored @@ -9310,13 +9339,7 @@ class SettingsDialog(AboutSectionMixin): self.test_button.set_label("Test Dictation") return False - state = getattr(self.speech_engine, "state", RecognitionState.IDLE) - still_busy = state in ( - RecognitionState.LISTENING, - RecognitionState.PROCESSING, - ) or getattr(self.speech_engine, "_buffered_reload_session", False) - - if still_busy: + if self._test_engine_busy(): self._test_idle_wait_ticks = getattr(self, "_test_idle_wait_ticks", 0) + 1 # ~3 minutes at 100ms — generous for cold large-model reload. if self._test_idle_wait_ticks < 1800: @@ -9333,7 +9356,8 @@ class SettingsDialog(AboutSectionMixin): def _on_test_idle_wait_timeout(self) -> bool: """Handle idle-wait timeout without restoring live injectors early.""" cancel = getattr(self.speech_engine, "_cancel_reload_recording", None) - if callable(cancel): + buffered_reload = getattr(self.speech_engine, "_buffered_reload_session", False) is True + if buffered_reload and callable(cancel): logger.warning( "Test Dictation: timed out waiting for recognition IDLE; " "cancelling buffered reload before restoring callbacks" @@ -9349,8 +9373,8 @@ class SettingsDialog(AboutSectionMixin): return False logger.error( - "Test Dictation: timed out waiting for recognition IDLE with no " - "cancel API; keeping test callbacks until IDLE" + "Test Dictation: timed out waiting for recognition IDLE with no safe " + "buffered cancel; keeping test callbacks until idle" ) self._keep_waiting_after_timeout( "(Timed out waiting for recognition to finish. " @@ -9385,7 +9409,10 @@ class SettingsDialog(AboutSectionMixin): return False def _finish_test_after_buffered_cancel(self) -> bool: - """Restore live callbacks only after cancel has discarded the session.""" + """Resume waiting if recognition can still deliver text after cancellation.""" + if self._test_engine_busy(): + GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks) + return False return self._finish_test_restore_ui() def _keep_waiting_after_timeout(self, message: str) -> None: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index b26096b..c805e1a 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -1319,7 +1319,7 @@ class TrayIndicator: logger.debug("Settings clicked") self._show_settings_page(None) - def _show_settings_page(self, page_name: Optional[str]): + def _show_settings_page(self, page_name: Optional[str]) -> None: """Open settings, optionally focused on a specific sidebar page.""" dialog = self._settings_dialog if dialog is not None: @@ -1349,6 +1349,9 @@ class TrayIndicator: language_shortcuts_update_callback=self.refresh_language_shortcuts, history_update_callback=self._apply_history_settings, history_clear_callback=self.clear_transcription_history, + dictation_pad_open_callback=( + self.dictation_pad.show_pad if self.dictation_pad is not None else None + ), ) dialog.connect("response", self._on_settings_dialog_response) dialog.connect("destroy", self._on_settings_dialog_destroyed) diff --git a/tests/test_auto_pause_monitor.py b/tests/test_auto_pause_monitor.py index 70a5968..a1d329b 100644 --- a/tests/test_auto_pause_monitor.py +++ b/tests/test_auto_pause_monitor.py @@ -7,6 +7,7 @@ callbacks — not a parallel reimplementation of the match logic. import os import sys +import threading import unittest from unittest.mock import MagicMock, patch @@ -461,6 +462,8 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase): mgr._faster_whisper_engine = None mgr._reconnection_attempts = 0 mgr._update_state = MagicMock() + mgr._recognition_workers = [] + mgr._recognition_workers_lock = threading.Lock() mgr._init_vosk = MagicMock() mgr._init_whisper = MagicMock() mgr._init_whispercpp = MagicMock() diff --git a/tests/test_dictation_pad.py b/tests/test_dictation_pad.py index ab8ed5b..934c272 100644 --- a/tests/test_dictation_pad.py +++ b/tests/test_dictation_pad.py @@ -294,22 +294,45 @@ class TestDictationPadFacade(unittest.TestCase): finally: pad.destroy() - def test_capture_checkbox_persists_config(self) -> None: - """The pad checkbox writes dictate_to_pad via the config manager.""" + def test_pad_actions_never_write_config(self) -> None: + """The pad receives edits but never persists the routing setting.""" config_manager = MagicMock() with patch.object( DictationPad, "_init_gtk_window", side_effect=RuntimeError("gtk disabled in unit tests"), ): - pad = DictationPad(enabled=False, config_manager=config_manager) + pad = DictationPad(enabled=True, config_manager=config_manager) try: - widget = MagicMock() - widget.get_active.return_value = True - pad._on_capture_toggled(widget) - config_manager.set.assert_called_once_with("text_injection", "dictate_to_pad", True) - config_manager.save_settings.assert_called_once() + pad.append_text("hello world") + pad.handle_action("undo") + pad.handle_action("redo") + pad.delete_last_chars(5) + pad.set_capture_enabled(False) + pad._on_clear_clicked() + + config_manager.set.assert_not_called() + config_manager.save_settings.assert_not_called() + finally: + pad.destroy() + + def test_controller_routing_state_tracks_external_config_changes(self) -> None: + """The pad reads Settings changes and syncs its controller without writing.""" + config_manager = MagicMock() + config_manager.get_bool.side_effect = [False, True] + with patch.object( + DictationPad, + "_init_gtk_window", + side_effect=RuntimeError("gtk disabled in unit tests"), + ): + pad = DictationPad(enabled=True, config_manager=config_manager) + try: + self.assertFalse(pad._capture_enabled()) + self.assertFalse(pad.controller.enabled) + self.assertTrue(pad._capture_enabled()) self.assertTrue(pad.controller.enabled) + config_manager.set.assert_not_called() + config_manager.save_settings.assert_not_called() finally: pad.destroy() diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py index 0462b55..56a2009 100644 --- a/tests/test_recognition_manager_advanced.py +++ b/tests/test_recognition_manager_advanced.py @@ -1096,5 +1096,194 @@ class TestVoiceCommandsProperty(unittest.TestCase): self.assertFalse(mgr._resolve_voice_commands_enabled()) +class TestPendingTranscription(unittest.TestCase): + def test_live_non_current_worker_is_pending(self) -> None: + manager = _make_manager() + started = threading.Event() + release = threading.Event() + + def worker() -> None: + started.set() + release.wait(timeout=2.0) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + try: + self.assertTrue(started.wait(timeout=1.0)) + manager.recognition_thread = thread + self.assertTrue(manager.has_pending_transcription()) + finally: + release.set() + thread.join(timeout=1.0) + + def test_superseded_worker_is_still_pending(self) -> None: + manager = _make_manager() + started = threading.Event() + release = threading.Event() + + def worker_a() -> None: + started.set() + release.wait(timeout=2.0) + + def worker_b() -> None: + return + + first = threading.Thread(target=worker_a, daemon=True) + manager._track_recognition_worker(first) + first.start() + self.assertTrue(started.wait(timeout=1.0)) + + second = threading.Thread(target=worker_b, daemon=True) + second.start() + second.join(timeout=1.0) + manager.recognition_thread = second + + try: + self.assertTrue(manager.has_pending_transcription()) + finally: + release.set() + first.join(timeout=1.0) + + self.assertFalse(manager.has_pending_transcription()) + + def test_track_recognition_worker_prunes_finished_workers(self) -> None: + manager = _make_manager() + + def finished_worker() -> None: + return + + finished = threading.Thread(target=finished_worker, daemon=True) + finished.start() + finished.join(timeout=1.0) + manager._track_recognition_worker(finished) + + started = threading.Event() + release = threading.Event() + + def active_worker() -> None: + started.set() + release.wait(timeout=2.0) + + active = threading.Thread(target=active_worker, daemon=True) + manager._track_recognition_worker(active) + active.start() + + try: + self.assertTrue(started.wait(timeout=1.0)) + self.assertEqual(manager._recognition_workers, [active]) + finally: + release.set() + active.join(timeout=1.0) + + def test_start_recognition_tracks_worker(self) -> None: + manager = _make_manager() + manager._model_initialized = True + manager.model = MagicMock() + started = threading.Event() + release = threading.Event() + + def record_audio() -> None: + return + + def perform_recognition() -> None: + started.set() + release.wait(timeout=2.0) + + manager._record_audio = record_audio + manager._perform_recognition = perform_recognition + + try: + with ( + patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"), + patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"), + patch.object(manager, "_arm_playback_duck"), + ): + self.assertTrue(manager.start_recognition()) + worker = manager.recognition_thread + self.assertIsNotNone(worker) + self.assertIn(worker, manager._recognition_workers) + self.assertTrue(started.wait(timeout=1.0)) + release.set() + manager.stop_recognition() + finally: + release.set() + if manager.recognition_thread is not None: + manager.recognition_thread.join(timeout=1.0) + + def test_queued_audio_without_live_worker_is_not_pending(self) -> None: + manager = _make_manager() + manager._segment_queue.put(([], "en")) + + self.assertFalse(manager.has_pending_transcription()) + + def test_idle_manager_without_queued_audio_is_not_pending(self) -> None: + manager = _make_manager() + + self.assertFalse(manager.has_pending_transcription()) + + def test_stop_recognition_signal_is_not_pending(self) -> None: + manager = _make_manager() + manager.state = RecognitionState.LISTENING + manager.should_record = True + started = threading.Event() + + def worker() -> None: + started.set() + manager._perform_recognition() + + thread = threading.Thread(target=worker, daemon=True) + manager.recognition_thread = thread + thread.start() + self.assertTrue(started.wait(timeout=1.0)) + + with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"): + manager.stop_recognition() + + self.assertFalse(manager.has_pending_transcription()) + + def test_orphaned_final_segment_is_not_pending(self) -> None: + manager = _make_manager() + manager.state = RecognitionState.LISTENING + manager.should_record = True + started = threading.Event() + audio_release = threading.Event() + + def worker() -> None: + started.set() + manager._perform_recognition() + + def audio_worker() -> None: + audio_release.wait(timeout=1.0) + + recognition_thread = threading.Thread(target=worker, daemon=True) + manager.recognition_thread = recognition_thread + recognition_thread.start() + self.assertTrue(started.wait(timeout=1.0)) + + audio_thread = threading.Thread(target=audio_worker, daemon=True) + manager.audio_thread = audio_thread + audio_thread.start() + manager.audio_buffer = [b"\x00\x00"] * 8 + manager._recording_segment_has_speech = True + + try: + with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"): + manager.stop_recognition() + finally: + audio_release.set() + audio_thread.join(timeout=1.0) + recognition_thread.join(timeout=1.0) + + with manager._segment_queue.mutex: + self.assertTrue(any(item is not None for item in manager._segment_queue.queue)) + self.assertFalse(manager.has_pending_transcription()) + + def test_worker_does_not_count_itself_as_pending(self) -> None: + manager = _make_manager() + manager.recognition_thread = threading.current_thread() + + self.assertFalse(manager.has_pending_transcription()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_settings_test_dictation.py b/tests/test_settings_test_dictation.py index 2880af4..08941fb 100644 --- a/tests/test_settings_test_dictation.py +++ b/tests/test_settings_test_dictation.py @@ -252,6 +252,7 @@ def _dialog_for_finalize( buffered_reload: bool = False, saved_callbacks: Optional[list[Any]] = None, has_cancel: bool = True, + pending_transcription: bool = False, ) -> Mock: """Minimal dialog wired for _finalize_test / idle-wait helpers.""" dialog = Mock() @@ -281,6 +282,7 @@ def _dialog_for_finalize( ) dialog._keep_waiting_after_timeout = SettingsDialog._keep_waiting_after_timeout.__get__(dialog) dialog._finish_test_restore_ui = SettingsDialog._finish_test_restore_ui.__get__(dialog) + dialog._test_engine_busy = SettingsDialog._test_engine_busy.__get__(dialog) dialog._check_test_result = Mock(return_value=False) if has_cancel: @@ -294,16 +296,72 @@ def _dialog_for_finalize( "_buffered_reload_session", "stop_recognition", "set_text_callbacks", + "has_pending_transcription", ] ) engine.state = state engine._buffered_reload_session = buffered_reload + engine.has_pending_transcription = Mock(return_value=pending_transcription) engine.stop_recognition = Mock() engine.set_text_callbacks = Mock() dialog.speech_engine = engine return dialog +def test_open_dictation_pad_row_invokes_callback_on_click() -> None: + callback = Mock() + dialog = Mock() + dialog.dictation_pad_open_callback = callback + dialog._on_dictation_pad_open_clicked = SettingsDialog._on_dictation_pad_open_clicked.__get__( + dialog + ) + group = Mock() + button = Mock() + row = Mock() + + with ( + patch.object(settings_dialog.Gtk, "Button", return_value=button), + patch.object(settings_dialog, "PreferenceRow", return_value=row) as row_factory, + ): + SettingsDialog._add_dictation_pad_open_row(dialog, group) + + group.add_row.assert_called_once_with(row) + row_factory.assert_called_once_with( + title="Open Dictation Pad", + subtitle="Show the Dictation Pad window", + widget=button, + keywords=("pad", "window", "open"), + ) + click_callback = button.connect.call_args.args[1] + click_callback(button) + callback.assert_called_once_with() + + +def test_open_dictation_pad_row_is_absent_without_callback() -> None: + dialog = Mock() + dialog.dictation_pad_open_callback = None + group = Mock() + + with patch.object(settings_dialog.Gtk, "Button") as button_factory: + SettingsDialog._add_dictation_pad_open_row(dialog, group) + + button_factory.assert_not_called() + group.add_row.assert_not_called() + assert dialog.dictation_pad_open_button is None + + +def test_dictation_pad_switch_persists_routing_preference() -> None: + dialog = Mock() + dialog._initializing = False + dialog._applying_settings = False + dialog.config_manager = Mock() + + assert SettingsDialog._on_dictation_pad_toggled(dialog, Mock(), True) is False + + dialog.config_manager.set.assert_called_once_with("text_injection", "dictate_to_pad", True) + dialog.config_manager.save_settings.assert_called_once() + + def test_finalize_test_buffered_reload_waits_for_idle_before_restore() -> None: """stop_recognition may return while PROCESSING; keep test callbacks.""" dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True) @@ -354,7 +412,7 @@ def test_wait_for_idle_treats_buffered_flag_as_busy_even_if_state_idle() -> None def test_finalize_test_idle_path_uses_settle_delay() -> None: - """Non-buffered stop already reached IDLE — keep the legacy 500ms settle.""" + """When no work remains, keep the legacy 500ms settle.""" dialog = _dialog_for_finalize(state=RecognitionState.IDLE, buffered_reload=False) with patch.object(settings_dialog, "GLib") as glib: @@ -368,6 +426,40 @@ def test_finalize_test_idle_path_uses_settle_delay() -> None: assert hasattr(dialog, "_saved_text_callbacks") +def test_finalize_waits_for_pending_transcription_and_keeps_test_callback() -> None: + """Late recognition text stays on the test path after state becomes IDLE.""" + live_text: list[str] = [] + + def live_callback(text: str) -> None: + live_text.append(text) + + dialog = _dialog_for_finalize(state=RecognitionState.IDLE) + dialog._saved_text_callbacks = [live_callback] + dialog.speech_engine.has_pending_transcription = Mock(side_effect=[True, True, False]) + dialog._test_text_callback = SettingsDialog._test_text_callback.__get__(dialog) + dialog._append_test_result = SettingsDialog._append_test_result.__get__(dialog) + + with patch.object(settings_dialog, "GLib") as glib: + glib.idle_add.side_effect = lambda callback, *args: callback(*args) + SettingsDialog._finalize_test(dialog) + + dialog.speech_engine.set_text_callbacks.assert_not_called() + assert dialog._test_active is True + + dialog._test_text_callback("hello world") + assert dialog.test_buffer._text == "hello world" + assert live_text == [] + + assert SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) is True + dialog.speech_engine.set_text_callbacks.assert_not_called() + assert SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) is False + + dialog.speech_engine.set_text_callbacks.assert_called_once_with([live_callback]) + assert live_text == [] + assert not hasattr(dialog, "_saved_text_callbacks") + glib.timeout_add.assert_any_call(100, dialog._wait_for_idle_then_restore_callbacks) + + def test_timeout_does_not_restore_live_while_buffered_starts_cancel() -> None: """At ~3min timeout while buffered, cancel off-thread; do not restore yet.""" dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True) @@ -387,6 +479,26 @@ def test_timeout_does_not_restore_live_while_buffered_starts_cancel() -> None: assert thread_cls.call_args.kwargs["daemon"] is True +def test_nonbuffered_timeout_keeps_waiting_for_pending_transcription() -> None: + """Do not invoke buffered cancellation for a non-buffered active worker.""" + dialog = _dialog_for_finalize( + state=RecognitionState.IDLE, + pending_transcription=True, + ) + dialog._test_idle_wait_ticks = 1799 + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + result = SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) + + assert result is True + thread_cls.assert_not_called() + dialog.speech_engine._cancel_reload_recording.assert_not_called() + dialog.speech_engine.set_text_callbacks.assert_not_called() + assert dialog._test_active is True + assert dialog._test_idle_wait_ticks == 1800 + assert dialog._test_timeout_cancel_attempted is True + + def test_timeout_without_cancel_keeps_waiting_active() -> None: """If cancel API is missing, keep polling with test callbacks / _test_active.""" dialog = _dialog_for_finalize( @@ -429,6 +541,21 @@ def test_finish_after_buffered_cancel_restores_live_callbacks() -> None: glib.timeout_add.assert_called_once_with(300, dialog._check_test_result) +def test_finish_after_buffered_cancel_resumes_waiting_if_work_remains() -> None: + dialog = _dialog_for_finalize( + state=RecognitionState.IDLE, + pending_transcription=True, + ) + + with patch.object(settings_dialog, "GLib") as glib: + result = SettingsDialog._finish_test_after_buffered_cancel(dialog) + + assert result is False + dialog.speech_engine.set_text_callbacks.assert_not_called() + assert dialog._test_active is True + glib.timeout_add.assert_called_once_with(100, dialog._wait_for_idle_then_restore_callbacks) + + def test_cancel_buffered_reload_then_restore_schedules_finish() -> None: dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True) diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index ebc821e..97454e9 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -1024,6 +1024,23 @@ class TestTrayIndicator(unittest.TestCase): self.tray_indicator.set_overlay_enabled, ) + def test_settings_dialog_receives_dictation_pad_open_callback(self) -> None: + """Settings receives the live pad's show callback when one exists.""" + import vocalinux.ui.tray_indicator as tray_module + + pad = MagicMock() + self.tray_indicator.dictation_pad = pad + self.tray_indicator._settings_dialog = None + mock_dialog_class = MagicMock(return_value=MagicMock()) + + with patch.object(tray_module, "SettingsDialog", mock_dialog_class): + self.tray_indicator._on_settings_clicked(None) + + self.assertIs( + mock_dialog_class.call_args.kwargs["dictation_pad_open_callback"], + pad.show_pad, + ) + def test_quit_destroys_overlay(self): mock_overlay = MagicMock() self.tray_indicator.overlay = mock_overlay