* 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.
110 lines
3.9 KiB
Python
110 lines
3.9 KiB
Python
"""Tests for reporting failed model downloads instead of swallowing them."""
|
|
|
|
import importlib
|
|
import sys
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _load_settings_dialog():
|
|
"""Import settings_dialog with real base classes for its GTK subclasses.
|
|
|
|
conftest swaps gi for a MagicMock, which leaves every ``class X(Gtk.Y)`` in
|
|
the module as a mock and makes its methods unreachable. Handing the three
|
|
bases the module subclasses a real class keeps the classes intact, while
|
|
the rest of GTK stays mocked.
|
|
|
|
Restore both sys.modules and the vocalinux.ui.settings_dialog attribute so
|
|
3.9/3.10 mock and later imports see the same module object.
|
|
"""
|
|
import vocalinux.ui as ui_pkg
|
|
|
|
repository = sys.modules["gi.repository"]
|
|
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
|
|
saved = sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
try:
|
|
with patch.object(repository, "Gtk", MagicMock(**bases)):
|
|
module = importlib.import_module("vocalinux.ui.settings_dialog")
|
|
finally:
|
|
sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
if saved is not None:
|
|
sys.modules["vocalinux.ui.settings_dialog"] = saved
|
|
ui_pkg.settings_dialog = saved
|
|
else:
|
|
ui_pkg.__dict__.pop("settings_dialog", None)
|
|
return module
|
|
|
|
|
|
settings_dialog = _load_settings_dialog()
|
|
SettingsDialog = settings_dialog.SettingsDialog
|
|
|
|
|
|
def _dialog_stub():
|
|
dialog = Mock()
|
|
dialog.speech_engine.state = "idle"
|
|
return dialog
|
|
|
|
|
|
def test_download_threads_get_the_failure():
|
|
"""With raise_errors the caller's handler sees the exception.
|
|
|
|
The download threads rely on this: their except blocks turn the failure
|
|
into set_complete(False, reason) on the progress dialog. Without it they
|
|
reported "Complete!" for downloads that were cancelled or never finished.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
dialog.speech_engine.reconfigure.side_effect = RuntimeError("Download cancelled")
|
|
|
|
with pytest.raises(RuntimeError, match="Download cancelled"):
|
|
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, raise_errors=True)
|
|
|
|
|
|
def test_no_gtk_dialog_is_built_when_errors_are_raised():
|
|
"""The failing thread path must not touch Gtk — that is the caller's job.
|
|
|
|
Building and running a Gtk.MessageDialog off the main loop is what the
|
|
swallowed-error path used to do from the download thread.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
dialog.speech_engine.reconfigure.side_effect = RuntimeError("boom")
|
|
|
|
with patch.object(settings_dialog, "Gtk") as gtk:
|
|
with pytest.raises(RuntimeError):
|
|
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, raise_errors=True)
|
|
|
|
gtk.MessageDialog.assert_not_called()
|
|
|
|
|
|
def test_main_loop_callers_keep_the_dialog_and_the_false_return():
|
|
"""Without the flag the old contract stays: swallow, show, return False."""
|
|
dialog = _dialog_stub()
|
|
dialog.speech_engine.reconfigure.side_effect = RuntimeError("boom")
|
|
|
|
with patch.object(settings_dialog, "Gtk") as gtk:
|
|
result = SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"})
|
|
|
|
assert result is False
|
|
gtk.MessageDialog.assert_called_once()
|
|
|
|
|
|
def test_success_still_returns_true():
|
|
dialog = _dialog_stub()
|
|
|
|
assert SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}) is True
|
|
assert (
|
|
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, raise_errors=True)
|
|
is 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 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 == 3
|