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
This commit is contained in:
Jatin K Malik
2026-10-21 01:20:14 -07:00
committed by GitHub
parent 3e4230ffc2
commit 9b5cdf686f
10 changed files with 462 additions and 86 deletions
+4
View File
@@ -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."""
...
@@ -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:
+3 -45
View File
@@ -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
+58 -31
View File
@@ -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:
+4 -1
View File
@@ -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)
+3
View File
@@ -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()
+31 -8
View File
@@ -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()
+189
View File
@@ -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()
+128 -1
View File
@@ -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)
+17
View File
@@ -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