fix(settings): stop Test Dictation calling a missing model "no speech" (#702)

* 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.
This commit is contained in:
Jatin K Malik
2026-08-18 17:59:57 -07:00
committed by GitHub
parent fd23ef0d9d
commit d8ff7052cd
11 changed files with 289 additions and 45 deletions
+1 -1
View File
@@ -523,7 +523,7 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase):
patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"),
patch("vocalinux.speech_recognition.recognition_manager._show_notification"),
):
mgr.start_recognition()
self.assertFalse(mgr.start_recognition())
self.assertEqual(mgr.state, RecognitionState.IDLE)
# Should not have transitioned to listening
+8
View File
@@ -14,7 +14,12 @@ def _load_settings_dialog():
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)
@@ -25,6 +30,9 @@ def _load_settings_dialog():
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
+9 -15
View File
@@ -92,23 +92,17 @@ def test_whisper_list_and_delete(tmp_path):
tiny.write_bytes(b"weights")
missing_default = tmp_path / "missing-whisper-cache"
from vocalinux.ui.settings_dialog import (
_delete_whisper_model,
_list_downloaded_whisper_models,
)
import vocalinux.ui.settings_dialog as sd
# Patch the same module object we call. String-target patch() on 3.9/3.10
# follows vocalinux.ui.settings_dialog, which can be a different copy than
# sys.modules after a test-time reload.
with (
patch(
"vocalinux.ui.settings_dialog._get_whisper_cache_dir",
return_value=str(cache),
),
patch(
"vocalinux.ui.settings_dialog.os.path.expanduser",
return_value=str(missing_default),
),
patch.object(sd, "_get_whisper_cache_dir", return_value=str(cache)),
patch.object(sd.os.path, "expanduser", return_value=str(missing_default)),
):
assert _list_downloaded_whisper_models() == ["tiny"]
deleted = _delete_whisper_model("tiny")
assert sd._list_downloaded_whisper_models() == ["tiny"]
deleted = sd._delete_whisper_model("tiny")
assert deleted == [str(tiny)]
assert not tiny.exists()
assert _list_downloaded_whisper_models() == []
assert sd._list_downloaded_whisper_models() == []
+1 -1
View File
@@ -475,7 +475,7 @@ class TestSpeechRecognition(unittest.TestCase):
manager.model = None
# Should not start and should play error sound
manager.start_recognition()
self.assertFalse(manager.start_recognition())
self.assertEqual(manager.state, RecognitionState.IDLE)
mock_audio_feedback.play_error_sound.assert_called()
+2 -2
View File
@@ -474,7 +474,7 @@ class TestStartStopRecognition(unittest.TestCase):
"""Test successful recognition start."""
manager = SpeechRecognitionManager(engine="vosk")
manager.start_recognition()
self.assertTrue(manager.start_recognition())
self.assertEqual(manager.state, RecognitionState.LISTENING)
self.assertTrue(manager.should_record)
@@ -492,7 +492,7 @@ class TestStartStopRecognition(unittest.TestCase):
# Second start should not create new threads
manager.audio_thread = self.threadInstance
manager.recognition_thread = self.threadInstance
manager.start_recognition()
self.assertFalse(manager.start_recognition())
# No new threads created
self.threadMock.assert_not_called()
@@ -1774,7 +1774,7 @@ class TestStartStopRecognition(unittest.TestCase):
manager.state = RecognitionState.LISTENING
with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"):
manager.start_recognition()
assert manager.start_recognition() is False
# Should return early without starting threads
def test_start_recognition_model_not_ready(self):
@@ -1785,7 +1785,7 @@ class TestStartStopRecognition(unittest.TestCase):
with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"):
with patch("vocalinux.speech_recognition.recognition_manager._show_notification"):
manager.start_recognition()
assert manager.start_recognition() is False
assert manager.state == RecognitionState.IDLE # Should not change
def test_start_recognition_success(self):
@@ -1798,7 +1798,7 @@ class TestStartStopRecognition(unittest.TestCase):
with patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"):
with patch.object(manager, "_record_audio"):
with patch.object(manager, "_perform_recognition"):
manager.start_recognition()
assert manager.start_recognition() is True
assert manager.state == RecognitionState.LISTENING
assert manager.should_record is True
@@ -584,7 +584,7 @@ class TestStartStopRecognition(unittest.TestCase):
manager._model_initialized = True
manager.state = RecognitionState.IDLE
manager.start_recognition()
assert manager.start_recognition() is True
assert manager.state == RecognitionState.LISTENING
assert manager.should_record is True
@@ -606,7 +606,7 @@ class TestStartStopRecognition(unittest.TestCase):
manager._model_initialized = False
manager.state = RecognitionState.IDLE
manager.start_recognition()
assert manager.start_recognition() is False
# Should stay idle if model not ready
assert manager.state == RecognitionState.IDLE
@@ -627,7 +627,7 @@ class TestStartStopRecognition(unittest.TestCase):
manager._model_initialized = True
manager.state = RecognitionState.LISTENING
manager.start_recognition()
assert manager.start_recognition() is False
# Should still be listening (no-op)
assert manager.state == RecognitionState.LISTENING
+208
View File
@@ -0,0 +1,208 @@
"""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"