fix(settings): make model downloads explicit and cancel the whole intent (#1006)
* fix(settings): make model downloads explicit and cancel the whole intent "Use it" on the recommendation card applied a different model than the one shown, a cancelled or failed apply left the staged model behind so the next Test Dictation downloaded it, and cancelling a download tore down and then reloaded the previous model while the dialog waited. - Recommendation is now passive text, compared by size tier, with a link to docs/MODEL_SELECTION.md. The "Use it" button is gone. - Every Settings download asks first and shows size and source host. Declining, cancelling or failing drops the staged model spec and puts the pickers back on the saved config. - Test Dictation refuses a model that is not on disk instead of applying it. - reconfigure() fetches a missing model before releasing the loaded one, so a cancel restores state without reloading or rehashing the old model. Cancellation state is reset only when a download claim is taken or ended. - Follow-layout mode shows the resolved language in the simple Main language control, read-only, and simple edits keep the "layout" setting. Fixes #979 Fixes #980 Fixes #981 Fixes #982 Fixes #985 Fixes #988 * fix(settings): restore follow-layout mode when a model change is declined * fix(settings): keep follow-layout and dictation consistent during model changes * style(tests): format follow-layout regression tests * fix(recognition): skip resume reinit while a model download is running * fix(recognition): keep a lazy reload pending when resume is skipped during a download --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
f013b7ff88
commit
3e4230ffc2
@@ -181,6 +181,50 @@ def test_changed_fires_only_when_the_selection_actually_changes(settings_dialog)
|
||||
assert picker._active_id == "ja"
|
||||
|
||||
|
||||
def test_clear_active_shows_a_readout_without_emitting_changed(settings_dialog: Any) -> None:
|
||||
picker = _picker_stub(settings_dialog, rows=LANGS, active="pl")
|
||||
|
||||
settings_dialog.SearchablePicker.clear_active(picker, "Detected each time")
|
||||
|
||||
assert picker._active_id is None
|
||||
picker._label.set_text.assert_called_once_with("Detected each time")
|
||||
picker.emit.assert_not_called()
|
||||
|
||||
|
||||
def test_follow_layout_auto_uses_a_readout_without_applying_settings(
|
||||
settings_dialog: Any,
|
||||
) -> None:
|
||||
picker = _picker_stub(settings_dialog, rows=LANGS, active="en-us")
|
||||
label_text = {"value": "English (US)"}
|
||||
picker._label.set_text.side_effect = lambda text: label_text.__setitem__("value", text)
|
||||
picker.set_active_id.side_effect = (
|
||||
lambda item_id: settings_dialog.SearchablePicker.set_active_id(picker, item_id)
|
||||
)
|
||||
picker.get_active_id.side_effect = lambda: settings_dialog.SearchablePicker.get_active_id(
|
||||
picker
|
||||
)
|
||||
picker.clear_active.side_effect = lambda text="": settings_dialog.SearchablePicker.clear_active(
|
||||
picker, text
|
||||
)
|
||||
dialog = Mock()
|
||||
dialog._follow_layout_active = True
|
||||
dialog.language = "auto"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
dialog.simple_language_combo = picker
|
||||
dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason")
|
||||
dialog._get_selected_whispercpp_model.return_value = "small"
|
||||
dialog._update_simple_visibility = Mock()
|
||||
dialog._auto_apply_settings = Mock()
|
||||
|
||||
settings_dialog.SettingsDialog._sync_simple_from_advanced(dialog)
|
||||
|
||||
assert picker.get_active_id() is None
|
||||
assert label_text["value"] == "Detected each time"
|
||||
dialog.simple_language_row.set_sensitive.assert_called_once_with(False)
|
||||
dialog._auto_apply_settings.assert_not_called()
|
||||
picker.emit.assert_not_called()
|
||||
|
||||
|
||||
def test_opening_starts_from_an_empty_search(settings_dialog):
|
||||
"""Leftover text from last time would hide most of the list on open."""
|
||||
picker = _picker_stub(settings_dialog, rows=LANGS)
|
||||
|
||||
@@ -665,7 +665,7 @@ def test_failed_reconfigure_settles_the_pending_oneshot_restore() -> None:
|
||||
manager._init_selected_engine = MagicMock(side_effect=[RuntimeError("boom"), None])
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
manager.reconfigure(language="fr", model_size="tiny")
|
||||
manager.reconfigure(language="fr", model_size="tiny", force_download=False)
|
||||
|
||||
assert manager.language == "en-us"
|
||||
assert manager._oneshot_language_restore is None
|
||||
|
||||
@@ -592,21 +592,21 @@ class TestReconfigure(unittest.TestCase):
|
||||
mgr = _make_manager()
|
||||
mgr.state = RecognitionState.IDLE
|
||||
with patch.object(mgr, "_init_whispercpp"):
|
||||
mgr.reconfigure(model_size="medium")
|
||||
mgr.reconfigure(model_size="medium", force_download=False)
|
||||
self.assertEqual(mgr.model_size, "medium")
|
||||
|
||||
def test_reconfigure_engine(self):
|
||||
mgr = _make_manager()
|
||||
mgr.state = RecognitionState.IDLE
|
||||
with patch.object(mgr, "_init_vosk"):
|
||||
mgr.reconfigure(engine="vosk")
|
||||
mgr.reconfigure(engine="vosk", force_download=False)
|
||||
self.assertEqual(mgr.engine, "vosk")
|
||||
|
||||
def test_reconfigure_language_candidates(self) -> None:
|
||||
mgr = _make_manager()
|
||||
mgr.state = RecognitionState.IDLE
|
||||
with patch.object(mgr, "_init_whispercpp"):
|
||||
mgr.reconfigure(whispercpp_language_candidates="en,es")
|
||||
mgr.reconfigure(whispercpp_language_candidates="en,es", force_download=False)
|
||||
self.assertEqual(mgr.whispercpp_language_candidates, ["en", "es"])
|
||||
|
||||
|
||||
@@ -1069,6 +1069,7 @@ class TestWhispercppModelKwargs(unittest.TestCase):
|
||||
whispercpp_temperature=0.5,
|
||||
whispercpp_no_timestamps=False,
|
||||
whispercpp_initial_prompt="hello",
|
||||
force_download=False,
|
||||
)
|
||||
self.assertEqual(mgr.whispercpp_temperature, 0.5)
|
||||
self.assertFalse(mgr.whispercpp_no_timestamps)
|
||||
|
||||
@@ -2417,7 +2417,7 @@ class TestReconfiguration(unittest.TestCase):
|
||||
|
||||
with patch.object(manager, "_init_whisper"):
|
||||
with patch.object(manager, "stop_recognition"):
|
||||
manager.reconfigure(engine="whisper")
|
||||
manager.reconfigure(engine="whisper", force_download=False)
|
||||
assert manager.engine == "whisper"
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import MagicMock, Mock, PropertyMock, mock_open, patch
|
||||
|
||||
# Mock modules BEFORE importing anything from vocalinux
|
||||
@@ -33,6 +34,7 @@ sys.modules["zipfile"] = MagicMock()
|
||||
# Import after mocking
|
||||
from conftest import mock_audio_feedback
|
||||
|
||||
import vocalinux.speech_recognition.recognition_manager as recognition_manager_module
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.speech_recognition.command_processor import CommandProcessor
|
||||
from vocalinux.speech_recognition.recognition_manager import (
|
||||
@@ -97,6 +99,266 @@ class TestDownloadFunctions(unittest.TestCase):
|
||||
manager._download_cancelled = False
|
||||
assert manager._download_cancelled is False
|
||||
|
||||
def test_download_claim_resets_cancellation_for_the_next_attempt(self) -> None:
|
||||
"""A stale cancel from one attempt must not cancel the next one."""
|
||||
manager = self._create_manager(engine="whisper")
|
||||
manager._download_cancelled = True
|
||||
|
||||
assert manager.try_begin_download() is True
|
||||
assert manager._download_cancelled is False
|
||||
manager.cancel_download()
|
||||
manager.end_download()
|
||||
assert manager._download_cancelled is False
|
||||
|
||||
def _assert_missing_model_dispatch(self, engine: str, model_size: str, downloader: str) -> None:
|
||||
manager = self._create_manager()
|
||||
manager.engine = engine
|
||||
manager.model_size = model_size
|
||||
with ExitStack() as stack:
|
||||
availability = None
|
||||
if engine == "whisper_cpp":
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
recognition_manager_module,
|
||||
"get_model_path",
|
||||
return_value="/models/target",
|
||||
)
|
||||
)
|
||||
availability = stack.enter_context(
|
||||
patch.object(recognition_manager_module.os.path, "exists")
|
||||
)
|
||||
elif engine == "whisper":
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
manager,
|
||||
"_whisper_model_cache_paths",
|
||||
return_value=("/models/whisper", "/models/whisper/target.pt"),
|
||||
)
|
||||
)
|
||||
availability = stack.enter_context(
|
||||
patch.object(recognition_manager_module.os.path, "exists")
|
||||
)
|
||||
elif engine == "faster_whisper":
|
||||
availability = stack.enter_context(
|
||||
patch.object(recognition_manager_module.faster_whisper, "is_model_downloaded")
|
||||
)
|
||||
elif engine == "parakeet":
|
||||
availability = stack.enter_context(
|
||||
patch.object(recognition_manager_module.parakeet, "is_model_downloaded")
|
||||
)
|
||||
else:
|
||||
stack.enter_context(
|
||||
patch.object(manager, "_get_vosk_model_path", return_value="/models/target")
|
||||
)
|
||||
availability = stack.enter_context(
|
||||
patch.object(recognition_manager_module.os.path, "exists")
|
||||
)
|
||||
download = stack.enter_context(patch.object(manager, downloader))
|
||||
|
||||
for downloaded in (True, False):
|
||||
with self.subTest(engine=engine, downloaded=downloaded):
|
||||
availability.return_value = downloaded
|
||||
manager._download_selected_model_if_missing()
|
||||
if downloaded:
|
||||
download.assert_not_called()
|
||||
elif engine == "whisper":
|
||||
download.assert_called_once_with("/models/whisper")
|
||||
else:
|
||||
download.assert_called_once_with()
|
||||
download.reset_mock()
|
||||
|
||||
def test_download_selected_model_dispatches_whispercpp(self) -> None:
|
||||
self._assert_missing_model_dispatch("whisper_cpp", "small", "_download_whispercpp_model")
|
||||
|
||||
def test_download_selected_model_dispatches_whisper(self) -> None:
|
||||
self._assert_missing_model_dispatch("whisper", "small", "_download_whisper_model")
|
||||
|
||||
def test_download_selected_model_dispatches_faster_whisper(self) -> None:
|
||||
self._assert_missing_model_dispatch(
|
||||
"faster_whisper", "tiny", "_download_faster_whisper_model"
|
||||
)
|
||||
|
||||
def test_download_selected_model_dispatches_parakeet(self) -> None:
|
||||
self._assert_missing_model_dispatch("parakeet", "v3-european", "_download_parakeet_model")
|
||||
|
||||
def test_download_selected_model_dispatches_vosk(self) -> None:
|
||||
self._assert_missing_model_dispatch("vosk", "small", "_download_vosk_model")
|
||||
|
||||
def test_reconfigure_download_cancellation_preserves_the_loaded_model(self) -> None:
|
||||
manager = self._create_manager()
|
||||
loaded_model = object()
|
||||
manager.model = loaded_model
|
||||
manager.engine = "whisper"
|
||||
manager.model_size = "small"
|
||||
manager.language = "en-us"
|
||||
manager.command_processor = Mock()
|
||||
restore_language = patch.object(manager, "_restore_language_after_oneshot")
|
||||
restore_language_mock = restore_language.start()
|
||||
self.patches.append(restore_language)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
recognition_manager_module,
|
||||
"get_model_path",
|
||||
return_value="/models/medium.bin",
|
||||
),
|
||||
patch.object(recognition_manager_module.os.path, "exists", return_value=False),
|
||||
patch.object(
|
||||
manager,
|
||||
"_download_whispercpp_model",
|
||||
side_effect=RuntimeError("Download cancelled"),
|
||||
) as download,
|
||||
patch.object(manager, "_init_selected_engine") as init_selected,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "Download cancelled"):
|
||||
manager.reconfigure(
|
||||
engine="whisper_cpp",
|
||||
model_size="medium",
|
||||
language="fr",
|
||||
force_download=True,
|
||||
)
|
||||
|
||||
assert manager.model is loaded_model
|
||||
assert manager.engine == "whisper"
|
||||
assert manager.model_size == "small"
|
||||
assert manager.language == "en-us"
|
||||
assert manager._defer_download is True
|
||||
assert manager._model_lock.locked() is False
|
||||
assert manager._prefetching_model is False
|
||||
download.assert_called_once_with()
|
||||
init_selected.assert_not_called()
|
||||
manager.command_processor.set_language.assert_any_call("fr")
|
||||
manager.command_processor.set_language.assert_any_call("en-us")
|
||||
restore_language_mock.assert_called_once_with()
|
||||
|
||||
def test_reconfigure_prefetches_before_initializing_the_selected_engine(self) -> None:
|
||||
manager = self._create_manager()
|
||||
manager.engine = "whisper"
|
||||
available = {"model": False}
|
||||
loaded_model = object()
|
||||
|
||||
def model_exists(_path: str) -> bool:
|
||||
return available["model"]
|
||||
|
||||
def download_model() -> None:
|
||||
assert manager._prefetching_model is True
|
||||
assert manager.start_recognition() is False
|
||||
available["model"] = True
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
recognition_manager_module,
|
||||
"get_model_path",
|
||||
return_value="/models/medium.bin",
|
||||
),
|
||||
patch.object(recognition_manager_module.os.path, "exists", side_effect=model_exists),
|
||||
patch.object(recognition_manager_module, "_preload_pywhispercpp_shared_libraries"),
|
||||
patch.object(manager, "_whispercpp_model_is_verified", return_value=True),
|
||||
patch.object(
|
||||
manager, "_download_whispercpp_model", side_effect=download_model
|
||||
) as download,
|
||||
patch.object(
|
||||
manager,
|
||||
"_load_whispercpp_model",
|
||||
side_effect=lambda _path: setattr(manager, "model", loaded_model),
|
||||
),
|
||||
patch.object(
|
||||
manager, "_init_selected_engine", wraps=manager._init_selected_engine
|
||||
) as init,
|
||||
patch.object(recognition_manager_module, "play_error_sound") as error_sound,
|
||||
patch.object(recognition_manager_module, "_show_notification") as notification,
|
||||
):
|
||||
manager.reconfigure(
|
||||
engine="whisper_cpp",
|
||||
model_size="medium",
|
||||
force_download=True,
|
||||
)
|
||||
|
||||
assert available["model"] is True
|
||||
download.assert_called_once_with()
|
||||
init.assert_called_once_with()
|
||||
assert manager.model is loaded_model
|
||||
assert manager._prefetching_model is False
|
||||
error_sound.assert_called_once_with()
|
||||
notification.assert_called_once_with(
|
||||
"Speech model downloading",
|
||||
"Dictation will be available when the new model has finished downloading.",
|
||||
"dialog-information",
|
||||
)
|
||||
|
||||
def test_reconfigure_without_download_permission_skips_prefetch(self) -> None:
|
||||
manager = self._create_manager()
|
||||
manager.engine = "whisper"
|
||||
|
||||
with (
|
||||
patch.object(manager, "_download_selected_model_if_missing") as prefetch,
|
||||
patch.object(manager, "_init_selected_engine"),
|
||||
):
|
||||
manager.reconfigure(
|
||||
engine="whisper_cpp",
|
||||
model_size="medium",
|
||||
force_download=False,
|
||||
)
|
||||
|
||||
prefetch.assert_not_called()
|
||||
|
||||
def test_resume_skips_engine_reinit_while_model_is_prefetching(self) -> None:
|
||||
manager = self._create_manager(engine="whisper")
|
||||
manager.engine = "whisper_cpp"
|
||||
manager._prefetching_model = True
|
||||
manager._auto_paused = True
|
||||
manager._idle_unloaded = True
|
||||
|
||||
with patch.object(manager, "_init_whispercpp") as init_whispercpp:
|
||||
manager.reinitialize_after_resume()
|
||||
|
||||
init_whispercpp.assert_not_called()
|
||||
self.assertFalse(manager._auto_paused)
|
||||
self.assertFalse(manager._idle_unloaded)
|
||||
|
||||
def test_lazy_reload_follows_a_cancelled_prefetch_after_resume(self) -> None:
|
||||
manager = self._create_manager(engine="whisper")
|
||||
manager.engine = "whisper_cpp"
|
||||
manager.model = None
|
||||
manager._model_initialized = False
|
||||
manager._prefetching_model = True
|
||||
manager._auto_paused = True
|
||||
|
||||
manager.reinitialize_after_resume()
|
||||
manager._prefetching_model = False
|
||||
|
||||
with patch.object(manager, "_init_whispercpp") as init_whispercpp:
|
||||
manager.ensure_model_loaded()
|
||||
|
||||
init_whispercpp.assert_called_once()
|
||||
|
||||
def test_start_recognition_refuses_while_a_model_is_prefetching(self) -> None:
|
||||
manager = self._create_manager()
|
||||
manager.engine = "whisper_cpp"
|
||||
manager._prefetching_model = True
|
||||
uninitialized_manager = object.__new__(SpeechRecognitionManager)
|
||||
assert uninitialized_manager._prefetching_model is False
|
||||
|
||||
with (
|
||||
patch.object(recognition_manager_module, "play_error_sound") as error_sound,
|
||||
patch.object(recognition_manager_module, "_show_notification") as notification,
|
||||
):
|
||||
assert manager.start_recognition() is False
|
||||
manager.language = "pl"
|
||||
with patch.object(
|
||||
manager, "start_recognition", wraps=manager.start_recognition
|
||||
) as start:
|
||||
assert manager.start_recognition_with_language("pl") is False
|
||||
|
||||
start.assert_called_once_with(mode="toggle")
|
||||
assert error_sound.call_count == 2
|
||||
notification.assert_called_with(
|
||||
"Speech model downloading",
|
||||
"Dictation will be available when the new model has finished downloading.",
|
||||
"dialog-information",
|
||||
)
|
||||
assert notification.call_count == 2
|
||||
|
||||
def test_download_vosk_model_with_progress(self):
|
||||
"""Test VOSK model download with progress tracking."""
|
||||
# Test that progress callback can be registered
|
||||
@@ -378,6 +640,40 @@ class TestErrorHandling(unittest.TestCase):
|
||||
assert manager.engine == "whisper"
|
||||
assert manager.engine != original_engine
|
||||
|
||||
def test_reconfigure_aborts_when_download_cancelled_while_lock_is_busy(self) -> None:
|
||||
"""Cancellation while waiting for a transcription lock restores state."""
|
||||
manager = SpeechRecognitionManager(engine="vosk", defer_download=True)
|
||||
manager._download_claim.acquire()
|
||||
manager.engine = "vosk"
|
||||
manager._model_lock.acquire()
|
||||
manager._model_lock.release()
|
||||
|
||||
lock_held = threading.Event()
|
||||
release_lock = threading.Event()
|
||||
|
||||
def hold_model_lock() -> None:
|
||||
manager._model_lock.acquire()
|
||||
lock_held.set()
|
||||
release_lock.wait(timeout=2)
|
||||
manager._model_lock.release()
|
||||
|
||||
holder = threading.Thread(target=hold_model_lock)
|
||||
holder.start()
|
||||
assert lock_held.wait(timeout=2)
|
||||
manager.cancel_download()
|
||||
|
||||
try:
|
||||
with patch.object(manager, "_download_selected_model_if_missing"):
|
||||
with self.assertRaisesRegex(RuntimeError, "Download cancelled"):
|
||||
manager.reconfigure(engine="whisper", force_download=True)
|
||||
finally:
|
||||
release_lock.set()
|
||||
holder.join(timeout=2)
|
||||
manager.end_download()
|
||||
|
||||
assert manager.engine == "vosk"
|
||||
assert manager._defer_download is True
|
||||
|
||||
def test_reconfigure_language_change(self):
|
||||
"""Test reconfiguring language triggers restart."""
|
||||
patches = [
|
||||
|
||||
@@ -1150,8 +1150,14 @@ class TestFailedReconfigureRestoresPreviousEngine:
|
||||
manager._model_initialized = True
|
||||
manager.state = RecognitionState.IDLE
|
||||
|
||||
with patch.object(
|
||||
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
|
||||
),
|
||||
):
|
||||
with patch.object(manager, "_init_whispercpp") as restore_init:
|
||||
with pytest.raises(RuntimeError, match="download failed"):
|
||||
@@ -1172,8 +1178,14 @@ class TestFailedReconfigureRestoresPreviousEngine:
|
||||
manager.model_size = "tiny"
|
||||
manager.state = RecognitionState.IDLE
|
||||
|
||||
with patch.object(
|
||||
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
|
||||
),
|
||||
):
|
||||
with patch.object(
|
||||
manager, "_init_whispercpp", side_effect=RuntimeError("restore failed")
|
||||
@@ -1210,8 +1222,14 @@ class TestFailedReconfigureRestoresPreviousEngine:
|
||||
manager._model_initialized = True
|
||||
manager.state = RecognitionState.IDLE
|
||||
|
||||
with patch.object(
|
||||
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
|
||||
),
|
||||
):
|
||||
with patch.object(manager, "_init_whispercpp") as restore_init:
|
||||
with pytest.raises(RuntimeError, match="download failed"):
|
||||
|
||||
@@ -747,7 +747,7 @@ class TestHTTPSession(unittest.TestCase):
|
||||
session = manager._http_session
|
||||
|
||||
with patch.object(manager, "_init_vosk"):
|
||||
manager.reconfigure(engine="vosk")
|
||||
manager.reconfigure(engine="vosk", force_download=False)
|
||||
|
||||
session.close.assert_called_once()
|
||||
self.assertIsNone(manager._http_session)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""The recommendation must be applicable, and must not ignore the disk (#778)."""
|
||||
"""Model recommendations are informational and never change the selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,12 +16,7 @@ import vocalinux.ui
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def settings_dialog() -> Iterator[Any]:
|
||||
"""Import settings_dialog with real base classes for its GTK subclasses.
|
||||
|
||||
Same reasoning as tests/test_settings_model_state.py; both sys.modules and the
|
||||
package attribute are restored so later tests patching the module by name do
|
||||
not reach a second module object (#686).
|
||||
"""
|
||||
"""Import settings_dialog with concrete GTK base classes."""
|
||||
repository = sys.modules["gi.repository"]
|
||||
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
|
||||
saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
||||
@@ -44,160 +40,96 @@ def dialog_class(settings_dialog: Any) -> Any:
|
||||
return settings_dialog.SettingsDialog
|
||||
|
||||
|
||||
def _dialog_stub(language: str = "en-us") -> Mock:
|
||||
def _dialog_stub() -> Mock:
|
||||
dialog = Mock()
|
||||
dialog.language = language
|
||||
dialog.language_combo.get_active_id.return_value = language
|
||||
dialog._populating_models = False
|
||||
dialog._processing_language_change = False
|
||||
# Plain Mock attrs are truthy; these guards must be real False or the
|
||||
# language-changed handler returns before updating dialog.language.
|
||||
dialog._initializing = False
|
||||
dialog._applying_settings = False
|
||||
dialog._simple_driving = False
|
||||
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
||||
dialog.model_variant_combo.get_active_id.return_value = "small.en"
|
||||
dialog.model_combo.get_active_id.return_value = "small"
|
||||
dialog.language_combo.get_active_id.return_value = "en-us"
|
||||
dialog._get_selected_whispercpp_model.return_value = "small.en"
|
||||
dialog._get_recommended_whispercpp_model_for_language.return_value = (
|
||||
"small",
|
||||
"8GB RAM - good balance",
|
||||
)
|
||||
dialog._refresh_simple_readout = Mock()
|
||||
return dialog
|
||||
|
||||
|
||||
def _with_disk(settings_dialog: Any, downloaded: list[str]) -> tuple[Any, Any]:
|
||||
"""Patch the disk so only ``downloaded`` counts as present."""
|
||||
return (
|
||||
patch.object(settings_dialog, "list_downloaded_whispercpp_models", return_value=downloaded),
|
||||
def test_recommendation_has_no_action_button(settings_dialog: Any, dialog_class: Any) -> None:
|
||||
source = inspect.getsource(dialog_class.__init__)
|
||||
|
||||
assert "Use it" not in source
|
||||
assert "model_recommendation_button" not in source
|
||||
|
||||
|
||||
def test_same_size_whispercpp_variant_is_not_shown(settings_dialog: Any, dialog_class: Any) -> None:
|
||||
dialog = _dialog_stub()
|
||||
|
||||
with patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True):
|
||||
dialog_class._update_model_info(dialog)
|
||||
|
||||
dialog.model_recommendation_box.hide.assert_called_once_with()
|
||||
dialog.model_recommendation.set_text.assert_not_called()
|
||||
|
||||
|
||||
def test_different_size_recommendation_shows_size_and_reason(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
dialog._get_selected_whispercpp_model.return_value = "tiny"
|
||||
|
||||
with patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True):
|
||||
dialog_class._update_model_info(dialog)
|
||||
|
||||
message = dialog.model_recommendation.set_text.call_args.args[0]
|
||||
assert message.startswith("Suggested for this computer: Small")
|
||||
assert "MB" in message
|
||||
assert "8GB RAM - good balance" in message
|
||||
dialog.model_recommendation_box.show.assert_called_once_with()
|
||||
|
||||
|
||||
def test_other_engine_recommendation_uses_recommended_model_size(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
||||
dialog.model_combo.get_active_id.return_value = "small"
|
||||
dialog.language = "en-us"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
settings_dialog,
|
||||
"is_whispercpp_model_downloaded",
|
||||
side_effect=lambda name: name in downloaded,
|
||||
"VOSK_MODEL_INFO",
|
||||
{
|
||||
"small": {"size_mb": 50, "desc": "Selected model"},
|
||||
"large": {"size_mb": 250, "desc": "Recommended model"},
|
||||
},
|
||||
),
|
||||
)
|
||||
patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True),
|
||||
patch.object(settings_dialog, "_get_recommended_vosk_model", return_value=("large", "RAM")),
|
||||
):
|
||||
dialog_class._update_model_info(dialog)
|
||||
|
||||
assert "(250 MB, RAM)" in dialog.model_recommendation.set_text.call_args.args[0]
|
||||
|
||||
|
||||
def test_a_bigger_model_on_disk_is_offered_instead_of_a_download(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
"""The reported case: recommended small.en, medium.en already paid for."""
|
||||
dialog = _dialog_stub()
|
||||
listed, downloaded = _with_disk(settings_dialog, ["medium.en", "small", "tiny"])
|
||||
|
||||
with listed, downloaded:
|
||||
alternative = dialog_class._downloaded_alternative_for(dialog, "small.en")
|
||||
|
||||
assert alternative == "medium.en"
|
||||
|
||||
|
||||
def test_nothing_is_offered_when_the_recommendation_is_already_on_disk(
|
||||
def test_recommendation_does_not_change_picker_or_config_state(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
listed, downloaded = _with_disk(settings_dialog, ["small.en", "medium.en"])
|
||||
dialog.config_manager = Mock()
|
||||
|
||||
with listed, downloaded:
|
||||
assert dialog_class._downloaded_alternative_for(dialog, "small.en") is None
|
||||
|
||||
|
||||
def test_a_smaller_model_is_never_offered(settings_dialog: Any, dialog_class: Any) -> None:
|
||||
"""Reusing a download must not quietly cost accuracy."""
|
||||
dialog = _dialog_stub()
|
||||
listed, downloaded = _with_disk(settings_dialog, ["tiny", "tiny.en"])
|
||||
|
||||
with listed, downloaded:
|
||||
assert dialog_class._downloaded_alternative_for(dialog, "medium.en") is None
|
||||
|
||||
|
||||
def test_english_only_weights_are_not_offered_for_another_language(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub(language="pl")
|
||||
listed, downloaded = _with_disk(settings_dialog, ["medium.en"])
|
||||
|
||||
with listed, downloaded:
|
||||
assert dialog_class._downloaded_alternative_for(dialog, "small") is None
|
||||
|
||||
|
||||
def test_the_smallest_qualifying_model_wins(settings_dialog: Any, dialog_class: Any) -> None:
|
||||
"""Between two usable downloads, take the cheaper one to run."""
|
||||
dialog = _dialog_stub()
|
||||
listed, downloaded = _with_disk(settings_dialog, ["large", "medium.en"])
|
||||
|
||||
with listed, downloaded:
|
||||
assert dialog_class._downloaded_alternative_for(dialog, "small.en") == "medium.en"
|
||||
|
||||
|
||||
def test_applying_the_recommendation_sets_both_pickers(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
"""Clicking must move size and specialization together, not just one."""
|
||||
dialog = _dialog_stub()
|
||||
dialog._recommended_target_model = "small.en"
|
||||
|
||||
dialog_class._on_apply_recommendation(dialog, None)
|
||||
|
||||
dialog.model_combo.set_active_id.assert_called_once_with("small")
|
||||
dialog._populate_whispercpp_variant_options.assert_called_once_with("small", "small.en")
|
||||
dialog.model_variant_combo.set_active_id.assert_called_once_with("small.en")
|
||||
dialog._sync_language_options_for_selected_model.assert_called_once_with()
|
||||
dialog._update_model_info.assert_called_once_with()
|
||||
dialog._refresh_unused_downloads.assert_called_once_with()
|
||||
dialog._auto_apply_settings.assert_called_once_with()
|
||||
assert dialog._populating_models is False
|
||||
|
||||
|
||||
def test_applying_does_nothing_without_a_target(settings_dialog: Any, dialog_class: Any) -> None:
|
||||
dialog = _dialog_stub()
|
||||
dialog._recommended_target_model = None
|
||||
|
||||
dialog_class._on_apply_recommendation(dialog, None)
|
||||
with patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True):
|
||||
dialog_class._update_model_info(dialog)
|
||||
|
||||
dialog.model_combo.set_active_id.assert_not_called()
|
||||
dialog._auto_apply_settings.assert_not_called()
|
||||
dialog.model_variant_combo.set_active_id.assert_not_called()
|
||||
dialog.config_manager.set.assert_not_called()
|
||||
dialog.config_manager.save_settings.assert_not_called()
|
||||
|
||||
|
||||
def test_applying_recommendation_suppresses_model_changed_auto_apply(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
"""Size change must not briefly auto-apply the default size before the target.
|
||||
|
||||
Without ``_populating_models``, ``set_active_id(large)`` would fire
|
||||
``_on_model_changed`` and download full large before turbo-q5_0 lands.
|
||||
"""
|
||||
dialog = _dialog_stub()
|
||||
dialog._recommended_target_model = "large-v3-turbo-q5_0"
|
||||
seen_while_setting_size: list[bool] = []
|
||||
|
||||
def capture_flag(_model_size: str) -> bool:
|
||||
seen_while_setting_size.append(dialog._populating_models)
|
||||
return True
|
||||
|
||||
dialog.model_combo.set_active_id.side_effect = capture_flag
|
||||
|
||||
dialog_class._on_apply_recommendation(dialog, None)
|
||||
|
||||
assert seen_while_setting_size == [True]
|
||||
dialog._populate_whispercpp_variant_options.assert_called_once_with(
|
||||
"large", "large-v3-turbo-q5_0"
|
||||
def test_model_selection_docs_url_is_stable(settings_dialog: Any) -> None:
|
||||
assert (
|
||||
settings_dialog.MODEL_SELECTION_DOC_URL
|
||||
== "https://github.com/VocaHQ/vocalinux/blob/main/docs/MODEL_SELECTION.md"
|
||||
)
|
||||
dialog.model_variant_combo.set_active_id.assert_called_once_with("large-v3-turbo-q5_0")
|
||||
# One apply after both pickers settle — not during the size change.
|
||||
dialog._auto_apply_settings.assert_called_once_with()
|
||||
assert dialog._populating_models is False
|
||||
|
||||
|
||||
def test_language_change_refreshes_recommendation_ui(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
"""EN→PL must refresh recommendation target / label so Use it is not stale."""
|
||||
dialog = _dialog_stub(language="en-us")
|
||||
dialog.language_combo.get_active_id.return_value = "pl"
|
||||
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
||||
dialog._recommended_target_model = "small.en"
|
||||
|
||||
dialog_class._on_language_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "pl"
|
||||
dialog._populate_model_options.assert_called_once_with()
|
||||
dialog._update_language_warning.assert_called_once_with()
|
||||
dialog._update_model_info.assert_called_once_with()
|
||||
dialog._auto_apply_settings.assert_called_once_with()
|
||||
# Refresh happens before auto-apply so the card is current when settings land.
|
||||
assert dialog.mock_calls.index(call._update_model_info()) < dialog.mock_calls.index(
|
||||
call._auto_apply_settings()
|
||||
)
|
||||
assert dialog._processing_language_change is False
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
import vocalinux.ui
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.utils.model_choice import FASTEST, priority_for_size
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -201,6 +202,112 @@ def test_failed_auto_apply_resyncs_the_pickers_with_the_config(settings_dialog,
|
||||
assert dialog._applying_settings is False
|
||||
|
||||
|
||||
def test_auto_apply_confirms_before_starting_a_download(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
dialog.get_selected_settings.return_value = {
|
||||
"engine": "whisper_cpp",
|
||||
"model_size": "small",
|
||||
"model_variant": "small",
|
||||
}
|
||||
dialog._confirm_model_download = Mock(return_value=False)
|
||||
dialog._drop_staged_model_spec_and_resync = Mock()
|
||||
|
||||
with patch.object(
|
||||
settings_dialog,
|
||||
"_undownloaded_model_info",
|
||||
return_value={"size_mb": 100, "url": "https://example.test/model.bin"},
|
||||
):
|
||||
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
||||
|
||||
dialog._confirm_model_download.assert_called_once_with("whisper_cpp", "small", 100)
|
||||
dialog.speech_engine.try_begin_download.assert_not_called()
|
||||
dialog._drop_staged_model_spec_and_resync.assert_called_once_with()
|
||||
|
||||
|
||||
def test_apply_confirms_before_starting_a_download(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
dialog.get_selected_settings.return_value = {
|
||||
"engine": "whisper_cpp",
|
||||
"model_size": "small",
|
||||
"model_variant": "small",
|
||||
}
|
||||
dialog._confirm_model_download = Mock(return_value=False)
|
||||
dialog._drop_staged_model_spec_and_resync = Mock()
|
||||
|
||||
with patch.object(
|
||||
settings_dialog,
|
||||
"_undownloaded_model_info",
|
||||
return_value={"size_mb": 100, "url": "https://example.test/model.bin"},
|
||||
):
|
||||
assert dialog_class.apply_settings(dialog) is False
|
||||
|
||||
dialog._confirm_model_download.assert_called_once_with("whisper_cpp", "small", 100)
|
||||
dialog.speech_engine.try_begin_download.assert_not_called()
|
||||
dialog._drop_staged_model_spec_and_resync.assert_called_once_with()
|
||||
|
||||
|
||||
def test_download_confirmation_includes_catalog_host_and_cancel_default(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
gtk = settings_dialog.Gtk
|
||||
gtk.MessageDialog.reset_mock()
|
||||
message_dialog = gtk.MessageDialog.return_value
|
||||
message_dialog.run.return_value = gtk.ResponseType.CANCEL
|
||||
|
||||
with patch.object(
|
||||
settings_dialog,
|
||||
"_undownloaded_model_info",
|
||||
return_value={"url": "https://models.example.test/model.bin"},
|
||||
):
|
||||
assert dialog_class._confirm_model_download(Mock(), "whisper_cpp", "small", 125) is False
|
||||
|
||||
gtk.MessageDialog.assert_called_once()
|
||||
kwargs = gtk.MessageDialog.call_args.kwargs
|
||||
assert kwargs["message_type"] == gtk.MessageType.QUESTION
|
||||
assert kwargs["text"] == "Download Small?"
|
||||
message_dialog.format_secondary_text.assert_called_once_with(
|
||||
"About 125 MB from models.example.test. Nothing is downloaded until you confirm."
|
||||
)
|
||||
message_dialog.set_default_response.assert_called_once_with(gtk.ResponseType.CANCEL)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("engine", "model_name", "language", "host"),
|
||||
[
|
||||
("whisper", "small", "en-us", "openaipublic.azureedge.net"),
|
||||
("whisper_cpp", "small", "en-us", "huggingface.co"),
|
||||
("vosk", "small", "en-us", "alphacephei.com"),
|
||||
("parakeet", "v3-european", "en-us", "huggingface.co"),
|
||||
("faster_whisper", "tiny", "en-us", "huggingface.co"),
|
||||
],
|
||||
)
|
||||
def test_model_download_source_url_covers_each_downloadable_engine(
|
||||
settings_dialog: Any, engine: str, model_name: str, language: str, host: str
|
||||
) -> None:
|
||||
url = settings_dialog._model_download_source_url(engine, model_name, language)
|
||||
|
||||
assert settings_dialog.urlparse(url).netloc == host
|
||||
|
||||
|
||||
def test_download_confirmation_derives_host_when_catalog_has_no_url(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
gtk = settings_dialog.Gtk
|
||||
gtk.MessageDialog.reset_mock()
|
||||
gtk.MessageDialog.return_value.run.return_value = gtk.ResponseType.CANCEL
|
||||
|
||||
with patch.object(settings_dialog, "_undownloaded_model_info", return_value={"size_mb": 125}):
|
||||
assert dialog_class._confirm_model_download(Mock(), "whisper", "small", 125) is False
|
||||
|
||||
gtk.MessageDialog.return_value.format_secondary_text.assert_called_once_with(
|
||||
"About 125 MB from openaipublic.azureedge.net. Nothing is downloaded until you confirm."
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -430,7 +537,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._idle_resync_model_ui_from_config, ()) in scheduled
|
||||
assert (dialog._idle_drop_staged_model_spec_and_resync, ()) 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()
|
||||
@@ -456,7 +563,7 @@ def test_download_path_resyncs_when_the_download_is_cancelled(settings_dialog, d
|
||||
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
||||
|
||||
modal = modal_class.return_value
|
||||
assert (dialog._idle_resync_model_ui_from_config, ()) in idle_calls
|
||||
assert (dialog._idle_drop_staged_model_spec_and_resync, ()) in idle_calls
|
||||
assert (modal.set_complete, (False, "Download cancelled")) in idle_calls
|
||||
|
||||
|
||||
@@ -478,7 +585,7 @@ def test_modal_close_resyncs_an_engine_that_never_applied(settings_dialog, dialo
|
||||
):
|
||||
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
||||
|
||||
dialog._resync_engine_ui_if_unapplied.assert_called_once()
|
||||
dialog._drop_staged_model_spec_and_resync.assert_called_once_with()
|
||||
|
||||
|
||||
def test_idle_resync_skips_a_destroyed_dialog(dialog_class):
|
||||
@@ -556,6 +663,107 @@ def test_resync_restores_language_from_saved_config(dialog_class):
|
||||
dialog.engine_combo.set_active_id.assert_not_called()
|
||||
|
||||
|
||||
def _resync_dialog_for_language(
|
||||
dialog_class: type[Any], saved_language: str
|
||||
) -> tuple[Mock, dict[str, Any]]:
|
||||
dialog = _dialog_stub()
|
||||
state: dict[str, Any] = {
|
||||
"language": "auto",
|
||||
"follow": False,
|
||||
"priority": FASTEST,
|
||||
}
|
||||
dialog.config_manager.get_settings.return_value = {
|
||||
"speech_recognition": {
|
||||
"engine": "whisper_cpp",
|
||||
"model_size": "medium",
|
||||
"language": saved_language,
|
||||
}
|
||||
}
|
||||
dialog.config_manager.get.return_value = ""
|
||||
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
||||
dialog.language_combo.get_active_id.side_effect = lambda: state["language"]
|
||||
dialog.language_combo.set_active_id.side_effect = lambda value: state.__setitem__(
|
||||
"language", value
|
||||
)
|
||||
dialog.follow_layout_switch.get_active.side_effect = lambda: state["follow"]
|
||||
dialog.follow_layout_switch.set_active.side_effect = lambda value: state.__setitem__(
|
||||
"follow", value
|
||||
)
|
||||
dialog.simple_priority_combo.get_active_id.side_effect = lambda: state["priority"]
|
||||
dialog.simple_priority_combo.set_active_id.side_effect = lambda value: state.__setitem__(
|
||||
"priority", value
|
||||
)
|
||||
dialog._get_selected_engine.return_value = "whisper_cpp"
|
||||
dialog._get_selected_whispercpp_model.return_value = "medium"
|
||||
dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason")
|
||||
dialog._follow_layout_supported.return_value = True
|
||||
dialog._is_selected_whispercpp_model_english_only.return_value = False
|
||||
|
||||
def set_combo_active_id(combo: Any, value: str) -> bool:
|
||||
combo.set_active_id(value)
|
||||
return True
|
||||
|
||||
dialog._set_combo_active_id_or_first.side_effect = set_combo_active_id
|
||||
dialog._populate_model_options = Mock()
|
||||
dialog._populate_language_options = Mock()
|
||||
dialog._update_model_info = Mock()
|
||||
dialog._update_model_picker_tooltips = Mock()
|
||||
dialog._update_simple_visibility = Mock()
|
||||
dialog._auto_apply_settings = Mock()
|
||||
dialog._confirm_model_download = Mock()
|
||||
dialog.config_manager.update_speech_recognition_settings = Mock()
|
||||
dialog.speech_engine.try_begin_download = Mock()
|
||||
dialog._simple_syncing = False
|
||||
dialog._staged_model_spec = {"engine": "whisper_cpp", "model_size": "large"}
|
||||
_bind_real(
|
||||
dialog,
|
||||
dialog_class,
|
||||
"_drop_staged_model_spec_and_resync",
|
||||
"_resync_model_ui_from_config",
|
||||
"_sync_language_options_for_selected_model",
|
||||
"_sync_follow_layout_controls",
|
||||
"_update_language_warning",
|
||||
"_sync_simple_from_advanced",
|
||||
)
|
||||
return dialog, state
|
||||
|
||||
|
||||
def test_declined_model_resync_restores_follow_layout_and_saved_simple_priority(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
dialog, state = _resync_dialog_for_language(dialog_class, "layout")
|
||||
|
||||
with patch.object(settings_dialog, "language_for_active_layout", return_value="en-us"):
|
||||
dialog._drop_staged_model_spec_and_resync()
|
||||
|
||||
expected_priority = priority_for_size("small", "medium")
|
||||
assert dialog._staged_model_spec is None
|
||||
assert dialog._follow_layout_saved is True
|
||||
assert dialog.follow_layout_switch.get_active() is True
|
||||
assert dialog._follow_layout_active is True
|
||||
assert dialog.language_combo.get_active_id() == "en-us"
|
||||
assert dialog.language == "en-us"
|
||||
assert state["priority"] == expected_priority
|
||||
dialog.config_manager.update_speech_recognition_settings.assert_not_called()
|
||||
dialog.speech_engine.try_begin_download.assert_not_called()
|
||||
dialog._confirm_model_download.assert_not_called()
|
||||
dialog._auto_apply_settings.assert_not_called()
|
||||
|
||||
|
||||
def test_resync_with_saved_language_disables_follow_layout(
|
||||
dialog_class: type[Any],
|
||||
) -> None:
|
||||
dialog, _state = _resync_dialog_for_language(dialog_class, "de")
|
||||
|
||||
dialog._drop_staged_model_spec_and_resync()
|
||||
|
||||
assert dialog._follow_layout_saved is False
|
||||
assert dialog.follow_layout_switch.get_active() is False
|
||||
assert dialog._follow_layout_active is False
|
||||
assert dialog.language_combo.get_active_id() == "de"
|
||||
assert dialog.language == "de"
|
||||
|
||||
|
||||
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()
|
||||
@@ -744,6 +952,7 @@ def _download_setup(dialog: Mock) -> None:
|
||||
"language": "auto",
|
||||
}
|
||||
dialog._apply_settings_internal.return_value = True
|
||||
dialog._confirm_model_download.return_value = True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["_auto_apply_settings", "apply_settings"])
|
||||
@@ -772,7 +981,7 @@ def test_settings_refuses_a_download_while_the_tray_holds_the_engine(
|
||||
modal_class.assert_not_called()
|
||||
dialog._apply_settings_internal.assert_not_called()
|
||||
dialog._show_download_busy_dialog.assert_called_once_with()
|
||||
dialog._resync_model_ui_from_config.assert_called_once_with()
|
||||
dialog._drop_staged_model_spec_and_resync.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["_auto_apply_settings", "apply_settings"])
|
||||
@@ -796,6 +1005,43 @@ def test_settings_releases_the_engine_once_the_download_is_over(
|
||||
dialog.speech_engine.end_download.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["_auto_apply_settings", "apply_settings"])
|
||||
def test_cancel_poll_stays_registered_until_download_worker_removes_it(
|
||||
settings_dialog: Any, dialog_class: type[Any], entry: str
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
_download_setup(dialog)
|
||||
source_callbacks = []
|
||||
callback_results = []
|
||||
glib = MagicMock()
|
||||
|
||||
def timeout_add(_interval: int, callback: Callable[[], bool]) -> int:
|
||||
source_callbacks.append(callback)
|
||||
callback_results.append(callback())
|
||||
return 74
|
||||
|
||||
glib.timeout_add.side_effect = timeout_add
|
||||
|
||||
with (
|
||||
patch.object(settings_dialog, "_undownloaded_model_info", return_value={"size_mb": 100}),
|
||||
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
||||
patch.object(settings_dialog, "GLib", glib),
|
||||
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
||||
):
|
||||
modal_class.return_value.cancelled = True
|
||||
if entry == "_auto_apply_settings":
|
||||
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
||||
else:
|
||||
dialog_class.apply_settings(dialog)
|
||||
|
||||
dialog.speech_engine.try_begin_download.assert_called_once_with()
|
||||
assert len(source_callbacks) == 1
|
||||
assert callback_results == [True]
|
||||
glib.source_remove.assert_called_once_with(74)
|
||||
dialog.speech_engine.cancel_download.assert_called_once_with()
|
||||
dialog.speech_engine.end_download.assert_called_once_with()
|
||||
|
||||
|
||||
def test_a_picker_change_only_stages_a_missing_model(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
@@ -921,7 +1167,6 @@ def _update_model_info_stub(dialog_class: type[Any]) -> Mock:
|
||||
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
||||
dialog._get_selected_whispercpp_model.return_value = "small"
|
||||
dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "fits")
|
||||
dialog._downloaded_alternative_for.return_value = None
|
||||
return dialog
|
||||
|
||||
|
||||
@@ -1185,6 +1430,17 @@ def test_whisper_selected_settings_keep_combo_language(dialog_class):
|
||||
assert settings["language"] == "fr"
|
||||
|
||||
|
||||
def test_follow_layout_selected_settings_keep_the_layout_sentinel(
|
||||
settings_dialog: Any, dialog_class: type[Any]
|
||||
) -> None:
|
||||
dialog = _dialog_for_selected_settings("whisper.cpp", "de")
|
||||
|
||||
with patch.object(settings_dialog, "_is_following_layout", return_value=True):
|
||||
settings = dialog_class.get_selected_settings(dialog)
|
||||
|
||||
assert settings["language"] == settings_dialog.LANGUAGE_FOLLOWS_LAYOUT
|
||||
|
||||
|
||||
def test_parakeet_engine_change_forces_language_auto(dialog_class):
|
||||
"""A leftover catalog language from another engine must not stick in memory."""
|
||||
dialog = _dialog_stub()
|
||||
@@ -1458,7 +1714,12 @@ def test_speech_manager_reconfigure_to_parakeet_normalizes_language():
|
||||
defer_download=True,
|
||||
)
|
||||
assert manager.language == "en-us"
|
||||
manager.reconfigure(engine="parakeet", model_size="v3-european", language="fr")
|
||||
manager.reconfigure(
|
||||
engine="parakeet",
|
||||
model_size="v3-european",
|
||||
language="fr",
|
||||
force_download=False,
|
||||
)
|
||||
assert manager.engine == "parakeet"
|
||||
assert manager.language == "auto"
|
||||
assert manager.command_processor.language == "auto"
|
||||
|
||||
@@ -118,17 +118,42 @@ def test_test_dictation_missing_model_skips_listen_timer() -> None:
|
||||
|
||||
thread_cls.assert_not_called()
|
||||
assert dialog._test_active is False
|
||||
assert "Speech Model page" in dialog.test_buffer._text
|
||||
assert (
|
||||
dialog.test_buffer._text
|
||||
== "Tiny is not downloaded yet. Press Download on the Speech Model page first, "
|
||||
"or pick a downloaded model."
|
||||
)
|
||||
assert "No speech detected" not in dialog.test_buffer._text
|
||||
dialog.speech_engine.set_text_callbacks.assert_any_call([])
|
||||
dialog.apply_settings.assert_not_called()
|
||||
dialog.test_button.set_label.assert_not_called()
|
||||
dialog.test_output_revealer.set_reveal_child.assert_called_with(True)
|
||||
|
||||
|
||||
def test_cancelled_settings_apply_does_not_start_test_dictation() -> None:
|
||||
dialog = _dialog_for_test(start_return=True)
|
||||
dialog.get_selected_settings.return_value["model_size"] = "small"
|
||||
dialog.apply_settings.return_value = False
|
||||
|
||||
with (
|
||||
patch.object(settings_dialog, "_undownloaded_model_info", return_value=None),
|
||||
patch.object(settings_dialog.threading, "Thread") as thread_cls,
|
||||
):
|
||||
SettingsDialog._on_test_clicked(dialog, None)
|
||||
|
||||
dialog.apply_settings.assert_called_once()
|
||||
dialog.speech_engine.start_recognition.assert_not_called()
|
||||
thread_cls.assert_not_called()
|
||||
assert dialog._test_active is False
|
||||
assert dialog.test_buffer._text == "Failed to apply settings. Please try again."
|
||||
|
||||
|
||||
def test_test_dictation_auto_paused_message() -> None:
|
||||
dialog = _dialog_for_test(start_return=False, is_auto_paused=True)
|
||||
|
||||
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
||||
with (
|
||||
patch.object(settings_dialog, "_undownloaded_model_info", return_value=None),
|
||||
patch.object(settings_dialog.threading, "Thread") as thread_cls,
|
||||
):
|
||||
SettingsDialog._on_test_clicked(dialog, None)
|
||||
|
||||
thread_cls.assert_not_called()
|
||||
@@ -139,7 +164,10 @@ def test_test_dictation_auto_paused_message() -> None:
|
||||
def test_test_dictation_start_failure_generic_message() -> None:
|
||||
dialog = _dialog_for_test(start_return=False, model_ready=True, is_auto_paused=False)
|
||||
|
||||
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
||||
with (
|
||||
patch.object(settings_dialog, "_undownloaded_model_info", return_value=None),
|
||||
patch.object(settings_dialog.threading, "Thread") as thread_cls,
|
||||
):
|
||||
SettingsDialog._on_test_clicked(dialog, None)
|
||||
|
||||
thread_cls.assert_not_called()
|
||||
@@ -149,7 +177,10 @@ def test_test_dictation_start_failure_generic_message() -> None:
|
||||
def test_test_dictation_started_arms_listen_timer() -> None:
|
||||
dialog = _dialog_for_test(start_return=True)
|
||||
|
||||
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
||||
with (
|
||||
patch.object(settings_dialog, "_undownloaded_model_info", return_value=None),
|
||||
patch.object(settings_dialog.threading, "Thread") as thread_cls,
|
||||
):
|
||||
SettingsDialog._on_test_clicked(dialog, None)
|
||||
|
||||
thread_cls.assert_called_once()
|
||||
@@ -174,7 +205,10 @@ def test_test_dictation_reconfigures_when_live_engine_differs_from_ui() -> None:
|
||||
|
||||
dialog.apply_settings.side_effect = _apply_and_sync
|
||||
|
||||
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
||||
with (
|
||||
patch.object(settings_dialog, "_undownloaded_model_info", return_value=None),
|
||||
patch.object(settings_dialog.threading, "Thread") as thread_cls,
|
||||
):
|
||||
SettingsDialog._on_test_clicked(dialog, None)
|
||||
|
||||
dialog.apply_settings.assert_called_once()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -387,6 +388,67 @@ def test_auto_detect_shows_up_as_the_other_languages_switch(settings_dialog, dia
|
||||
dialog.simple_multi_switch.set_active.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_follow_layout_shows_and_disables_the_resolved_simple_language(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub(language="de")
|
||||
dialog.language_combo.get_active_id.return_value = "de"
|
||||
dialog._get_selected_whispercpp_model.return_value = "small"
|
||||
dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason")
|
||||
|
||||
with patch.object(settings_dialog, "_is_following_layout", return_value=True):
|
||||
dialog_class._sync_simple_from_advanced(dialog)
|
||||
|
||||
dialog.simple_language_combo.set_active_id.assert_called_once_with("de")
|
||||
dialog.simple_language_row.set_sensitive.assert_called_once_with(False)
|
||||
dialog.simple_language_row.set_tooltip_text.assert_called_once_with(
|
||||
"Follows your keyboard layout. Turn off Follow keyboard layout "
|
||||
"in Advanced to pick a language."
|
||||
)
|
||||
|
||||
|
||||
def test_follow_layout_priority_change_does_not_write_resolved_language(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub(language="de", priority=ACCURATE, recommended="small")
|
||||
dialog._get_selected_engine.return_value = "whisper_cpp"
|
||||
|
||||
with patch.object(settings_dialog, "_is_following_layout", return_value=True):
|
||||
dialog_class._apply_simple_choice(dialog)
|
||||
|
||||
dialog._set_combo_active_id_or_first.assert_not_called()
|
||||
assert dialog.language == "de"
|
||||
dialog.model_combo.set_active_id.assert_called_once_with("medium")
|
||||
|
||||
|
||||
def test_follow_layout_priority_change_keeps_multilingual_variant_and_mode(
|
||||
settings_dialog: Any, dialog_class: Any
|
||||
) -> None:
|
||||
dialog = _dialog_stub(language="en-us", priority=FASTEST, recommended="small")
|
||||
dialog._follow_layout_active = True
|
||||
dialog.follow_layout_switch.get_active.return_value = True
|
||||
dialog._follow_layout_supported.return_value = True
|
||||
selected_variant: dict[str, Any] = {"id": None}
|
||||
dialog.model_variant_combo.set_active_id.side_effect = lambda variant: selected_variant.update(
|
||||
id=variant
|
||||
)
|
||||
dialog._get_selected_whispercpp_model.side_effect = lambda: selected_variant["id"] or "small"
|
||||
dialog._is_selected_whispercpp_model_english_only.side_effect = lambda: (
|
||||
settings_dialog.is_english_only_whispercpp_model(dialog._get_selected_whispercpp_model())
|
||||
)
|
||||
|
||||
dialog_class._apply_simple_choice(dialog)
|
||||
dialog_class._sync_follow_layout_controls(dialog)
|
||||
|
||||
assert selected_variant["id"] == "base"
|
||||
dialog.model_variant_combo.set_active_id.assert_called_once_with("base")
|
||||
dialog._on_disk_stand_in.assert_called_once_with(
|
||||
"base", "base", settings_dialog.LANGUAGE_FOLLOWS_LAYOUT
|
||||
)
|
||||
assert dialog.follow_layout_switch.get_active() is True
|
||||
assert dialog._follow_layout_active is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stale_second", ["auto", "en-us"])
|
||||
def test_pinned_advanced_language_plus_stale_stored_second_does_not_replace_the_pin(
|
||||
settings_dialog, dialog_class, stale_second
|
||||
|
||||
Reference in New Issue
Block a user