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
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user