Files
voculinux/tests/test_notifications.py
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

153 lines
5.6 KiB
Python

"""Tests for vocalinux.ui.notifications against a fake libnotify.
Every other test patches the whole ``notifications`` module away, so the
libnotify/notify-send boundary itself was never exercised. These drive the
module directly: ``conftest`` already installs ``gi.repository`` as a
MagicMock, so the fake ``Notify`` is steered through that.
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def notifications():
"""The real module with its lazy state reset, restored afterwards."""
from vocalinux.ui import notifications as module
saved = (module._notify_module, module._notify_initialized, set(module._pending))
module._notify_module = None
module._notify_initialized = False
module._pending.clear()
yield module
module._notify_module, module._notify_initialized = saved[0], saved[1]
module._pending.clear()
module._pending.update(saved[2])
@pytest.fixture
def fake_notify(notifications):
"""A libnotify that advertises action buttons, handed to the module directly.
Installed through the module's own lazy cache rather than by patching
``gi.repository.Notify``: that global is shared with every other test in
the process, and background threads left behind by earlier tests (the
single-instance checks in test_main, for one) keep calling into it. A fake
that only this module can reach is immune to them.
"""
notify = MagicMock()
notify.get_server_caps.return_value = ["body", "actions"]
notifications._notify_module = notify
notifications._notify_initialized = True
return notify
@pytest.fixture
def notify_send(notifications):
with patch.object(notifications.subprocess, "Popen") as popen:
yield popen
def _registered_callback(notification, signal_name):
"""The callback the module connected to ``signal_name`` on the fake."""
for call in notification.connect.call_args_list:
if call.args[0] == signal_name:
return call.args[1]
raise AssertionError(f"nothing connected to {signal_name!r}")
class TestFallbackToNotifySend:
def test_action_without_server_support_falls_back_to_notify_send(
self, notifications, fake_notify, notify_send
):
fake_notify.get_server_caps.return_value = ["body"]
handle = notifications.notify(
"Title", "Body", "dialog-warning", action=("Go", lambda: None)
)
assert handle is None
notify_send.assert_called_once()
argv = notify_send.call_args.args[0]
assert argv[0] == "notify-send"
assert argv[-2:] == ["Title", "Body"]
def test_failed_libnotify_init_falls_back(self, notifications, notify_send):
broken = MagicMock()
broken.is_initted.return_value = False
broken.init.return_value = False
with patch.object(sys.modules["gi.repository"], "Notify", broken):
handle = notifications.notify("Title", "Body")
assert handle is None
assert notifications._notify_module is None
notify_send.assert_called_once()
def test_plain_notification_needs_no_action_support(
self, notifications, fake_notify, notify_send
):
fake_notify.get_server_caps.return_value = ["body"]
handle = notifications.notify("Title", "Body")
assert handle is fake_notify.Notification.new.return_value
notify_send.assert_not_called()
class TestPendingHandles:
def test_action_keeps_the_notification_alive_until_answered(self, notifications, fake_notify):
handle = notifications.notify("Title", "Body", action=("Go", lambda: None))
assert handle in notifications._pending
_registered_callback(handle, "closed")(handle)
assert handle not in notifications._pending
def test_action_callback_runs_and_releases_the_handle(self, notifications, fake_notify):
callback = MagicMock()
handle = notifications.notify("Title", "Body", action=("Go", callback))
on_action = handle.add_action.call_args.args[2]
on_action(handle, "default-action", None)
callback.assert_called_once_with()
assert handle not in notifications._pending
def test_a_failing_action_callback_is_logged_not_raised(self, notifications, fake_notify):
handle = notifications.notify(
"Title", "Body", action=("Go", MagicMock(side_effect=RuntimeError("boom")))
)
on_action = handle.add_action.call_args.args[2]
on_action(handle, "default-action", None) # must not raise
assert handle not in notifications._pending
def test_close_releases_the_handle(self, notifications, fake_notify):
handle = notifications.notify("Title", "Body", action=("Go", lambda: None))
notifications.close(handle)
assert handle not in notifications._pending
handle.close.assert_called_once_with()
class TestUpdate:
def test_update_returns_false_for_fallback_handles(self, notifications):
assert notifications.update(None, "Title", "Body") is False
def test_update_refreshes_a_real_handle_in_place(self, notifications, fake_notify):
# A handle of our own: the fake's auto-created children are not safe to
# count on, since other tests leave calls behind on shared mocks.
handle = MagicMock()
fake_notify.Notification.new.return_value = handle
assert notifications.notify("Title", "Body") is handle
handle.show.assert_called_once_with()
assert notifications.update(handle, "New", "Text", "folder-download") is True
handle.update.assert_called_once_with("New", "Text", "folder-download")
assert handle.show.call_count == 2