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.
153 lines
5.6 KiB
Python
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
|