From 40bc31bebf30d4e36be335ff2572f4be55ac0ad2 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 7 Sep 2026 14:11:54 -0700 Subject: [PATCH] fix(settings): stop freezing when applying an already-downloaded model (#790) * fix(settings): apply already-downloaded models off the GTK main loop Changing Model Size, Specialization, or Language froze Settings when the model was already on disk, because reconfigure ran in the changed handler. Move that apply onto a worker thread (same shape as the download path) and keep the apply-guard held until GLib.idle_add clears it so a second pick cannot start a parallel load. Fixes #789. * fix(settings): guard Test/apply while downloaded-model worker runs Block apply_settings and Test Dictation while _applying_settings is held so a concurrent Test click cannot race the shared engine. Annotate the new worker/helpers per AGENTS.md and cover the no-op/block behavior. * fix(settings): resync pickers when a second pick lands mid-apply While an already-downloaded model worker was running, Model Size / Specialization / Language could still move. The apply guard blocked saving the new pick, then finish only cleared the flag, so the UI could show B with dictation still on A. On finish, if the selection no longer matches the saved config, put the pickers back. Idle resyncs from workers now skip a destroyed dialog the same way update check does. * fix(settings): always resync pickers after model apply finishes A second Model Size pick on whisper.cpp could move the size combo while the apply guard skipped rebuilding specialization, so selected settings still looked like the saved variant and finish skipped the resync. Always put the pickers back on the saved config when the worker ends, and restore language from that config too. * test(settings): prove mid-apply picker handlers round-trip on finish Call the real size, specialization, and language handlers while the apply guard is held, and run finish through the real resync path so whisper.cpp size and language cannot silently stick on the second pick. --- src/vocalinux/ui/settings_dialog.py | 79 ++++- tests/test_download_error_reporting.py | 4 +- tests/test_settings_model_state.py | 461 ++++++++++++++++++++++++- tests/test_settings_test_dictation.py | 1 + 4 files changed, 513 insertions(+), 32 deletions(-) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 497c65f..897bf31 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -5186,6 +5186,8 @@ class SettingsDialog(Gtk.Dialog): """Handle changes in the selected model.""" if self._populating_models: return + if self._initializing or self._applying_settings: + return if self._get_selected_engine() == "whisper_cpp": model_size = self.model_combo.get_active_id() @@ -5205,6 +5207,8 @@ class SettingsDialog(Gtk.Dialog): """Handle changes in the selected whisper.cpp specialization.""" if self._populating_models: return + if self._initializing or self._applying_settings: + return self._sync_language_options_for_selected_model() self._update_model_info() @@ -5291,6 +5295,8 @@ class SettingsDialog(Gtk.Dialog): """Handle language selection change.""" if self._processing_language_change: return + if self._initializing or self._applying_settings: + return lang_code = self.language_combo.get_active_id() if not lang_code: @@ -5480,6 +5486,9 @@ class SettingsDialog(Gtk.Dialog): return self._applying_settings = True + # Already-downloaded apply is handed to a worker that clears this flag + # via GLib.idle_add. Download still holds it for the modal run() below. + worker_holds_guard = False try: settings = self.get_selected_settings() engine = settings.get("engine", "vosk") @@ -5539,7 +5548,7 @@ class SettingsDialog(Gtk.Dialog): # False return must not be read as success: it # means nothing was saved and the pickers still # show settings the engine never took. - GLib.idle_add(self._resync_model_ui_from_config) + GLib.idle_add(self._idle_resync_model_ui_from_config) GLib.idle_add( download_dialog.set_complete, False, @@ -5555,7 +5564,7 @@ class SettingsDialog(Gtk.Dialog): # The settings were never saved, so the previously working # model is still the configured one; put the pickers back on # it so the UI matches the config and a retry is possible. - GLib.idle_add(self._resync_model_ui_from_config) + GLib.idle_add(self._idle_resync_model_ui_from_config) if "cancelled" in error_msg.lower(): GLib.idle_add( download_dialog.set_complete, @@ -5582,18 +5591,43 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Auto-applying settings: {settings}") - was_running = self.speech_engine.state != RecognitionState.IDLE - if was_running: - self.speech_engine.stop_recognition() + def apply_already_downloaded() -> None: + try: + self._apply_settings_internal(settings, raise_errors=True) + logger.info("Settings auto-applied successfully") + except Exception as e: + logger.error(f"Failed to auto-apply settings: {e}") + GLib.idle_add(self._idle_resync_model_ui_from_config) + finally: + GLib.idle_add(self._finish_auto_apply) - self.speech_engine.reconfigure(**settings) - self._save_selected_settings(settings) - logger.info("Settings auto-applied successfully") + threading.Thread(target=apply_already_downloaded, daemon=True).start() + worker_holds_guard = True + return except Exception as e: logger.error(f"Failed to auto-apply settings: {e}") self._resync_model_ui_from_config() finally: - self._applying_settings = False + if not worker_holds_guard: + self._applying_settings = False + + def _finish_auto_apply(self) -> bool: + """Release the apply-guard after an already-downloaded worker finishes. + + Always resync while the dialog is alive: handlers early-return during + apply, so selected settings can still match the saved config even when + a combo (whisper.cpp size) has already moved. + """ + self._applying_settings = False + if self._dialog_is_alive(): + self._resync_model_ui_from_config() + return False + + def _idle_resync_model_ui_from_config(self) -> bool: + """Main-loop resync from a worker thread; no-op if the dialog is gone.""" + if self._dialog_is_alive(): + self._resync_model_ui_from_config() + return False def _resync_model_ui_from_config(self): """Put the pickers back on the settings that are actually saved. @@ -5604,9 +5638,8 @@ class SettingsDialog(Gtk.Dialog): signal. """ try: - saved_engine = ( - self.config_manager.get_settings().get("speech_recognition", {}).get("engine") - ) + sr_config = self.config_manager.get_settings().get("speech_recognition", {}) + saved_engine = sr_config.get("engine") if saved_engine: display = _engine_display_name(saved_engine) if self.engine_combo.get_active_text() != display: @@ -5620,6 +5653,9 @@ class SettingsDialog(Gtk.Dialog): finally: self._applying_settings = was_applying self._populate_model_options() + saved_language = sr_config.get("language") + if saved_language: + self._sync_language_options_for_selected_model(saved_language) self._update_model_info() except Exception as e: # pragma: no cover - UI resync must never mask the original error logger.debug(f"Could not resync model pickers: {e}") @@ -5734,6 +5770,13 @@ class SettingsDialog(Gtk.Dialog): logger.warning("Test already in progress.") return + if self._applying_settings: + # The live engine may still be mid-reconfigure even when the UI + # already matches the saved config. Do not apply or start a test. + self.test_output_revealer.set_reveal_child(True) + self.test_buffer.set_text("Settings are still applying. Try Test again in a moment.") + return + current_config = self.config_manager.get_settings().get("speech_recognition", {}) selected_settings = self.get_selected_settings() selected_engine = selected_settings.get("engine") @@ -5893,8 +5936,12 @@ For now, the engine has been reverted to VOSK.""" self._populate_model_options() self._update_engine_specific_ui() - def apply_settings(self): + def apply_settings(self) -> bool: """Apply the selected settings.""" + if self._applying_settings: + logger.warning("Ignoring apply_settings(); another apply is already in progress") + return False + settings = self.get_selected_settings() logger.info(f"Applying settings: {settings}") @@ -5918,7 +5965,7 @@ For now, the engine has been reverted to VOSK.""" # Same guard as in _auto_apply_settings: one download at a time. self._show_download_busy_dialog() self._resync_model_ui_from_config() - return + return False download_dialog = ModelDownloadDialog( self, model_name, @@ -5946,7 +5993,7 @@ For now, the engine has been reverted to VOSK.""" if applied: GLib.idle_add(download_dialog.set_complete, True, "") else: - GLib.idle_add(self._resync_model_ui_from_config) + GLib.idle_add(self._idle_resync_model_ui_from_config) GLib.idle_add( download_dialog.set_complete, False, @@ -5961,7 +6008,7 @@ For now, the engine has been reverted to VOSK.""" error_msg = str(e) # Nothing was saved, so the config still names the previous # engine and model; put the pickers back on them. - GLib.idle_add(self._resync_model_ui_from_config) + GLib.idle_add(self._idle_resync_model_ui_from_config) if "cancelled" in error_msg.lower(): GLib.idle_add(download_dialog.set_complete, False, "Download cancelled") elif engine == "whisper" and "no module named" in error_msg.lower(): diff --git a/tests/test_download_error_reporting.py b/tests/test_download_error_reporting.py index 88c25bf..b202179 100644 --- a/tests/test_download_error_reporting.py +++ b/tests/test_download_error_reporting.py @@ -101,9 +101,9 @@ def test_success_still_returns_true(): def test_both_download_threads_ask_for_the_failure(): """Source guard: every _apply_settings_internal call made off the main loop passes raise_errors=True, so a regression cannot silently reintroduce the - swallowed-error path in a download thread.""" + swallowed-error path on a worker thread.""" import inspect source = inspect.getsource(settings_dialog) thread_calls = source.count("_apply_settings_internal(settings, raise_errors=True)") - assert thread_calls == 2 + assert thread_calls == 3 diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index 70c406d..c4a527b 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -1,7 +1,11 @@ """Tests for keeping the saved model in step with the engine that runs it.""" +from __future__ import annotations + import importlib import sys +from collections.abc import Callable +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest @@ -48,13 +52,14 @@ def dialog_class(settings_dialog): return settings_dialog.SettingsDialog -def _dialog_stub(): +def _dialog_stub() -> Mock: """A stand-in ``self`` for calling dialog methods without building the UI.""" dialog = Mock() dialog._applying_settings = False dialog._initializing = False dialog._test_active = False dialog._populating_models = False + dialog._processing_language_change = False dialog.language = "en-us" # The real attribute is an enum member; a bare "idle" string would compare # unequal and send every test down the stop_recognition + sleep(0.5) branch. @@ -65,19 +70,86 @@ def _dialog_stub(): class _InlineThread: """Run the download worker on the calling thread, in call order.""" - def __init__(self, target=None, daemon=None, **kwargs): + def __init__( + self, + target: Callable[..., Any] | None = None, + daemon: bool | None = None, + **kwargs: Any, + ) -> None: self._target = target - def start(self): + def start(self) -> None: self._target() -def _glib_stub(idle_calls): +class _DeferredThread: + """Record the worker without running it, so the apply-guard stays held.""" + + def __init__( + self, + target: Callable[..., Any] | None = None, + daemon: bool | None = None, + **kwargs: Any, + ) -> None: + self.target = target + + def start(self) -> None: + pass + + +def _glib_stub(idle_calls: list[tuple[Any, tuple[Any, ...]]]) -> MagicMock: glib = MagicMock() glib.idle_add.side_effect = lambda func, *args: idle_calls.append((func, args)) return glib +def _already_downloaded_settings() -> dict[str, str]: + return { + "engine": "vosk", + "model_size": "small", + "language": "en-us", + } + + +def _run_finish_idle( + dialog: Mock, + dialog_class: type[Any], + idle_calls: list[tuple[Any, tuple[Any, ...]]], +) -> None: + """Invoke the scheduled apply-guard release (Mock dialogs have no real method).""" + finish = dialog_class._finish_auto_apply + ran = False + for func, args in idle_calls: + if func is dialog._finish_auto_apply or func is finish: + finish(dialog, *args) + ran = True + assert ran, "expected GLib.idle_add of the apply-guard release" + + +def _bind_real(dialog: Mock, dialog_class: type[Any], *names: str) -> None: + """Attach production methods so a Mock dialog cannot swallow the call.""" + for name in names: + setattr(dialog, name, getattr(dialog_class, name).__get__(dialog)) + + +def _wire_whispercpp_pickers( + dialog: Mock, *, size: str, variant: str, language: str +) -> dict[str, str]: + """Enough combo/spin state for real ``get_selected_settings`` on whisper.cpp.""" + saved = {"engine": "whisper_cpp", "model_size": variant, "language": language} + dialog.engine_combo.get_active_text.return_value = "whisper.cpp" + dialog.model_combo.get_active_id.return_value = size + dialog.model_variant_combo.get_active_id.return_value = variant + dialog.language_combo.get_active_id.return_value = language + dialog.vad_spin.get_value.return_value = 3 + dialog.silence_spin.get_value.return_value = 2.0 + dialog.gpu_device_combo.get_active_id.return_value = None + dialog.language = language + dialog.config_manager.get_settings.return_value = {"speech_recognition": dict(saved)} + dialog._dialog_is_alive.return_value = True + return saved + + def test_settings_persisted_only_after_the_engine_accepts_them(dialog_class): """A model is saved once it really loaded, not when it was picked.""" dialog = _dialog_stub() @@ -102,21 +174,220 @@ def test_failed_apply_leaves_the_previous_model_configured(dialog_class): def test_failed_auto_apply_resyncs_the_pickers_with_the_config(settings_dialog, dialog_class): """The pickers go back to the saved model so a retry is possible.""" dialog = _dialog_stub() - dialog.get_selected_settings.return_value = { - "engine": "vosk", - "model_size": "small", - "language": "en-us", - } - dialog.speech_engine.reconfigure.side_effect = RuntimeError("boom") + dialog.get_selected_settings.return_value = _already_downloaded_settings() + dialog._apply_settings_internal.side_effect = RuntimeError("boom") + idle_calls = [] - with patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True): + with ( + patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _InlineThread), + ): dialog_class._auto_apply_settings(dialog) dialog._save_selected_settings.assert_not_called() + assert (dialog._idle_resync_model_ui_from_config, ()) in idle_calls + assert dialog._applying_settings is True + _run_finish_idle(dialog, dialog_class, idle_calls) + assert dialog._applying_settings is False + + +def test_already_downloaded_auto_apply_runs_on_a_worker(settings_dialog, dialog_class): + """A model already on disk must not reconfigure on the GTK main loop.""" + dialog = _dialog_stub() + settings = _already_downloaded_settings() + dialog.get_selected_settings.return_value = settings + dialog._apply_settings_internal.return_value = True + idle_calls = [] + + with ( + patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _InlineThread), + ): + dialog_class._auto_apply_settings(dialog) + + dialog._apply_settings_internal.assert_called_once_with(settings, raise_errors=True) + dialog.speech_engine.reconfigure.assert_not_called() + assert dialog._applying_settings is True + _run_finish_idle(dialog, dialog_class, idle_calls) + assert dialog._applying_settings is False + + +def test_apply_guard_blocks_a_second_auto_apply_while_a_worker_is_in_flight( + settings_dialog, dialog_class +): + """A second pick must not start another load until the first apply finishes.""" + dialog = _dialog_stub() + dialog.get_selected_settings.return_value = _already_downloaded_settings() + idle_calls = [] + workers = [] + + class _CaptureThread(_DeferredThread): + def __init__( + self, + target: Callable[..., Any] | None = None, + daemon: bool | None = None, + **kwargs: Any, + ) -> None: + super().__init__(target=target, daemon=daemon, **kwargs) + workers.append(self) + + with ( + patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _CaptureThread), + ): + dialog_class._auto_apply_settings(dialog) + assert dialog._applying_settings is True + assert len(workers) == 1 + + dialog.get_selected_settings.reset_mock() + dialog_class._auto_apply_settings(dialog) + assert len(workers) == 1 + dialog.get_selected_settings.assert_not_called() + dialog._apply_settings_internal.assert_not_called() + + workers[0].target() + dialog._apply_settings_internal.assert_called_once() + assert dialog._applying_settings is True + + _run_finish_idle(dialog, dialog_class, idle_calls) + assert dialog._applying_settings is False + + dialog_class._auto_apply_settings(dialog) + assert len(workers) == 2 + + +def test_second_pick_during_apply_resyncs_ui_when_the_worker_finishes( + settings_dialog, dialog_class +): + """A second pick while the worker runs must not leave the combos on the unapplied model.""" + dialog = _dialog_stub() + first = _already_downloaded_settings() + dialog.get_selected_settings.return_value = first + dialog.config_manager.get_settings.return_value = {"speech_recognition": dict(first)} + idle_calls = [] + workers = [] + + class _CaptureThread(_DeferredThread): + def __init__( + self, + target: Callable[..., Any] | None = None, + daemon: bool | None = None, + **kwargs: Any, + ) -> None: + super().__init__(target=target, daemon=daemon, **kwargs) + workers.append(self) + + with ( + patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _CaptureThread), + ): + dialog_class._auto_apply_settings(dialog) + assert dialog._applying_settings is True + assert len(workers) == 1 + + dialog.get_selected_settings.return_value = { + "engine": "vosk", + "model_size": "medium", + "language": "en-us", + } + dialog_class._auto_apply_settings(dialog) + assert len(workers) == 1 + + workers[0].target() + dialog._apply_settings_internal.assert_called_once_with(first, raise_errors=True) + assert dialog._applying_settings is True + + _run_finish_idle(dialog, dialog_class, idle_calls) + dialog._resync_model_ui_from_config.assert_called_once() assert dialog._applying_settings is False +def test_matching_selection_on_finish_still_resyncs(settings_dialog, dialog_class): + """Finish always resyncs pickers, even when selected settings still match saved.""" + dialog = _dialog_stub() + settings = _already_downloaded_settings() + dialog.get_selected_settings.return_value = settings + dialog.config_manager.get_settings.return_value = {"speech_recognition": dict(settings)} + idle_calls = [] + + with ( + patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _InlineThread), + ): + dialog_class._auto_apply_settings(dialog) + + _run_finish_idle(dialog, dialog_class, idle_calls) + dialog._resync_model_ui_from_config.assert_called_once() + assert dialog._applying_settings is False + + +def test_apply_settings_returns_false_while_guard_held(dialog_class: type[Any]) -> None: + """A held apply-guard must no-op apply_settings without touching the engine.""" + dialog = _dialog_stub() + dialog._applying_settings = True + + result = dialog_class.apply_settings(dialog) + + assert result is False + dialog.get_selected_settings.assert_not_called() + dialog._apply_settings_internal.assert_not_called() + dialog.speech_engine.try_begin_download.assert_not_called() + + +@pytest.mark.parametrize("settings_differ", [False, True]) +def test_test_click_blocked_while_settings_are_applying( + settings_dialog: Any, dialog_class: type[Any], settings_differ: bool +) -> None: + """Test must not start a second apply or recognition while a worker holds the guard. + + UI matching the saved config is not enough: the live engine may still be + mid-reconfigure. Differing settings are the other race — apply_settings + itself must not be entered. + """ + dialog = _dialog_stub() + dialog._applying_settings = True + dialog.test_buffer = Mock() + dialog.test_output_revealer = 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.return_value = { + "engine": "vosk" if settings_differ else "whisper_cpp", + "model_size": "small" if settings_differ else "tiny", + "silence_timeout": 2.0, + "vad_sensitivity": 3, + } + dialog.speech_engine.engine = "whisper_cpp" + dialog.speech_engine.model_size = "tiny" + # Real apply_settings (not a dummy True): if Test skipped its own guard, + # the apply-guard would still return False. + dialog.apply_settings = Mock(side_effect=dialog_class.apply_settings.__get__(dialog)) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + dialog_class._on_test_clicked(dialog, None) + + dialog.test_output_revealer.set_reveal_child.assert_called_with(True) + message = dialog.test_buffer.set_text.call_args[0][0] + assert "still applying" in message.lower() + dialog.apply_settings.assert_not_called() + dialog.get_selected_settings.assert_not_called() + dialog._apply_settings_internal.assert_not_called() + dialog.speech_engine.start_recognition.assert_not_called() + thread_cls.assert_not_called() + assert dialog._test_active is False + + def test_download_path_resyncs_when_the_apply_reports_failure(settings_dialog, dialog_class): """A False return from the apply must not be reported as a finished switch. @@ -144,7 +415,7 @@ def test_download_path_resyncs_when_the_apply_reports_failure(settings_dialog, d modal = modal_class.return_value scheduled = [(func, args) for func, args in idle_calls] - assert (dialog._resync_model_ui_from_config, ()) in scheduled + assert (dialog._idle_resync_model_ui_from_config, ()) in scheduled assert (modal.set_complete, (True, "")) not in scheduled assert any(func is modal.set_complete and args[0] is False for func, args in scheduled) dialog._save_selected_settings.assert_not_called() @@ -170,7 +441,7 @@ def test_download_path_resyncs_when_the_download_is_cancelled(settings_dialog, d dialog_class._auto_apply_settings(dialog) modal = modal_class.return_value - assert (dialog._resync_model_ui_from_config, ()) in idle_calls + assert (dialog._idle_resync_model_ui_from_config, ()) in idle_calls assert (modal.set_complete, (False, "Download cancelled")) in idle_calls @@ -195,6 +466,25 @@ def test_modal_close_resyncs_an_engine_that_never_applied(settings_dialog, dialo dialog._resync_engine_ui_if_unapplied.assert_called_once() +def test_idle_resync_skips_a_destroyed_dialog(dialog_class): + """Worker-thread idle callbacks must not touch widgets after close.""" + dialog = _dialog_stub() + dialog._dialog_is_alive.return_value = False + + assert dialog_class._idle_resync_model_ui_from_config(dialog) is False + + dialog._resync_model_ui_from_config.assert_not_called() + + +def test_idle_resync_runs_when_the_dialog_is_alive(dialog_class): + dialog = _dialog_stub() + dialog._dialog_is_alive.return_value = True + + assert dialog_class._idle_resync_model_ui_from_config(dialog) is False + + dialog._resync_model_ui_from_config.assert_called_once_with() + + def test_unapplied_engine_is_resynced_when_it_differs_from_the_config(dialog_class): """The check compares what is shown against what was actually saved.""" dialog = _dialog_stub() @@ -227,6 +517,149 @@ def test_resync_puts_the_engine_picker_back_on_the_saved_engine(dialog_class): dialog.engine_combo.set_active_id.assert_called_once_with("Vosk") dialog._populate_model_options.assert_called_once() + dialog._sync_language_options_for_selected_model.assert_not_called() + assert dialog._applying_settings is False + + +def test_resync_restores_language_from_saved_config(dialog_class): + """Language combo is restored from the saved config after model options rebuild.""" + dialog = _dialog_stub() + dialog.config_manager.get_settings.return_value = { + "speech_recognition": { + "engine": "whisper_cpp", + "model_size": "tiny", + "language": "fr", + } + } + dialog.engine_combo.get_active_text.return_value = "whisper.cpp" + + dialog_class._resync_model_ui_from_config(dialog) + + dialog._populate_model_options.assert_called_once() + dialog._sync_language_options_for_selected_model.assert_called_once_with("fr") + dialog._update_model_info.assert_called_once() + dialog.engine_combo.set_active_id.assert_not_called() + + +def test_picker_handlers_early_return_while_applying(dialog_class: type[Any]) -> None: + """Size, specialization, and language handlers must not rebuild or apply mid-apply.""" + dialog = _dialog_stub() + dialog._applying_settings = True + saved_language = dialog.language + + dialog_class._on_model_changed(dialog, None) + dialog_class._on_model_variant_changed(dialog, None) + dialog_class._on_language_changed(dialog, None) + + dialog._populate_whispercpp_variant_options.assert_not_called() + dialog._sync_language_options_for_selected_model.assert_not_called() + dialog._populate_model_options.assert_not_called() + dialog._auto_apply_settings.assert_not_called() + assert dialog.language == saved_language + + +def test_finish_resyncs_whispercpp_size_when_selected_settings_still_report_old_variant( + settings_dialog, dialog_class +): + """Whisper.cpp size combo can move while get_selected_settings still reports the old id. + + Handlers early-return while applying, so variant options are not rebuilt. + Finish must still resync even though selected vs saved look identical. + """ + dialog = _dialog_stub() + _bind_real( + dialog, + dialog_class, + "get_selected_settings", + "_get_selected_whispercpp_model", + "_resync_model_ui_from_config", + ) + saved = _wire_whispercpp_pickers(dialog, size="tiny", variant="tiny", language="auto") + idle_calls = [] + workers = [] + + class _CaptureThread(_DeferredThread): + def __init__( + self, + target: Callable[..., Any] | None = None, + daemon: bool | None = None, + **kwargs: Any, + ) -> None: + super().__init__(target=target, daemon=daemon, **kwargs) + workers.append(self) + + with ( + patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _CaptureThread), + ): + dialog_class._auto_apply_settings(dialog) + assert dialog._applying_settings is True + assert len(workers) == 1 + + # Size combo moved to B; production handler early-returns, so the variant + # combo still names the saved id and selected settings still match config. + dialog.model_combo.get_active_id.return_value = "small" + dialog_class._on_model_changed(dialog, None) + dialog._populate_whispercpp_variant_options.assert_not_called() + dialog._auto_apply_settings.assert_not_called() + assert dialog.get_selected_settings()["model_size"] == saved["model_size"] + + workers[0].target() + applied = dialog._apply_settings_internal.call_args[0][0] + assert applied["model_size"] == saved["model_size"] + assert dialog._applying_settings is True + + _run_finish_idle(dialog, dialog_class, idle_calls) + + dialog._populate_model_options.assert_called_once() + dialog._sync_language_options_for_selected_model.assert_called_once_with("auto") + assert dialog._applying_settings is False + + +def test_language_moved_mid_apply_is_restored_from_saved_config(settings_dialog, dialog_class): + """A language pick during apply must not stick; finish restores the saved language.""" + dialog = _dialog_stub() + _bind_real( + dialog, + dialog_class, + "get_selected_settings", + "_get_selected_whispercpp_model", + "_resync_model_ui_from_config", + ) + saved = _wire_whispercpp_pickers(dialog, size="tiny", variant="tiny", language="fr") + idle_calls = [] + workers = [] + + class _CaptureThread(_DeferredThread): + def __init__( + self, + target: Callable[..., Any] | None = None, + daemon: bool | None = None, + **kwargs: Any, + ) -> None: + super().__init__(target=target, daemon=daemon, **kwargs) + workers.append(self) + + with ( + patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True), + patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)), + patch.object(settings_dialog.threading, "Thread", _CaptureThread), + ): + dialog_class._auto_apply_settings(dialog) + assert dialog._applying_settings is True + + dialog.language_combo.get_active_id.return_value = "en-us" + dialog_class._on_language_changed(dialog, None) + dialog._populate_model_options.assert_not_called() + dialog._auto_apply_settings.assert_not_called() + assert dialog.language == saved["language"] + + workers[0].target() + _run_finish_idle(dialog, dialog_class, idle_calls) + + dialog._populate_model_options.assert_called_once() + dialog._sync_language_options_for_selected_model.assert_called_once_with("fr") assert dialog._applying_settings is False @@ -288,7 +721,7 @@ def test_closing_the_dialog_resyncs_an_engine_that_was_never_applied(settings_di dialog._resync_engine_ui_if_unapplied.assert_called_once() -def _download_setup(dialog): +def _download_setup(dialog: Mock) -> None: """A model that is not on disk, so the apply goes down the download path.""" dialog.get_selected_settings.return_value = { "engine": "whisper_cpp", diff --git a/tests/test_settings_test_dictation.py b/tests/test_settings_test_dictation.py index a4399ad..7d631b1 100644 --- a/tests/test_settings_test_dictation.py +++ b/tests/test_settings_test_dictation.py @@ -65,6 +65,7 @@ def _text_buffer(): def _dialog_for_test(*, start_return, model_ready=True, is_auto_paused=False): dialog = Mock() dialog._test_active = False + dialog._applying_settings = False dialog.test_button = Mock() dialog.test_output_revealer = Mock() dialog.test_buffer = _text_buffer()