Files
Tunetti/tests/test_mpris.py
NikkeDoy 4671a6937b
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m52s
| Boost coverage to 60% with 256 passing tests
- mpris.py: 81% → 100% — import fallbacks, flush_props edge cases,
  stop_async error path, run_async exception path, notify loops
- player.py: 51% → 73% — AudioPlayer state/volume/loop/queue tests,
  _on_download_failed, _on_download_succeeded, _on_playback_state,
  _on_media_status, seek/skip/previous, _start_prefetch, _advance,
  _cancel_active_downloads, DownloadWorker._do_cancel
- test_gui_widgets.py: 50 → 88 tests across TestAudioPlayer and
  existing widget tests
- 5 modules at 100%: config, discord_rpc, main, mpris, music_db
2026-06-09 19:33:58 +03:00

457 lines
17 KiB
Python

"""Tests for the MPRIS integration module (mpris.py)."""
from unittest import mock
import pytest
# ── _State ─────────────────────────────────────────────────────────────────────
class TestState:
"""Tests for the _State thread-safe holder."""
def _import(self):
from mpris import _State
return _State
def test_initial_state_is_stopped(self):
cls = self._import()
s = cls()
assert s.get_status() == "Stopped"
assert s.get_metadata() == {}
assert s.get_can_go_prev() is False
def test_update_changes_state(self):
cls = self._import()
s = cls()
s.update("Playing", {"xesam:title": "Test"}, True)
assert s.get_status() == "Playing"
assert s.get_metadata() == {"xesam:title": "Test"}
assert s.get_can_go_prev() is True
def test_get_metadata_returns_copy(self):
cls = self._import()
s = cls()
s.update("Paused", {"key": "val"}, False)
meta = s.get_metadata()
meta["extra"] = "added"
assert "extra" not in s.get_metadata()
# ── _build_mpris_metadata ──────────────────────────────────────────────────────
class TestBuildMprisMetadata:
"""Tests for the _build_mpris_metadata helper."""
def _import(self):
from mpris import _build_mpris_metadata
return _build_mpris_metadata
def test_none_song_returns_empty(self):
fn = self._import()
assert fn(None) == {}
def test_empty_song_returns_empty(self):
fn = self._import()
# Empty dict is falsy, so the function returns {} early
assert fn({}) == {}
def test_full_song_metadata(self):
fn = self._import()
song = {
"videoId": "abc123",
"title": "Test Song",
"artists": [{"name": "Artist One"}, {"name": "Artist Two"}],
"album": "Test Album",
"duration": 240,
"thumbnail": "https://example.com/thumb.jpg",
}
meta = fn(song)
assert meta["xesam:title"].value == "Test Song"
assert meta["xesam:artist"].value == ["Artist One", "Artist Two"]
assert meta["xesam:album"].value == "Test Album"
assert meta["mpris:length"].value == 240_000_000
assert meta["mpris:artUrl"].value == "https://example.com/thumb.jpg"
assert "abc123" in meta["mpris:trackid"].value
def test_album_as_dict_extracts_name(self):
fn = self._import()
song = {
"videoId": "x",
"album": {"name": "Real Album", "id": "album_id"},
}
meta = fn(song)
assert meta["xesam:album"].value == "Real Album"
def test_album_is_none(self):
fn = self._import()
meta = fn({"videoId": "x", "album": None})
assert meta["xesam:album"].value == ""
def test_artists_not_list(self):
fn = self._import()
song = {"videoId": "x", "title": "T", "artists": "not a list"}
meta = fn(song)
assert meta["xesam:artist"].value == []
def test_missing_video_id_does_not_crash(self):
fn = self._import()
song = {"title": "No ID"}
meta = fn(song)
assert isinstance(meta, dict)
assert "mpris:trackid" in meta
def test_zero_duration(self):
fn = self._import()
meta = fn({"videoId": "x", "duration": 0})
assert meta["mpris:length"].value == 0
def test_none_duration(self):
fn = self._import()
meta = fn({"videoId": "x", "duration": None})
assert meta["mpris:length"].value == 0
# ── MprisManager ──────────────────────────────────────────────────────────────
class TestMprisManager:
"""Tests for the MprisManager lifecycle."""
def test_start_returns_false_without_dbus(self):
"""When dbus-fast is unavailable, start() returns False."""
with mock.patch("mpris._HAS_DBUS", False):
from mpris import MprisManager
mgr = MprisManager()
assert mgr.start() is False
def test_bridge_is_none_before_start(self):
from mpris import MprisManager
mgr = MprisManager()
assert mgr.get_bridge() is None
def test_stop_without_start_does_not_crash(self):
from mpris import MprisManager
mgr = MprisManager()
mgr.stop() # should not raise
# ── MprisBridge ────────────────────────────────────────────────────────────────
class TestMprisBridge:
"""Tests for the MprisBridge Qt signal bridge."""
def test_has_required_signals(self):
from mpris import MprisBridge
bridge = MprisBridge()
for name in ("play_requested", "pause_requested", "play_pause_requested",
"stop_requested", "next_requested", "previous_requested",
"seek_requested"):
assert hasattr(bridge, name), f"Missing signal: {name}"
# ── MprisRootInterface ────────────────────────────────────────────────────────
class TestMprisRootInterface:
"""Tests for the root MPRIS interface."""
def test_identity(self):
from mpris import MprisRootInterface
iface = MprisRootInterface()
assert iface.Identity == "Tunetti"
def test_desktop_entry(self):
from mpris import MprisRootInterface
iface = MprisRootInterface()
assert iface.DesktopEntry == "tunetti"
def test_capabilities(self):
from mpris import MprisRootInterface
iface = MprisRootInterface()
assert iface.CanQuit is False
assert iface.CanRaise is False
assert iface.HasTrackList is False
# ── MprisPlayerInterface ──────────────────────────────────────────────────────
class TestMprisPlayerInterface:
"""Tests for the player MPRIS interface."""
def _make(self):
from mpris import _State, MprisBridge, MprisPlayerInterface
state = _State()
bridge = MprisBridge()
iface = MprisPlayerInterface(state, bridge)
return state, bridge, iface
def test_playback_status_default(self):
_, _, iface = self._make()
assert iface.PlaybackStatus == "Stopped"
def test_playback_status_playing(self):
state, _, iface = self._make()
state.update("Playing", {}, False)
assert iface.PlaybackStatus == "Playing"
def test_can_go_next_default(self):
_, _, iface = self._make()
assert iface.CanGoNext is True
def test_can_go_previous_false_when_no_history(self):
_, _, iface = self._make()
assert iface.CanGoPrevious is False
def test_can_go_previous_true_when_history_exists(self):
state, _, iface = self._make()
state.update("Playing", {"t": "1"}, True)
assert iface.CanGoPrevious is True
def test_can_control(self):
_, _, iface = self._make()
assert iface.CanControl is True
def test_methods_emit_via_bridge(self):
_, bridge, iface = self._make()
for method_name, signal_name in [
("Play", "play_requested"),
("Pause", "pause_requested"),
("PlayPause", "play_pause_requested"),
("Stop", "stop_requested"),
("Next", "next_requested"),
("Previous", "previous_requested"),
]:
with mock.patch.object(bridge, signal_name) as mock_sig:
getattr(iface, method_name)()
mock_sig.emit.assert_called_once()
def test_seek_emits_with_offset(self):
_, bridge, iface = self._make()
with mock.patch.object(bridge, "seek_requested") as mock_sig:
iface.Seek(5000)
mock_sig.emit.assert_called_once_with(5000)
# ── Import fallback (when dbus-fast unavailable) ──────────────────────────────
class TestImportFallback:
"""Tests for the mpris module when dbus-fast is unavailable."""
def test_fallback_classes_work(self):
"""When dbus-fast is unavailable, fallback classes work correctly."""
import builtins
import sys
original_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name.startswith("dbus_fast") or name.startswith("dbus-fast"):
raise ImportError(f"No module named {name!r}")
return original_import(name, *args, **kwargs)
# Remove mpris from sys.modules so it re-imports
sys.modules.pop("mpris", None)
with mock.patch("builtins.__import__", side_effect=mock_import):
import mpris as mpris_fallback
# Test fallback Variant
v = mpris_fallback.Variant("s", "hello")
assert v.signature == "s"
assert v.value == "hello"
# Test fallback NameFlag
assert mpris_fallback.NameFlag.REPLACE_EXISTING == 1
assert mpris_fallback.NameFlag.DO_NOT_QUEUE == 2
# Test fallback ServiceInterface
si = mpris_fallback.ServiceInterface("test.Interface")
assert si._interface_name == "test.Interface"
si.emit_properties_changed({"a": 1}, []) # no-op
# Test fallback decorators
@mpris_fallback.dbus_method()
def my_method():
return 42
assert my_method() == 42
@mpris_fallback.dbus_property(access="read")
def my_prop():
return "val"
assert my_prop() == "val"
@mpris_fallback.dbus_signal()
def my_signal():
return "sig"
assert my_signal() == "sig"
# Test PropertyAccess fallback
assert mpris_fallback.PropertyAccess.READ == "read"
# Test that _HAS_DBUS is False
assert mpris_fallback._HAS_DBUS is False
def test_pyside6_import_fallback(self):
"""When PySide6 is unavailable, Signal/QObject fallbacks work."""
import builtins
import sys
original_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "PySide6" or name.startswith("PySide6."):
raise ImportError(f"No module named {name!r}")
return original_import(name, *args, **kwargs)
sys.modules.pop("mpris", None)
with mock.patch("builtins.__import__", side_effect=mock_import):
import mpris as mpris_fallback
assert mpris_fallback._HAS_QT_BRIDGE is False
# MprisBridge should inherit from object, not QObject
assert mpris_fallback.MprisBridge.__bases__ == (object,)
# ── Manager advanced / edge-case tests ───────────────────────────────────────
class TestMprisManagerAdvanced:
"""Tests for manager edge cases (error paths, early returns)."""
def test_flush_props_without_bus(self):
"""_flush_props returns early when bus is None (lines 416-417)."""
from mpris import MprisManager
mgr = MprisManager()
# _bus is None, _flush_props should return without error
mgr._flush_props()
def test_flush_props_emission_raises(self):
"""_flush_props swallows exceptions from emit_properties_changed (lines 433-434)."""
from unittest.mock import MagicMock
from mpris import MprisManager
mgr = MprisManager()
mgr._bus = MagicMock()
mgr._player_iface = MagicMock()
mgr._player_iface.emit_properties_changed.side_effect = RuntimeError("boom")
with mgr._prop_lock:
mgr._prop_queue.append(("Playing", {"xesam:title": "Test"}, False))
mgr._flush_props() # should not raise
# Queue should be cleared
assert len(mgr._prop_queue) == 0
def test_stop_async_without_bus(self):
"""_stop_async does nothing when bus is None."""
from mpris import MprisManager
mgr = MprisManager()
mgr._stop_async() # should not raise
def test_stop_async_disconnect_raises(self):
"""_stop_async catches exceptions from bus.disconnect() (lines 438-442)."""
from unittest.mock import MagicMock
from mpris import MprisManager
mgr = MprisManager()
mock_bus = MagicMock()
mock_bus.disconnect.side_effect = RuntimeError("disconnect failed")
mgr._bus = mock_bus
mgr._stop_async() # should not raise
mock_bus.disconnect.assert_called_once()
def test_properties_changed_signal(self):
"""PropertiesChanged signal method can be called directly (line 290)."""
from mpris import _State, MprisBridge, MprisPlayerInterface
state = _State()
bridge = MprisBridge()
iface = MprisPlayerInterface(state, bridge)
# Call the signal method directly — it's a no-op pass
iface.PropertiesChanged("org.mpris.MediaPlayer2.Player", {}, [])
def test_run_async_exception_path(self):
"""_run_async catches and logs exceptions from _connect_and_serve (lines 449-451)."""
from unittest.mock import patch
from mpris import MprisManager
mgr = MprisManager()
with patch.object(mgr, "_connect_and_serve", side_effect=RuntimeError("dbus error")):
with patch("mpris.log") as mock_log:
mgr._run_async()
mock_log.debug.assert_called_once()
# Finally block should have cleaned up
assert mgr._loop is None
assert mgr._bus is None
assert mgr._root_iface is None
assert mgr._player_iface is None
# ── notify_state_changed edge cases ────────────────────────────────────────
class TestNotifyStateChanged:
"""Tests for notify_state_changed edge cases."""
def test_loop_not_running_queues_only(self):
"""When the asyncio loop isn't running, data is queued but not flushed."""
from mpris import MprisManager
mgr = MprisManager()
mgr._loop = mock.MagicMock()
mgr._loop.is_running.return_value = False
mgr.notify_state_changed("playing", {"videoId": "x"}, True)
assert len(mgr._prop_queue) == 1
mgr._loop.call_soon_threadsafe.assert_not_called()
def test_loop_is_none_queues_only(self):
"""When _loop is None, data is queued but not flushed."""
from mpris import MprisManager
mgr = MprisManager()
mgr._loop = None
mgr.notify_state_changed("playing", {"videoId": "x"}, True)
assert len(mgr._prop_queue) == 1
def test_status_mapping_fallback_to_stopped(self):
"""Unknown status string maps to STATUS_STOPPED."""
from mpris import MprisManager
mgr = MprisManager()
mgr._loop = mock.MagicMock()
mgr._loop.is_running.return_value = True
# Make call_soon_threadsafe actually invoke the callback so _flush_props runs
mgr._loop.call_soon_threadsafe.side_effect = lambda fn: fn()
mgr._bus = mock.MagicMock() # needed for _flush_props
mgr._player_iface = mock.MagicMock()
mgr.notify_state_changed("unknown_status", {"videoId": "x"}, False)
# Should have flushed - the status was mapped to "Stopped"
mgr._player_iface.emit_properties_changed.assert_called()
call_args = mgr._player_iface.emit_properties_changed.call_args
changed = call_args[0][0] if call_args else {}
assert changed.get("PlaybackStatus") == "Stopped"
def test_notify_without_song_queues_state_only(self):
"""notify_state_changed without a song queues PlaybackStatus only."""
from mpris import MprisManager
mgr = MprisManager()
mgr._loop = mock.MagicMock()
mgr._loop.is_running.return_value = True
# Make call_soon_threadsafe actually invoke the callback so _flush_props runs
mgr._loop.call_soon_threadsafe.side_effect = lambda fn: fn()
mgr._bus = mock.MagicMock()
mgr._player_iface = mock.MagicMock()
mgr.notify_state_changed("paused", None, False)
mgr._player_iface.emit_properties_changed.assert_called()
call_args = mgr._player_iface.emit_properties_changed.call_args
changed = call_args[0][0] if call_args else {}
assert "PlaybackStatus" in changed
assert "Metadata" not in changed