Files
voculinux/tests/test_settings_model_state.py
T
6b14338d1e feat(tray): offer the recommended model from the missing-model notification (#687)
* feat(tray): offer the recommended model from the missing-model notification

Dictation without a model on disk showed a dead-end notification telling
the user to open Settings, find the model picker and start the download
by hand, at the moment they wanted to talk. There was no Download button
anywhere: downloads only happen as a side effect of changing a selection.

The notification now carries a "Download <model> (<size>)" button for the
model the picker already stars for this machine, so recommendations stay
in one place: get_recommended_model() for whisper.cpp, including the
English-only variant when an English language is selected, and the RAM
based recommendations for VOSK and Whisper. Clicking it downloads in the
background, reports progress in the notification, and saves the model for
that engine only after it loaded.

Notification servers that do not advertise the actions capability, and
systems without libnotify, still get the plain text they got before.
Dictation is not started for the user afterwards: a download can take
several minutes, and grabbing the microphone unannounced would surprise
someone who has long since moved on.

* feat(tray): actually fetch the offered model, and only claim it when loaded

Review follow-ups on #687.

reconfigure() only re-initializes when engine, model or language change,
and force_download is read during that re-init. First-run defaults are
whisper_cpp + tiny, and tiny is what get_recommended_model() returns on a
CPU-only machine under 16GB, so the click was a no-op that logged
"Applied VAD/silence timeout changes.", saved the model, said "Speech
model ready", and left the next shortcut showing the same missing-model
notification.

Add force_reinit to reconfigure() and pass it here, then gate the save
and the success notification on speech_engine.model_ready instead of on
reconfigure() not raising.

The "starting..." notification is now created in
_download_recommended_model, which runs on the main loop where libnotify
dispatches the action; only the handle travels to the worker, which
already routes update/close/notify through _idle_once.

The offer body no longer promises a button the server cannot draw: with
no action it points at Settings, like the notification it replaced.

Tests cover the case that breaks the feature — the recommendation equal
to the configured model — behaviourally, through a stand-in engine that
gates re-init the way reconfigure() does, plus the real reconfigure()
contract in both directions. They also stop touching sys.modules at
collection time, which is the desync #686 hit, and black is clean.

* test(notifications): cover the libnotify/notify-send boundary directly

Every test that touches notifications so far patches the whole module
away, so the module itself had no coverage: the fallback to notify-send
when the server has no action support or libnotify fails to initialize,
the handle kept alive in _pending until the user answers, and update()
refusing a fallback handle. These drive it with a fake Notify installed
through the module's own lazy cache rather than by patching
gi.repository, which is shared with every other test in the process.

* fix(download): allow only one model download at a time across tray and Settings

The tray's download worker and the Settings dialog's both drive the same
engine, which has a single progress callback slot and a single
configuration. Started together, the later one overwrote the earlier
one's progress callback, the earlier one's finally block then cleared it
mid-download, and whichever finished last became the persisted model.

Give the engine a non-blocking claim. Both Settings entry points and the
tray take it before starting a download and release it when the worker
ends; whoever loses the race is told a download is already running and
the Settings picker goes back to the saved model.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
2026-08-28 23:29:58 -07:00

343 lines
13 KiB
Python

"""Tests for keeping the saved model in step with the engine that runs it."""
import importlib
import sys
from unittest.mock import MagicMock, Mock, patch
import pytest
import vocalinux.ui
from vocalinux.common_types import RecognitionState
@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():
"""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.language = "en-us"
# 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=None, daemon=None, **kwargs):
self._target = target
def start(self):
self._target()
def _glib_stub(idle_calls):
glib = MagicMock()
glib.idle_add.side_effect = lambda func, *args: idle_calls.append((func, args))
return glib
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 = {
"engine": "vosk",
"model_size": "small",
"language": "en-us",
}
dialog.speech_engine.reconfigure.side_effect = RuntimeError("boom")
with patch.object(settings_dialog, "_is_vosk_model_downloaded", return_value=True):
dialog_class._auto_apply_settings(dialog)
dialog._save_selected_settings.assert_not_called()
dialog._resync_model_ui_from_config.assert_called_once()
assert dialog._applying_settings 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)
modal = modal_class.return_value
scheduled = [(func, args) for func, args in idle_calls]
assert (dialog._resync_model_ui_from_config, ()) 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)
modal = modal_class.return_value
assert (dialog._resync_model_ui_from_config, ()) 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)
dialog._resync_engine_ui_if_unapplied.assert_called_once()
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()
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):
"""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
@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),
):
getattr(dialog_class, entry)(dialog)
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()
@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),
):
getattr(dialog_class, entry)(dialog)
dialog.speech_engine.try_begin_download.assert_called_once_with()
dialog.speech_engine.end_download.assert_called_once_with()