* 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.
210 lines
7.0 KiB
Python
210 lines
7.0 KiB
Python
"""Tests for Settings → Test Dictation start failure vs real no-speech."""
|
|
|
|
import importlib
|
|
import sys
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
from vocalinux.common_types import RecognitionState
|
|
|
|
|
|
def _load_settings_dialog():
|
|
"""Import settings_dialog with real bases so unbound methods stay callable.
|
|
|
|
conftest swaps gi for a MagicMock, which leaves every ``class X(Gtk.Y)`` as
|
|
a mock. Handing the three bases a real class keeps the methods intact.
|
|
|
|
Restore both sys.modules and the vocalinux.ui.settings_dialog attribute.
|
|
Python 3.9/3.10 mock follows the package attribute, so leaving a private
|
|
copy there makes later tests patch a different module than they imported.
|
|
"""
|
|
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 _text_buffer():
|
|
"""Minimal stand-in for Gtk.TextBuffer used by the test output pane."""
|
|
buf = Mock()
|
|
buf._text = ""
|
|
|
|
def set_text(text):
|
|
buf._text = text
|
|
|
|
def get_text(*_args, **_kwargs):
|
|
return buf._text
|
|
|
|
def insert(_iter, text):
|
|
buf._text += text
|
|
|
|
buf.set_text.side_effect = set_text
|
|
buf.get_text.side_effect = get_text
|
|
buf.insert.side_effect = insert
|
|
buf.get_start_iter.return_value = Mock()
|
|
buf.get_end_iter.return_value = Mock()
|
|
buf.get_insert.return_value = Mock()
|
|
return buf
|
|
|
|
|
|
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()
|
|
dialog.test_textview = Mock()
|
|
dialog.config_manager = 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 = Mock(
|
|
return_value={
|
|
"engine": "whisper_cpp",
|
|
"model_size": "tiny",
|
|
"silence_timeout": 2.0,
|
|
"vad_sensitivity": 3,
|
|
}
|
|
)
|
|
dialog.apply_settings = Mock(return_value=True)
|
|
dialog.connect_to_recognition_manager = Mock()
|
|
dialog.update_recognition_progress = Mock()
|
|
dialog._test_text_callback = Mock()
|
|
dialog._stop_test_after_delay = SettingsDialog._stop_test_after_delay.__get__(dialog)
|
|
|
|
engine = Mock()
|
|
engine.state = RecognitionState.IDLE
|
|
engine.engine = "whisper_cpp"
|
|
engine.model_size = "tiny"
|
|
engine.start_recognition = Mock(return_value=start_return)
|
|
engine.stop_recognition = Mock()
|
|
engine.get_text_callbacks = Mock(return_value=[])
|
|
engine.set_text_callbacks = Mock()
|
|
engine.model_ready = model_ready
|
|
engine.is_auto_paused = is_auto_paused
|
|
dialog.speech_engine = engine
|
|
return dialog
|
|
|
|
|
|
def test_test_dictation_missing_model_skips_listen_timer():
|
|
dialog = _dialog_for_test(start_return=False, model_ready=False)
|
|
|
|
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
|
SettingsDialog._on_test_clicked(dialog, None)
|
|
|
|
thread_cls.assert_not_called()
|
|
assert dialog._test_active is False
|
|
assert "Speech Model page" in dialog.test_buffer._text
|
|
assert "No speech detected" not in dialog.test_buffer._text
|
|
dialog.speech_engine.set_text_callbacks.assert_any_call([])
|
|
dialog.test_button.set_label.assert_not_called()
|
|
dialog.test_output_revealer.set_reveal_child.assert_called_with(True)
|
|
|
|
|
|
def test_test_dictation_auto_paused_message():
|
|
dialog = _dialog_for_test(start_return=False, is_auto_paused=True)
|
|
|
|
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
|
SettingsDialog._on_test_clicked(dialog, None)
|
|
|
|
thread_cls.assert_not_called()
|
|
assert "paused" in dialog.test_buffer._text.lower()
|
|
assert "No speech detected" not in dialog.test_buffer._text
|
|
|
|
|
|
def test_test_dictation_start_failure_generic_message():
|
|
dialog = _dialog_for_test(start_return=False, model_ready=True, is_auto_paused=False)
|
|
|
|
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
|
SettingsDialog._on_test_clicked(dialog, None)
|
|
|
|
thread_cls.assert_not_called()
|
|
assert dialog.test_buffer._text == "Could not start recognition test."
|
|
|
|
|
|
def test_test_dictation_started_arms_listen_timer():
|
|
dialog = _dialog_for_test(start_return=True)
|
|
|
|
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
|
SettingsDialog._on_test_clicked(dialog, None)
|
|
|
|
thread_cls.assert_called_once()
|
|
assert thread_cls.call_args.kwargs["args"] == (4.0,)
|
|
assert dialog._test_active is True
|
|
dialog.test_button.set_label.assert_called_with("Testing… Speak Now!")
|
|
dialog.apply_settings.assert_not_called()
|
|
|
|
|
|
def test_test_dictation_reconfigures_when_live_engine_differs_from_ui():
|
|
"""UI matches the file but the live manager is still on another engine."""
|
|
dialog = _dialog_for_test(start_return=True, model_ready=True)
|
|
dialog.speech_engine.engine = "vosk"
|
|
dialog.speech_engine.model_size = "small"
|
|
dialog.speech_engine.model_ready = False
|
|
|
|
def _apply_and_sync():
|
|
dialog.speech_engine.engine = "whisper_cpp"
|
|
dialog.speech_engine.model_size = "tiny"
|
|
dialog.speech_engine.model_ready = True
|
|
return True
|
|
|
|
dialog.apply_settings.side_effect = _apply_and_sync
|
|
|
|
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
|
SettingsDialog._on_test_clicked(dialog, None)
|
|
|
|
dialog.apply_settings.assert_called_once()
|
|
thread_cls.assert_called_once()
|
|
assert dialog._test_active is True
|
|
|
|
|
|
def test_check_test_result_empty_buffer_is_no_speech():
|
|
dialog = Mock()
|
|
dialog.test_buffer = _text_buffer()
|
|
dialog.test_buffer.set_text("")
|
|
|
|
SettingsDialog._check_test_result(dialog)
|
|
|
|
assert dialog.test_buffer._text == "(No speech detected during test)"
|
|
|
|
|
|
def test_check_test_result_keeps_captured_text():
|
|
dialog = Mock()
|
|
dialog.test_buffer = _text_buffer()
|
|
dialog.test_buffer.set_text("hello world")
|
|
|
|
SettingsDialog._check_test_result(dialog)
|
|
|
|
assert dialog.test_buffer._text == "hello world"
|
|
|
|
|
|
def test_append_test_result_shows_callback_text():
|
|
dialog = Mock()
|
|
dialog.test_buffer = _text_buffer()
|
|
dialog.test_textview = Mock()
|
|
|
|
SettingsDialog._append_test_result(dialog, "hello")
|
|
|
|
assert dialog.test_buffer._text == "hello"
|