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