Files
voculinux/tests/test_notifications.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
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.
2026-11-06 11:51:44 +05:30

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