Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
1912 lines
75 KiB
Python
1912 lines
75 KiB
Python
"""Tests for keeping the saved model in step with the engine that runs it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import inspect
|
|
import sys
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
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")
|
|
def 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.
|
|
|
|
This runs as a fixture, not at collection: reimporting rebinds
|
|
``vocalinux.ui.settings_dialog`` on the package as well as in
|
|
``sys.modules``, and leaving those two pointing at different module objects
|
|
breaks any later test that patches the module by name (#686 hit exactly
|
|
that in test_model_deletion). Both are restored here.
|
|
"""
|
|
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)
|
|
saved_attribute = getattr(vocalinux.ui, "settings_dialog", None)
|
|
try:
|
|
with patch.object(repository, "Gtk", MagicMock(**bases)):
|
|
module = importlib.import_module("vocalinux.ui.settings_dialog")
|
|
yield module
|
|
finally:
|
|
sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
if saved_module is not None:
|
|
sys.modules["vocalinux.ui.settings_dialog"] = saved_module
|
|
if saved_attribute is not None:
|
|
vocalinux.ui.settings_dialog = saved_attribute
|
|
elif hasattr(vocalinux.ui, "settings_dialog"):
|
|
del vocalinux.ui.settings_dialog
|
|
|
|
|
|
@pytest.fixture
|
|
def dialog_class(settings_dialog):
|
|
return settings_dialog.SettingsDialog
|
|
|
|
|
|
def _dialog_stub() -> Mock:
|
|
"""A stand-in ``self`` for calling dialog methods without building the UI."""
|
|
dialog = Mock()
|
|
dialog._applying_settings = False
|
|
dialog._initializing = False
|
|
dialog._test_active = False
|
|
dialog._populating_models = False
|
|
dialog._processing_language_change = False
|
|
# A Mock attribute is truthy, which would make _auto_apply_settings think
|
|
# simple mode is mid-way through steering the controls (#779).
|
|
dialog._simple_driving = False
|
|
dialog.language = "en-us"
|
|
dialog._last_non_parakeet_language = None
|
|
dialog._engine_for_language_memory = None
|
|
# A Mock attribute is not a dict, which keeps the staged-spec lookups on the
|
|
# saved-model fallback; tests that exercise staging set this themselves.
|
|
dialog._staged_model_spec = None
|
|
# The real attribute is an enum member; a bare "idle" string would compare
|
|
# unequal and send every test down the stop_recognition + sleep(0.5) branch.
|
|
dialog.speech_engine.state = RecognitionState.IDLE
|
|
return dialog
|
|
|
|
|
|
class _InlineThread:
|
|
"""Run the download worker on the calling thread, in call order."""
|
|
|
|
def __init__(
|
|
self,
|
|
target: Callable[..., Any] | None = None,
|
|
daemon: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
self._target = target
|
|
|
|
def start(self) -> None:
|
|
self._target()
|
|
|
|
|
|
class _DeferredThread:
|
|
"""Record the worker without running it, so the apply-guard stays held."""
|
|
|
|
def __init__(
|
|
self,
|
|
target: Callable[..., Any] | None = None,
|
|
daemon: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
self.target = target
|
|
|
|
def start(self) -> None:
|
|
pass
|
|
|
|
|
|
def _glib_stub(idle_calls: list[tuple[Any, tuple[Any, ...]]]) -> MagicMock:
|
|
glib = MagicMock()
|
|
glib.idle_add.side_effect = lambda func, *args: idle_calls.append((func, args))
|
|
return glib
|
|
|
|
|
|
def _already_downloaded_settings() -> dict[str, str]:
|
|
return {
|
|
"engine": "vosk",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
}
|
|
|
|
|
|
def _run_finish_idle(
|
|
dialog: Mock,
|
|
dialog_class: type[Any],
|
|
idle_calls: list[tuple[Any, tuple[Any, ...]]],
|
|
) -> None:
|
|
"""Invoke the scheduled apply-guard release (Mock dialogs have no real method)."""
|
|
finish = dialog_class._finish_auto_apply
|
|
ran = False
|
|
for func, args in idle_calls:
|
|
if func is dialog._finish_auto_apply or func is finish:
|
|
finish(dialog, *args)
|
|
ran = True
|
|
assert ran, "expected GLib.idle_add of the apply-guard release"
|
|
|
|
|
|
def _bind_real(dialog: Mock, dialog_class: type[Any], *names: str) -> None:
|
|
"""Attach production methods so a Mock dialog cannot swallow the call."""
|
|
for name in names:
|
|
setattr(dialog, name, getattr(dialog_class, name).__get__(dialog))
|
|
|
|
|
|
def _wire_whispercpp_pickers(
|
|
dialog: Mock, *, size: str, variant: str, language: str
|
|
) -> dict[str, str]:
|
|
"""Enough combo/spin state for real ``get_selected_settings`` on whisper.cpp."""
|
|
saved = {"engine": "whisper_cpp", "model_size": variant, "language": language}
|
|
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
|
dialog.model_combo.get_active_id.return_value = size
|
|
dialog.model_variant_combo.get_active_id.return_value = variant
|
|
dialog.language_combo.get_active_id.return_value = language
|
|
dialog.vad_spin.get_value.return_value = 3
|
|
dialog.silence_spin.get_value.return_value = 2.0
|
|
dialog.gpu_device_combo.get_active_id.return_value = None
|
|
dialog.language = language
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": dict(saved)}
|
|
dialog._dialog_is_alive.return_value = True
|
|
return saved
|
|
|
|
|
|
def test_settings_persisted_only_after_the_engine_accepts_them(dialog_class):
|
|
"""A model is saved once it really loaded, not when it was picked."""
|
|
dialog = _dialog_stub()
|
|
order = []
|
|
dialog.speech_engine.reconfigure.side_effect = lambda **kw: order.append("reconfigure")
|
|
dialog._save_selected_settings.side_effect = lambda settings: order.append("save")
|
|
|
|
assert dialog_class._apply_settings_internal(dialog, {"engine": "vosk"}) is True
|
|
assert order == ["reconfigure", "save"]
|
|
|
|
|
|
def test_failed_apply_leaves_the_previous_model_configured(dialog_class):
|
|
"""Nothing is written when reconfiguring fails, so the old model stays."""
|
|
dialog = _dialog_stub()
|
|
dialog.speech_engine.reconfigure.side_effect = RuntimeError("Download cancelled")
|
|
|
|
assert dialog_class._apply_settings_internal(dialog, {"engine": "whisper_cpp"}) is False
|
|
|
|
dialog._save_selected_settings.assert_not_called()
|
|
|
|
|
|
def test_failed_auto_apply_resyncs_the_pickers_with_the_config(settings_dialog, dialog_class):
|
|
"""The pickers go back to the saved model so a retry is possible."""
|
|
dialog = _dialog_stub()
|
|
dialog.get_selected_settings.return_value = _already_downloaded_settings()
|
|
dialog._apply_settings_internal.side_effect = RuntimeError("boom")
|
|
idle_calls = []
|
|
|
|
with (
|
|
patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
|
|
dialog._save_selected_settings.assert_not_called()
|
|
assert (dialog._idle_resync_model_ui_from_config, ()) in idle_calls
|
|
assert dialog._applying_settings is True
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
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()
|
|
settings = _already_downloaded_settings()
|
|
dialog.get_selected_settings.return_value = settings
|
|
dialog._apply_settings_internal.return_value = True
|
|
idle_calls = []
|
|
|
|
with (
|
|
patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
|
|
dialog._apply_settings_internal.assert_called_once_with(
|
|
settings, raise_errors=True, apply_generation=settings_dialog._apply_settings_generation
|
|
)
|
|
dialog.speech_engine.reconfigure.assert_not_called()
|
|
assert dialog._applying_settings is True
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
assert dialog._applying_settings is False
|
|
|
|
|
|
def test_apply_guard_blocks_a_second_auto_apply_while_a_worker_is_in_flight(
|
|
settings_dialog, dialog_class
|
|
):
|
|
"""A second pick must not start another load until the first apply finishes."""
|
|
dialog = _dialog_stub()
|
|
dialog.get_selected_settings.return_value = _already_downloaded_settings()
|
|
idle_calls = []
|
|
workers = []
|
|
|
|
class _CaptureThread(_DeferredThread):
|
|
def __init__(
|
|
self,
|
|
target: Callable[..., Any] | None = None,
|
|
daemon: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(target=target, daemon=daemon, **kwargs)
|
|
workers.append(self)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _CaptureThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert dialog._applying_settings is True
|
|
assert len(workers) == 1
|
|
|
|
dialog.get_selected_settings.reset_mock()
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert len(workers) == 1
|
|
dialog.get_selected_settings.assert_not_called()
|
|
dialog._apply_settings_internal.assert_not_called()
|
|
|
|
workers[0].target()
|
|
dialog._apply_settings_internal.assert_called_once()
|
|
assert dialog._applying_settings is True
|
|
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
assert dialog._applying_settings is False
|
|
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert len(workers) == 2
|
|
|
|
|
|
def test_second_pick_during_apply_resyncs_ui_when_the_worker_finishes(
|
|
settings_dialog, dialog_class
|
|
):
|
|
"""A second pick while the worker runs must not leave the combos on the unapplied model."""
|
|
dialog = _dialog_stub()
|
|
first = _already_downloaded_settings()
|
|
dialog.get_selected_settings.return_value = first
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": dict(first)}
|
|
idle_calls = []
|
|
workers = []
|
|
|
|
class _CaptureThread(_DeferredThread):
|
|
def __init__(
|
|
self,
|
|
target: Callable[..., Any] | None = None,
|
|
daemon: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(target=target, daemon=daemon, **kwargs)
|
|
workers.append(self)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _CaptureThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert dialog._applying_settings is True
|
|
assert len(workers) == 1
|
|
|
|
dialog.get_selected_settings.return_value = {
|
|
"engine": "vosk",
|
|
"model_size": "medium",
|
|
"language": "en-us",
|
|
}
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert len(workers) == 1
|
|
|
|
workers[0].target()
|
|
dialog._apply_settings_internal.assert_called_once_with(
|
|
first,
|
|
raise_errors=True,
|
|
apply_generation=settings_dialog._apply_settings_generation,
|
|
)
|
|
assert dialog._applying_settings is True
|
|
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
|
|
dialog._resync_model_ui_from_config.assert_called_once()
|
|
assert dialog._applying_settings is False
|
|
|
|
|
|
def test_matching_selection_on_finish_still_resyncs(settings_dialog, dialog_class):
|
|
"""Finish always resyncs pickers, even when selected settings still match saved."""
|
|
dialog = _dialog_stub()
|
|
settings = _already_downloaded_settings()
|
|
dialog.get_selected_settings.return_value = settings
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": dict(settings)}
|
|
idle_calls = []
|
|
|
|
with (
|
|
patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
dialog._resync_model_ui_from_config.assert_called_once()
|
|
assert dialog._applying_settings is False
|
|
|
|
|
|
def test_apply_settings_returns_false_while_guard_held(dialog_class: type[Any]) -> None:
|
|
"""A held apply-guard must no-op apply_settings without touching the engine."""
|
|
dialog = _dialog_stub()
|
|
dialog._applying_settings = True
|
|
|
|
result = dialog_class.apply_settings(dialog)
|
|
|
|
assert result is False
|
|
dialog.get_selected_settings.assert_not_called()
|
|
dialog._apply_settings_internal.assert_not_called()
|
|
dialog.speech_engine.try_begin_download.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("settings_differ", [False, True])
|
|
def test_test_click_blocked_while_settings_are_applying(
|
|
settings_dialog: Any, dialog_class: type[Any], settings_differ: bool
|
|
) -> None:
|
|
"""Test must not start a second apply or recognition while a worker holds the guard.
|
|
|
|
UI matching the saved config is not enough: the live engine may still be
|
|
mid-reconfigure. Differing settings are the other race — apply_settings
|
|
itself must not be entered.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
dialog._applying_settings = True
|
|
dialog.test_buffer = Mock()
|
|
dialog.test_output_revealer = 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.return_value = {
|
|
"engine": "vosk" if settings_differ else "whisper_cpp",
|
|
"model_size": "small" if settings_differ else "tiny",
|
|
"silence_timeout": 2.0,
|
|
"vad_sensitivity": 3,
|
|
}
|
|
dialog.speech_engine.engine = "whisper_cpp"
|
|
dialog.speech_engine.model_size = "tiny"
|
|
# Real apply_settings (not a dummy True): if Test skipped its own guard,
|
|
# the apply-guard would still return False.
|
|
dialog.apply_settings = Mock(side_effect=dialog_class.apply_settings.__get__(dialog))
|
|
|
|
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
|
dialog_class._on_test_clicked(dialog, None)
|
|
|
|
dialog.test_output_revealer.set_reveal_child.assert_called_with(True)
|
|
message = dialog.test_buffer.set_text.call_args[0][0]
|
|
assert "still applying" in message.lower()
|
|
dialog.apply_settings.assert_not_called()
|
|
dialog.get_selected_settings.assert_not_called()
|
|
dialog._apply_settings_internal.assert_not_called()
|
|
dialog.speech_engine.start_recognition.assert_not_called()
|
|
thread_cls.assert_not_called()
|
|
assert dialog._test_active is False
|
|
|
|
|
|
def test_download_path_resyncs_when_the_apply_reports_failure(settings_dialog, dialog_class):
|
|
"""A False return from the apply must not be reported as a finished switch.
|
|
|
|
This is the #692 path the earlier tests never reached: the model is not on
|
|
disk, so the modal opens and the work happens on the download thread.
|
|
_apply_settings_internal returns False there instead of raising, which used
|
|
to fall straight through to set_complete(True, "").
|
|
"""
|
|
dialog = _dialog_stub()
|
|
dialog.get_selected_settings.return_value = {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "auto",
|
|
}
|
|
dialog._apply_settings_internal.return_value = False
|
|
idle_calls = []
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
|
|
|
modal = modal_class.return_value
|
|
scheduled = [(func, args) for func, args in idle_calls]
|
|
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()
|
|
|
|
|
|
def test_download_path_resyncs_when_the_download_is_cancelled(settings_dialog, dialog_class):
|
|
"""Cancelling the modal leaves the config alone, so the combo must follow."""
|
|
dialog = _dialog_stub()
|
|
dialog.get_selected_settings.return_value = {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "auto",
|
|
}
|
|
dialog._apply_settings_internal.side_effect = RuntimeError("Download cancelled")
|
|
idle_calls = []
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
|
|
|
modal = modal_class.return_value
|
|
assert (dialog._idle_drop_staged_model_spec_and_resync, ()) in idle_calls
|
|
assert (modal.set_complete, (False, "Download cancelled")) in idle_calls
|
|
|
|
|
|
def test_modal_close_resyncs_an_engine_that_never_applied(settings_dialog, dialog_class):
|
|
"""Belt and braces: whatever ended the modal, the combo cannot outlive it."""
|
|
dialog = _dialog_stub()
|
|
dialog.get_selected_settings.return_value = {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "auto",
|
|
}
|
|
dialog._apply_settings_internal.return_value = False
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog"),
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
|
|
|
dialog._drop_staged_model_spec_and_resync.assert_called_once_with()
|
|
|
|
|
|
def test_idle_resync_skips_a_destroyed_dialog(dialog_class):
|
|
"""Worker-thread idle callbacks must not touch widgets after close."""
|
|
dialog = _dialog_stub()
|
|
dialog._dialog_is_alive.return_value = False
|
|
|
|
assert dialog_class._idle_resync_model_ui_from_config(dialog) is False
|
|
|
|
dialog._resync_model_ui_from_config.assert_not_called()
|
|
|
|
|
|
def test_idle_resync_runs_when_the_dialog_is_alive(dialog_class):
|
|
dialog = _dialog_stub()
|
|
dialog._dialog_is_alive.return_value = True
|
|
|
|
assert dialog_class._idle_resync_model_ui_from_config(dialog) is False
|
|
|
|
dialog._resync_model_ui_from_config.assert_called_once_with()
|
|
|
|
|
|
def test_unapplied_engine_is_resynced_when_it_differs_from_the_config(dialog_class):
|
|
"""The check compares what is shown against what was actually saved."""
|
|
dialog = _dialog_stub()
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": {"engine": "vosk"}}
|
|
dialog._get_selected_engine.return_value = "remote_api"
|
|
|
|
dialog_class._resync_engine_ui_if_unapplied(dialog)
|
|
|
|
dialog._resync_model_ui_from_config.assert_called_once()
|
|
|
|
|
|
def test_a_matching_engine_is_left_alone(dialog_class):
|
|
"""No churn when the picker already shows the engine that is configured."""
|
|
dialog = _dialog_stub()
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": {"engine": "vosk"}}
|
|
dialog._get_selected_engine.return_value = "vosk"
|
|
|
|
dialog_class._resync_engine_ui_if_unapplied(dialog)
|
|
|
|
dialog._resync_model_ui_from_config.assert_not_called()
|
|
|
|
|
|
def test_resync_puts_the_engine_picker_back_on_the_saved_engine(dialog_class):
|
|
"""After a failed switch the combo must not keep showing the dead engine."""
|
|
dialog = _dialog_stub()
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": {"engine": "vosk"}}
|
|
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
|
|
|
dialog_class._resync_model_ui_from_config(dialog)
|
|
|
|
dialog.engine_combo.set_active_id.assert_called_once_with("Vosk")
|
|
dialog._populate_model_options.assert_called_once()
|
|
dialog._sync_language_options_for_selected_model.assert_not_called()
|
|
assert dialog._applying_settings is False
|
|
|
|
|
|
def test_resync_restores_language_from_saved_config(dialog_class):
|
|
"""Language combo is restored from the saved config after model options rebuild."""
|
|
dialog = _dialog_stub()
|
|
dialog.config_manager.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "tiny",
|
|
"language": "fr",
|
|
}
|
|
}
|
|
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
|
|
|
dialog_class._resync_model_ui_from_config(dialog)
|
|
|
|
dialog._populate_model_options.assert_called_once()
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with("fr")
|
|
dialog._update_model_info.assert_called_once()
|
|
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()
|
|
dialog._applying_settings = True
|
|
saved_language = dialog.language
|
|
|
|
dialog_class._on_model_changed(dialog, None)
|
|
dialog_class._on_model_variant_changed(dialog, None)
|
|
dialog_class._on_language_changed(dialog, None)
|
|
|
|
dialog._populate_whispercpp_variant_options.assert_not_called()
|
|
dialog._sync_language_options_for_selected_model.assert_not_called()
|
|
dialog._populate_model_options.assert_not_called()
|
|
dialog._auto_apply_settings.assert_not_called()
|
|
assert dialog.language == saved_language
|
|
|
|
|
|
def test_finish_resyncs_whispercpp_size_when_selected_settings_still_report_old_variant(
|
|
settings_dialog, dialog_class
|
|
):
|
|
"""Whisper.cpp size combo can move while get_selected_settings still reports the old id.
|
|
|
|
Handlers early-return while applying, so variant options are not rebuilt.
|
|
Finish must still resync even though selected vs saved look identical.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
_bind_real(
|
|
dialog,
|
|
dialog_class,
|
|
"get_selected_settings",
|
|
"_get_selected_whispercpp_model",
|
|
"_resync_model_ui_from_config",
|
|
)
|
|
saved = _wire_whispercpp_pickers(dialog, size="tiny", variant="tiny", language="auto")
|
|
idle_calls = []
|
|
workers = []
|
|
|
|
class _CaptureThread(_DeferredThread):
|
|
def __init__(
|
|
self,
|
|
target: Callable[..., Any] | None = None,
|
|
daemon: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(target=target, daemon=daemon, **kwargs)
|
|
workers.append(self)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _CaptureThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert dialog._applying_settings is True
|
|
assert len(workers) == 1
|
|
|
|
# Size combo moved to B; production handler early-returns, so the variant
|
|
# combo still names the saved id and selected settings still match config.
|
|
dialog.model_combo.get_active_id.return_value = "small"
|
|
dialog_class._on_model_changed(dialog, None)
|
|
dialog._populate_whispercpp_variant_options.assert_not_called()
|
|
dialog._auto_apply_settings.assert_not_called()
|
|
assert dialog.get_selected_settings()["model_size"] == saved["model_size"]
|
|
|
|
workers[0].target()
|
|
applied = dialog._apply_settings_internal.call_args[0][0]
|
|
assert applied["model_size"] == saved["model_size"]
|
|
assert dialog._applying_settings is True
|
|
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
|
|
dialog._populate_model_options.assert_called_once()
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with("auto")
|
|
assert dialog._applying_settings is False
|
|
|
|
|
|
def test_language_moved_mid_apply_is_restored_from_saved_config(settings_dialog, dialog_class):
|
|
"""A language pick during apply must not stick; finish restores the saved language."""
|
|
dialog = _dialog_stub()
|
|
_bind_real(
|
|
dialog,
|
|
dialog_class,
|
|
"get_selected_settings",
|
|
"_get_selected_whispercpp_model",
|
|
"_resync_model_ui_from_config",
|
|
)
|
|
saved = _wire_whispercpp_pickers(dialog, size="tiny", variant="tiny", language="fr")
|
|
idle_calls = []
|
|
workers = []
|
|
|
|
class _CaptureThread(_DeferredThread):
|
|
def __init__(
|
|
self,
|
|
target: Callable[..., Any] | None = None,
|
|
daemon: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(target=target, daemon=daemon, **kwargs)
|
|
workers.append(self)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
|
|
patch.object(settings_dialog.threading, "Thread", _CaptureThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
assert dialog._applying_settings is True
|
|
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
dialog_class._on_language_changed(dialog, None)
|
|
dialog._populate_model_options.assert_not_called()
|
|
dialog._auto_apply_settings.assert_not_called()
|
|
assert dialog.language == saved["language"]
|
|
|
|
workers[0].target()
|
|
_run_finish_idle(dialog, dialog_class, idle_calls)
|
|
|
|
dialog._populate_model_options.assert_called_once()
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with("fr")
|
|
assert dialog._applying_settings is False
|
|
|
|
|
|
def test_resync_leaves_a_matching_engine_picker_alone(dialog_class):
|
|
"""No combo churn when the displayed engine already matches the config."""
|
|
dialog = _dialog_stub()
|
|
dialog.config_manager.get_settings.return_value = {"speech_recognition": {"engine": "vosk"}}
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
|
|
dialog_class._resync_model_ui_from_config(dialog)
|
|
|
|
dialog.engine_combo.set_active_id.assert_not_called()
|
|
|
|
|
|
def test_changing_the_engine_applies_it(dialog_class):
|
|
"""Selecting an engine must reach the config and the recognizer."""
|
|
dialog = _dialog_stub()
|
|
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
dialog._auto_apply_settings.assert_called_once()
|
|
|
|
|
|
def test_changing_to_remote_api_waits_for_a_server_url(dialog_class):
|
|
"""Applying an unconfigured remote engine would only raise, so defer it."""
|
|
dialog = _dialog_stub()
|
|
dialog.engine_combo.get_active_text.return_value = "Remote API"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
dialog.remote_api_url_entry.get_text.return_value = " "
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
dialog._auto_apply_settings.assert_not_called()
|
|
|
|
|
|
def test_a_resync_repaints_without_applying_or_rewriting_the_language(dialog_class):
|
|
"""The combo move made by a resync must not cascade into another apply."""
|
|
dialog = _dialog_stub()
|
|
dialog._applying_settings = True
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
dialog._auto_apply_settings.assert_not_called()
|
|
dialog._populate_model_options.assert_called_once()
|
|
assert dialog.language == "en-us"
|
|
|
|
|
|
def test_closing_the_dialog_resyncs_an_engine_that_was_never_applied(settings_dialog, dialog_class):
|
|
"""Remote API without a URL defers the apply; closing must not leave it shown."""
|
|
dialog = _dialog_stub()
|
|
gtk = settings_dialog.Gtk
|
|
|
|
dialog_class._on_settings_dialog_response(dialog, dialog, gtk.ResponseType.CLOSE)
|
|
|
|
dialog._resync_engine_ui_if_unapplied.assert_called_once()
|
|
|
|
|
|
def _download_setup(dialog: Mock) -> None:
|
|
"""A model that is not on disk, so the apply goes down the download path."""
|
|
dialog.get_selected_settings.return_value = {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"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"])
|
|
def test_settings_refuses_a_download_while_the_tray_holds_the_engine(
|
|
settings_dialog, dialog_class, entry
|
|
):
|
|
"""Both ways into a download stop at the engine's claim.
|
|
|
|
The tray downloads in the background too; a second download would fight it
|
|
over the one progress callback and the one engine configuration.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
_download_setup(dialog)
|
|
dialog.speech_engine.try_begin_download.return_value = False
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
if entry == "_auto_apply_settings":
|
|
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
|
else:
|
|
dialog_class.apply_settings(dialog)
|
|
|
|
modal_class.assert_not_called()
|
|
dialog._apply_settings_internal.assert_not_called()
|
|
dialog._show_download_busy_dialog.assert_called_once_with()
|
|
dialog._drop_staged_model_spec_and_resync.assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ["_auto_apply_settings", "apply_settings"])
|
|
def test_settings_releases_the_engine_once_the_download_is_over(
|
|
settings_dialog, dialog_class, entry
|
|
):
|
|
dialog = _dialog_stub()
|
|
_download_setup(dialog)
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog"),
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
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()
|
|
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:
|
|
"""#894: a spec that is not on disk must not start downloading on its own.
|
|
|
|
Pickers stage the spec; nothing downloads and nothing is resynced away, so
|
|
the next pick keeps composing instead of fighting a modal. Edits outside
|
|
the spec are not part of it and still apply.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
_download_setup(dialog)
|
|
dialog.get_selected_settings.return_value["vad_sensitivity"] = 2
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
|
|
modal_class.assert_not_called()
|
|
dialog.speech_engine.try_begin_download.assert_not_called()
|
|
# The spec keys are staged, not applied; edits outside it reach the
|
|
# engine live and save — never through reconfigure, which would cancel
|
|
# a buffered recording and reload the model on whispercpp_* keys.
|
|
dialog._apply_settings_internal.assert_not_called()
|
|
dialog.speech_engine.reconfigure.assert_not_called()
|
|
dialog.speech_engine.update_live_settings.assert_called_once_with(vad_sensitivity=2)
|
|
dialog._save_selected_settings.assert_called_once_with({"vad_sensitivity": 2})
|
|
assert dialog._staged_model_spec == {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"model_variant": "",
|
|
"language": "en-us",
|
|
}
|
|
dialog._resync_model_ui_from_config.assert_not_called()
|
|
dialog._resync_engine_ui_if_unapplied.assert_not_called()
|
|
dialog._update_model_info.assert_called_once_with()
|
|
|
|
|
|
def test_staging_several_specs_still_downloads_none(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""Each field of the spec can flip without a single download starting."""
|
|
dialog = _dialog_stub()
|
|
_download_setup(dialog)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
for _flip in range(3):
|
|
dialog_class._auto_apply_settings(dialog)
|
|
|
|
modal_class.assert_not_called()
|
|
dialog.speech_engine.try_begin_download.assert_not_called()
|
|
dialog._apply_settings_internal.assert_not_called()
|
|
|
|
|
|
def test_a_download_allowed_apply_still_downloads(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""The simple questions keep the instant apply-and-download flow."""
|
|
dialog = _dialog_stub()
|
|
_download_setup(dialog)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
|
|
|
dialog.speech_engine.try_begin_download.assert_called_once_with()
|
|
modal_class.assert_called_once()
|
|
|
|
|
|
def test_the_download_button_confirms_the_staged_spec(dialog_class: type[Any]) -> None:
|
|
"""The info card's Download action is the one explicit confirmation."""
|
|
dialog = _dialog_stub()
|
|
|
|
dialog_class._on_download_model_clicked(dialog, None)
|
|
|
|
dialog.apply_settings.assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ["_auto_apply_settings", "apply_settings"])
|
|
def test_a_finished_download_repaints_the_model_info_card(
|
|
settings_dialog: Any, dialog_class: type[Any], entry: str
|
|
) -> None:
|
|
"""A completed download must not leave the card on the pre-download render.
|
|
|
|
Without the repaint, the card keeps the amber size line and, since #894,
|
|
a Download action for the model that was just fetched. The cancel path
|
|
already repaints via ``_idle_resync_model_ui_from_config``; success needs
|
|
its own.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
_download_setup(dialog)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "ModelDownloadDialog"),
|
|
patch.object(settings_dialog, "GLib", MagicMock()),
|
|
patch.object(settings_dialog.threading, "Thread", _InlineThread),
|
|
):
|
|
if entry == "_auto_apply_settings":
|
|
dialog_class._auto_apply_settings(dialog, allow_download=True)
|
|
else:
|
|
dialog_class.apply_settings(dialog)
|
|
|
|
dialog._update_model_info.assert_called_once()
|
|
|
|
|
|
def _update_model_info_stub(dialog_class: type[Any]) -> Mock:
|
|
"""Enough widget state for the real ``_update_model_info`` on whisper.cpp."""
|
|
dialog = _dialog_stub()
|
|
_bind_real(dialog, dialog_class, "_update_model_info")
|
|
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")
|
|
return dialog
|
|
|
|
|
|
def test_the_info_card_offers_download_for_a_missing_model(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""An undownloaded spec shows the confirm action on the info card."""
|
|
dialog = _update_model_info_stub(dialog_class)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
|
|
patch.object(settings_dialog, "detect_compute_backend", return_value=("cpu", {})),
|
|
patch.object(settings_dialog, "get_backend_display_name", return_value="CPU"),
|
|
):
|
|
dialog._update_model_info()
|
|
|
|
dialog.model_download_button.set_visible.assert_called_once_with(True)
|
|
|
|
|
|
def test_the_info_card_offers_no_download_for_a_downloaded_model(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""An already-downloaded spec never prompts (#894 acceptance)."""
|
|
dialog = _update_model_info_stub(dialog_class)
|
|
|
|
with (
|
|
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True),
|
|
patch.object(settings_dialog, "detect_compute_backend", return_value=("cpu", {})),
|
|
patch.object(settings_dialog, "get_backend_display_name", return_value="CPU"),
|
|
):
|
|
dialog._update_model_info()
|
|
|
|
dialog.model_download_button.set_visible.assert_called_once_with(False)
|
|
|
|
|
|
def _dialog_for_engine_ui(engine_text: str):
|
|
"""Stub widgets that `_update_engine_specific_ui` show/hides."""
|
|
dialog = _dialog_stub()
|
|
dialog.engine_combo.get_active_text.return_value = engine_text
|
|
return dialog
|
|
|
|
|
|
class _FakeCombo:
|
|
"""ComboBoxText stand-in that records ids the way `_populate_model_options` writes them."""
|
|
|
|
def __init__(self) -> None:
|
|
self._items: list[tuple[str, str]] = []
|
|
self._active_id: str | None = None
|
|
|
|
def remove_all(self) -> None:
|
|
self._items.clear()
|
|
self._active_id = None
|
|
|
|
def append(self, item_id: str, text: str) -> None:
|
|
self._items.append((item_id, text))
|
|
|
|
def set_active_id(self, item_id: str) -> bool:
|
|
if any(stored_id == item_id for stored_id, _text in self._items):
|
|
self._active_id = item_id
|
|
return True
|
|
return False
|
|
|
|
def set_active(self, index: int) -> None:
|
|
self._active_id = self._items[index][0]
|
|
|
|
def get_active_id(self) -> str | None:
|
|
return self._active_id
|
|
|
|
def get_active_text(self) -> str | None:
|
|
for stored_id, text in self._items:
|
|
if stored_id == self._active_id:
|
|
return text
|
|
return None
|
|
|
|
def get_model(self) -> list[tuple[str, str]]:
|
|
# Gtk.ComboBoxText stores (display text, id); the populate fallback
|
|
# compares row[0] against the id it is trying to restore.
|
|
return [(text, stored_id) for stored_id, text in self._items]
|
|
|
|
@property
|
|
def ids(self) -> list[str]:
|
|
return [item_id for item_id, _text in self._items]
|
|
|
|
def text_for(self, item_id: str) -> str:
|
|
for stored_id, text in self._items:
|
|
if stored_id == item_id:
|
|
return text
|
|
raise KeyError(item_id)
|
|
|
|
|
|
def _dialog_for_selected_settings(engine_text: str, language_id: str, model_id: str = "small"):
|
|
"""Stub combos and spins so `get_selected_settings` can run unbound."""
|
|
dialog = _dialog_stub()
|
|
dialog.engine_combo.get_active_text.return_value = engine_text
|
|
dialog.model_combo.get_active_id.return_value = model_id
|
|
dialog.language_combo.get_active_id.return_value = language_id
|
|
dialog.vad_spin.get_value.return_value = 3
|
|
dialog.silence_spin.get_value.return_value = 2.0
|
|
dialog.advanced_no_timestamps_switch.get_active.return_value = False
|
|
dialog.advanced_no_context_switch.get_active.return_value = False
|
|
prompt = dialog.advanced_initial_prompt_buffer
|
|
prompt.get_text.return_value = ""
|
|
prompt.get_start_iter.return_value = Mock()
|
|
prompt.get_end_iter.return_value = Mock()
|
|
dialog.advanced_temperature_spin.get_value.return_value = 0.0
|
|
dialog.advanced_temperature_inc_spin.get_value.return_value = 0.2
|
|
dialog.advanced_entropy_thold_spin.get_value.return_value = 2.4
|
|
dialog.advanced_logprob_thold_spin.get_value.return_value = -1.0
|
|
dialog.advanced_no_speech_thold_spin.get_value.return_value = 0.6
|
|
dialog.gpu_device_combo.get_active_id.return_value = None
|
|
return dialog
|
|
|
|
|
|
def _faster_whisper_picker_dialog(
|
|
dialog_class: type[Any], *, saved_model: str, language: str = "en-us"
|
|
) -> Mock:
|
|
"""Stub enough widgets to run real `_populate_model_options` for Faster Whisper."""
|
|
dialog = _dialog_for_selected_settings("Faster Whisper", language, model_id=saved_model)
|
|
dialog.language = language
|
|
dialog.config_manager.get_model_size_for_engine.return_value = saved_model
|
|
dialog.model_combo = _FakeCombo()
|
|
dialog.model_variant_combo = _FakeCombo()
|
|
_bind_real(dialog, dialog_class, "_populate_model_options", "get_selected_settings")
|
|
return dialog
|
|
|
|
|
|
def _populate_faster_whisper_picker(
|
|
settings_dialog: Any,
|
|
dialog_class: type[Any],
|
|
*,
|
|
saved_model: str,
|
|
language: str = "en-us",
|
|
recommended: str = "small",
|
|
) -> Mock:
|
|
dialog = _faster_whisper_picker_dialog(dialog_class, saved_model=saved_model, language=language)
|
|
with (
|
|
patch.object(
|
|
settings_dialog,
|
|
"get_recommended_faster_whisper_model",
|
|
return_value=(recommended, "CUDA GPU"),
|
|
),
|
|
patch.object(settings_dialog, "is_faster_whisper_model_downloaded", return_value=False),
|
|
):
|
|
dialog._populate_model_options()
|
|
return dialog
|
|
|
|
|
|
def test_faster_whisper_picker_keeps_saved_english_model(settings_dialog, dialog_class):
|
|
"""A tray-persisted ``small.en`` must stay selected instead of falling back."""
|
|
dialog = _populate_faster_whisper_picker(
|
|
settings_dialog, dialog_class, saved_model="small.en", language="en-us"
|
|
)
|
|
|
|
assert "small.en" in dialog.model_combo.ids
|
|
assert "Small.en" not in dialog.model_combo.ids
|
|
assert dialog.model_combo.get_active_id() == "small.en"
|
|
assert dialog.get_selected_settings()["model_size"] == "small.en"
|
|
|
|
|
|
def test_faster_whisper_english_recommendation_stars_en_variant(settings_dialog, dialog_class):
|
|
"""English language must star ``small.en``, not the bare multilingual row."""
|
|
dialog = _populate_faster_whisper_picker(
|
|
settings_dialog,
|
|
dialog_class,
|
|
saved_model="tiny",
|
|
language="en-us",
|
|
recommended="small",
|
|
)
|
|
|
|
assert " ★" in dialog.model_combo.text_for("small.en")
|
|
assert " ★" not in dialog.model_combo.text_for("small")
|
|
|
|
|
|
def test_faster_whisper_non_english_stars_multilingual_and_keeps_saved_en(
|
|
settings_dialog, dialog_class
|
|
):
|
|
"""German must star ``small``, not ``small.en``, and must not rewrite a saved .en id."""
|
|
dialog = _populate_faster_whisper_picker(
|
|
settings_dialog,
|
|
dialog_class,
|
|
saved_model="small.en",
|
|
language="de",
|
|
recommended="small",
|
|
)
|
|
|
|
assert dialog.model_combo.get_active_id() == "small.en"
|
|
assert " ★" in dialog.model_combo.text_for("small")
|
|
assert " ★" not in dialog.model_combo.text_for("small.en")
|
|
assert dialog.get_selected_settings()["model_size"] == "small.en"
|
|
|
|
|
|
def test_faster_whisper_selected_settings_keep_dotted_id(dialog_class):
|
|
"""Raw combo ids such as ``small.en`` must persist through instant-apply."""
|
|
dialog = _dialog_for_selected_settings("Faster Whisper", "en-us", model_id="small.en")
|
|
|
|
settings = dialog_class.get_selected_settings(dialog)
|
|
|
|
assert settings["engine"] == "faster_whisper"
|
|
assert settings["model_size"] == "small.en"
|
|
|
|
|
|
def test_whisper_picker_still_uses_capitalized_combo_ids(settings_dialog, dialog_class):
|
|
"""Raw Faster Whisper ids must not leak into engines whose combo ids are capitalized."""
|
|
dialog = _dialog_for_selected_settings("Whisper", "en-us", model_id="small")
|
|
dialog.language = "en-us"
|
|
dialog.config_manager.get_model_size_for_engine.return_value = "small"
|
|
dialog.model_combo = _FakeCombo()
|
|
dialog.model_variant_combo = _FakeCombo()
|
|
_bind_real(dialog, dialog_class, "_populate_model_options")
|
|
with (
|
|
patch.object(
|
|
settings_dialog, "_get_recommended_whisper_model", return_value=("small", "reason")
|
|
),
|
|
patch.object(settings_dialog, "_is_whisper_model_downloaded", return_value=False),
|
|
):
|
|
dialog._populate_model_options()
|
|
|
|
assert "Small" in dialog.model_combo.ids
|
|
assert "small" not in dialog.model_combo.ids
|
|
assert dialog.model_combo.get_active_id() == "Small"
|
|
|
|
|
|
def test_parakeet_hides_the_language_picker(dialog_class):
|
|
"""Parakeet language coverage is the model, so the picker must not stay shown."""
|
|
dialog = _dialog_for_engine_ui("Parakeet")
|
|
|
|
dialog_class._update_engine_specific_ui(dialog)
|
|
|
|
dialog.language_row.hide.assert_called()
|
|
dialog.language_row.show_all.assert_not_called()
|
|
dialog.language_warning.hide.assert_called()
|
|
|
|
|
|
def test_non_parakeet_shows_the_language_picker(dialog_class):
|
|
"""Switching away from Parakeet must bring the language row back."""
|
|
dialog = _dialog_for_engine_ui("whisper.cpp")
|
|
|
|
dialog_class._update_engine_specific_ui(dialog)
|
|
|
|
dialog.language_row.show_all.assert_called()
|
|
dialog.language_row.hide.assert_not_called()
|
|
|
|
|
|
def test_parakeet_selected_settings_force_language_auto(dialog_class):
|
|
"""A leftover combo language must not be written as if Parakeet used it."""
|
|
dialog = _dialog_for_selected_settings("Parakeet", "fr", model_id="v3-european")
|
|
|
|
settings = dialog_class.get_selected_settings(dialog)
|
|
|
|
assert settings["engine"] == "parakeet"
|
|
assert settings["language"] == "auto"
|
|
|
|
|
|
def test_whisper_selected_settings_keep_combo_language(dialog_class):
|
|
"""Non-Parakeet engines still persist the language the combo reports."""
|
|
dialog = _dialog_for_selected_settings("Whisper", "fr", model_id="small")
|
|
|
|
settings = dialog_class.get_selected_settings(dialog)
|
|
|
|
assert settings["engine"] == "whisper"
|
|
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()
|
|
dialog.language = "en-us"
|
|
dialog._engine_for_language_memory = "whisper"
|
|
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
|
dialog.language_combo.get_active_id.return_value = "fr"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "fr"
|
|
assert dialog._engine_for_language_memory == "parakeet"
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with("auto")
|
|
|
|
|
|
def test_parakeet_reentry_does_not_clobber_remembered_language(dialog_class):
|
|
"""A second Parakeet changed signal must not replace memory with forced auto."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "auto"
|
|
dialog._last_non_parakeet_language = "fr"
|
|
dialog._engine_for_language_memory = "parakeet"
|
|
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "fr"
|
|
|
|
|
|
def test_leaving_parakeet_restores_remembered_language(dialog_class):
|
|
"""Whisper/cpp language must survive a round-trip through Parakeet."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "auto"
|
|
dialog._last_non_parakeet_language = "fr"
|
|
dialog._engine_for_language_memory = "parakeet"
|
|
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "fr"
|
|
assert dialog._last_non_parakeet_language == "fr"
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with("fr")
|
|
|
|
|
|
def test_leaving_parakeet_to_vosk_maps_unsupported_remembered_language(dialog_class):
|
|
"""Restored auto/non-Vosk languages must fall back to en-us on Vosk."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "auto"
|
|
dialog._last_non_parakeet_language = "auto"
|
|
dialog._engine_for_language_memory = "parakeet"
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "en-us"
|
|
# Coercion must not invent a user preference of en-us over remembered auto.
|
|
assert dialog._last_non_parakeet_language == "auto"
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with("en-us")
|
|
|
|
|
|
def test_whisper_parakeet_vosk_whisper_preserves_unsupported_language(dialog_class):
|
|
"""Vosk en-us fallback must not erase Whisper Greek across the round-trip."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "el"
|
|
dialog._last_non_parakeet_language = "el"
|
|
dialog._engine_for_language_memory = "whisper"
|
|
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
|
dialog.language_combo.get_active_id.return_value = "el"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
assert dialog._engine_for_language_memory == "parakeet"
|
|
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "en-us"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
assert dialog._engine_for_language_memory == "vosk"
|
|
|
|
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
|
# Combo still shows Vosk's coerced en-us; memory must win for Whisper.
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "el"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
dialog._sync_language_options_for_selected_model.assert_called_with("el")
|
|
|
|
|
|
def test_vosk_parakeet_whisper_preserves_unsupported_language(dialog_class):
|
|
"""Entering Parakeet from coerced Vosk must not record en-us as memory."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "el"
|
|
dialog._last_non_parakeet_language = "el"
|
|
dialog._engine_for_language_memory = "whisper"
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
dialog.language_combo.get_active_id.return_value = "el"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "en-us"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
assert dialog._engine_for_language_memory == "vosk"
|
|
|
|
# Combo still shows Vosk's en-us fallback; Parakeet entry must keep Greek.
|
|
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
assert dialog._engine_for_language_memory == "parakeet"
|
|
|
|
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "el"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
dialog._sync_language_options_for_selected_model.assert_called_with("el")
|
|
|
|
|
|
def test_vosk_parakeet_whisper_preserves_auto_language(dialog_class):
|
|
"""Entering Parakeet from coerced Vosk must not record en-us over auto."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "auto"
|
|
dialog._last_non_parakeet_language = "auto"
|
|
dialog._engine_for_language_memory = "whisper"
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "en-us"
|
|
assert dialog._last_non_parakeet_language == "auto"
|
|
assert dialog._engine_for_language_memory == "vosk"
|
|
|
|
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "auto"
|
|
assert dialog._engine_for_language_memory == "parakeet"
|
|
|
|
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "auto"
|
|
dialog._sync_language_options_for_selected_model.assert_called_with("auto")
|
|
|
|
|
|
def test_vosk_whisper_preserves_auto_language(dialog_class):
|
|
"""Leaving Vosk for Whisper must restore auto, not the coerced en-us combo."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "auto"
|
|
dialog._last_non_parakeet_language = "auto"
|
|
dialog._engine_for_language_memory = "whisper"
|
|
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
|
dialog.language_combo.get_active_id.return_value = "auto"
|
|
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "en-us"
|
|
assert dialog._last_non_parakeet_language == "auto"
|
|
|
|
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
dialog_class._on_engine_changed(dialog, None)
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "auto"
|
|
dialog._sync_language_options_for_selected_model.assert_called_with("auto")
|
|
|
|
|
|
def test_vosk_language_sync_does_not_clobber_unsupported_memory(dialog_class):
|
|
"""Vosk combo fallback to en-us must not overwrite a remembered catalog language."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "en-us"
|
|
dialog._last_non_parakeet_language = "el"
|
|
dialog._get_selected_engine.return_value = "vosk"
|
|
dialog.language_combo.get_active_id.return_value = "en-us"
|
|
dialog._set_combo_active_id_or_first.return_value = True
|
|
dialog._default_language_for_engine.return_value = "en-us"
|
|
|
|
dialog_class._sync_language_options_for_selected_model(dialog, "en-us")
|
|
|
|
assert dialog.language == "en-us"
|
|
assert dialog._last_non_parakeet_language == "el"
|
|
|
|
|
|
def test_parakeet_language_sync_forces_auto(dialog_class):
|
|
"""Sync must not keep a preferred or combo language for Parakeet."""
|
|
dialog = _dialog_stub()
|
|
dialog.language = "en-us"
|
|
dialog._get_selected_engine.return_value = "parakeet"
|
|
dialog.language_combo.get_active_id.return_value = "fr"
|
|
dialog._set_combo_active_id_or_first.return_value = True
|
|
dialog._default_language_for_engine.return_value = "auto"
|
|
|
|
dialog_class._sync_language_options_for_selected_model(dialog, "de")
|
|
|
|
assert dialog.language == "auto"
|
|
assert dialog._last_non_parakeet_language == "de"
|
|
dialog._set_combo_active_id_or_first.assert_any_call(dialog.language_combo, "auto")
|
|
|
|
|
|
def test_parakeet_recognition_does_not_consume_language():
|
|
"""Parakeet models do not take a Whisper-style language argument."""
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
init_src = inspect.getsource(SpeechRecognitionManager._init_parakeet)
|
|
transcribe_src = inspect.getsource(SpeechRecognitionManager._transcribe_with_parakeet)
|
|
assert "self.language" not in init_src
|
|
assert "self.language" not in transcribe_src
|
|
|
|
|
|
def test_normalize_language_for_engine_forces_auto_for_parakeet():
|
|
"""CLI/saved non-auto languages must not survive for Parakeet."""
|
|
from vocalinux.speech_recognition.recognition_manager import normalize_language_for_engine
|
|
|
|
assert normalize_language_for_engine("parakeet", "fr") == "auto"
|
|
assert normalize_language_for_engine("parakeet", "en-us") == "auto"
|
|
assert normalize_language_for_engine("parakeet", "auto") == "auto"
|
|
assert normalize_language_for_engine("whisper", "fr") == "fr"
|
|
assert normalize_language_for_engine("vosk", "en-us") == "en-us"
|
|
|
|
|
|
def test_speech_manager_init_normalizes_parakeet_language():
|
|
"""SpeechRecognitionManager must store auto even when constructed with a code."""
|
|
from unittest.mock import patch
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
|
|
manager = SpeechRecognitionManager(
|
|
engine="parakeet",
|
|
model_size="v3-european",
|
|
language="fr",
|
|
defer_download=True,
|
|
)
|
|
assert manager.language == "auto"
|
|
assert manager.command_processor.language == "auto"
|
|
assert "virgule" not in manager.command_processor.text_commands
|
|
|
|
|
|
def test_speech_manager_reconfigure_to_parakeet_normalizes_language():
|
|
"""Switching to Parakeet must clear a leftover catalog language."""
|
|
from unittest.mock import patch
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
with (
|
|
patch.object(SpeechRecognitionManager, "_init_vosk"),
|
|
patch.object(SpeechRecognitionManager, "_init_parakeet"),
|
|
):
|
|
manager = SpeechRecognitionManager(
|
|
engine="vosk",
|
|
model_size="small",
|
|
language="en-us",
|
|
defer_download=True,
|
|
)
|
|
assert manager.language == "en-us"
|
|
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"
|
|
assert "virgule" not in manager.command_processor.text_commands
|
|
|
|
|
|
def test_main_startup_normalizes_parakeet_language():
|
|
"""CLI/startup path must call the shared Parakeet language normalizer."""
|
|
from vocalinux import main as main_mod
|
|
|
|
src = inspect.getsource(main_mod.main)
|
|
assert "normalize_language_for_engine" in src
|
|
|
|
|
|
def test_speech_manager_reconfigure_to_parakeet_clears_leftover_without_language():
|
|
"""Switching TO Parakeet without a language arg must still drop leftover language."""
|
|
from unittest.mock import patch
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
with (
|
|
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
|
|
patch.object(SpeechRecognitionManager, "_init_parakeet"),
|
|
):
|
|
manager = SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="de",
|
|
defer_download=True,
|
|
)
|
|
assert manager.language == "de"
|
|
assert "komma" in manager.command_processor.text_commands
|
|
manager.reconfigure(engine="parakeet", model_size="v3-european", force_download=False)
|
|
assert manager.engine == "parakeet"
|
|
assert manager.language == "auto"
|
|
assert manager.command_processor.language == "auto"
|
|
assert "komma" not in manager.command_processor.text_commands
|
|
|
|
|
|
def test_speech_manager_reconfigure_parakeet_language_arg_stays_auto():
|
|
"""A catalog language passed while already on Parakeet must not stick."""
|
|
from unittest.mock import patch
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
|
|
manager = SpeechRecognitionManager(
|
|
engine="parakeet",
|
|
model_size="v3-european",
|
|
language="auto",
|
|
defer_download=True,
|
|
)
|
|
manager.reconfigure(language="fr", force_download=False)
|
|
assert manager.language == "auto"
|
|
assert manager.command_processor.language == "auto"
|
|
assert "virgule" not in manager.command_processor.text_commands
|
|
|
|
|
|
def test_speech_manager_command_processor_follows_language():
|
|
"""Localized command aliases track the recognition language."""
|
|
from unittest.mock import patch
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
manager = SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="it",
|
|
defer_download=True,
|
|
)
|
|
assert manager.command_processor.language == "it"
|
|
assert "virgola" in manager.command_processor.text_commands
|
|
manager.reconfigure(language="en-us", force_download=False)
|
|
assert manager.command_processor.language == "en-us"
|
|
assert "virgola" not in manager.command_processor.text_commands
|
|
|
|
|
|
def test_deferred_text_edits_persist_once_the_inflight_apply_finishes(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""Edits stashed at close are saved when no newer apply has begun."""
|
|
dialog = _dialog_stub()
|
|
dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"}
|
|
dialog._pending_apply_generation = settings_dialog._apply_settings_generation
|
|
|
|
with patch.object(settings_dialog.threading, "Thread", _InlineThread):
|
|
dialog_class._persist_pending_text_edits(dialog)
|
|
|
|
dialog.speech_engine.reconfigure.assert_called_once_with(whispercpp_language_candidates="en,es")
|
|
dialog.config_manager.set.assert_called_once_with(
|
|
"advanced", "whispercpp_language_candidates", "en,es"
|
|
)
|
|
dialog.config_manager.save_settings.assert_called_once()
|
|
|
|
|
|
def test_deferred_text_edits_dropped_when_a_newer_apply_ran(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""A newer apply owns the engine and config; the stashed snapshot stays stale."""
|
|
dialog = _dialog_stub()
|
|
dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"}
|
|
dialog._pending_text_edit_baseline = {"whispercpp_language_candidates": "en"}
|
|
dialog._pending_apply_generation = settings_dialog._apply_settings_generation
|
|
# A newer apply began after the edits were stashed and rewrote the key.
|
|
settings_dialog._apply_settings_generation += 1
|
|
settings_dialog._apply_settings_written["whispercpp_language_candidates"] = (
|
|
settings_dialog._apply_settings_generation
|
|
)
|
|
dialog.config_manager.get.side_effect = lambda section, key, default=None: "en"
|
|
|
|
try:
|
|
with patch.object(settings_dialog.threading, "Thread", _InlineThread):
|
|
dialog_class._persist_pending_text_edits(dialog)
|
|
finally:
|
|
settings_dialog._apply_settings_written.pop("whispercpp_language_candidates", None)
|
|
|
|
dialog.speech_engine.reconfigure.assert_not_called()
|
|
dialog.config_manager.set.assert_not_called()
|
|
dialog.config_manager.save_settings.assert_not_called()
|
|
|
|
|
|
def test_deferred_text_edits_dropped_when_newer_apply_wrote_baseline_value(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""A newer choice equal to the baseline is still a choice the edit must lose to."""
|
|
dialog = _dialog_stub()
|
|
dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"}
|
|
dialog._pending_text_edit_baseline = {"whispercpp_language_candidates": "en"}
|
|
dialog._pending_apply_generation = settings_dialog._apply_settings_generation
|
|
# The reopened dialog applied the key back to its baseline value. The
|
|
# config is indistinguishable from untouched, yet the write counts as
|
|
# touched — the closed dialog's older edit must not overwrite it.
|
|
settings_dialog._apply_settings_generation += 1
|
|
settings_dialog._apply_settings_written["whispercpp_language_candidates"] = (
|
|
settings_dialog._apply_settings_generation
|
|
)
|
|
dialog.config_manager.get.side_effect = lambda section, key, default=None: "en"
|
|
|
|
try:
|
|
with patch.object(settings_dialog.threading, "Thread", _InlineThread):
|
|
dialog_class._persist_pending_text_edits(dialog)
|
|
finally:
|
|
settings_dialog._apply_settings_written.pop("whispercpp_language_candidates", None)
|
|
|
|
dialog.speech_engine.reconfigure.assert_not_called()
|
|
dialog.config_manager.set.assert_not_called()
|
|
dialog.config_manager.save_settings.assert_not_called()
|
|
|
|
|
|
def test_superseded_apply_skips_the_stale_snapshot(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""A worker whose snapshot a newer apply predates must not write at all."""
|
|
dialog = _dialog_stub()
|
|
dialog.speech_engine.state = RecognitionState.LISTENING
|
|
stale_generation = settings_dialog._apply_settings_generation
|
|
# A newer apply began after this snapshot was collected.
|
|
settings_dialog._apply_settings_generation += 1
|
|
|
|
result = dialog_class._apply_settings_internal(
|
|
dialog,
|
|
{"whispercpp_language_candidates": "en,es"},
|
|
apply_generation=stale_generation,
|
|
)
|
|
|
|
assert result is True
|
|
# Dictation must keep running: the staleness check precedes the stop.
|
|
dialog.speech_engine.stop_recognition.assert_not_called()
|
|
dialog.speech_engine.reconfigure.assert_not_called()
|
|
dialog.config_manager.set.assert_not_called()
|
|
dialog.config_manager.save_settings.assert_not_called()
|
|
|
|
|
|
def test_deferred_text_edits_skipped_when_the_inflight_apply_landed_them(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""No second model restart when the saved values already match the edits."""
|
|
dialog = _dialog_stub()
|
|
dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"}
|
|
dialog._pending_apply_generation = settings_dialog._apply_settings_generation
|
|
dialog.config_manager.get.side_effect = lambda section, key, default=None: "en,es"
|
|
|
|
with patch.object(settings_dialog.threading, "Thread", _InlineThread):
|
|
dialog_class._persist_pending_text_edits(dialog)
|
|
|
|
dialog.speech_engine.reconfigure.assert_not_called()
|
|
dialog.config_manager.set.assert_not_called()
|
|
dialog.config_manager.save_settings.assert_not_called()
|