* fix(settings): stop Test Dictation calling a missing model "no speech" start_recognition now returns whether listening actually began. Test Dictation checks that before starting its timer, so a missing or auto-paused model gets a real error instead of "No speech detected". * fix(settings): reconfigure Test Dictation against the live engine UI matching config.json is not enough when speech_engine is still on another engine or model size. Compare those too before the listen timer. * fix(tests): keep settings_dialog lookups on one module _load_settings_dialog() restored sys.modules but left vocalinux.ui.settings_dialog pointing at the private reload. Python 3.9/3.10 mock follows that attribute, so test_whisper_list_and_delete patched a different copy than it imported and the expanduser stub hid the real cache. Point the package attribute back at the restored module, and patch the imported module object in the deletion test.
209 lines
7.0 KiB
Python
209 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.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"
|