Compare commits
9 Commits
fb478d6612
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c865637b8e | |||
| 4671a6937b | |||
| 27d12d31cb | |||
| 7a70232239 | |||
| e2d64cd464 | |||
| 37d4a7d47e | |||
| dc3c735655 | |||
| 5ce18506c1 | |||
| 6530911c28 |
@@ -41,7 +41,7 @@ jobs:
|
||||
.venv/bin/coverage xml
|
||||
|
||||
- name: Run SonarQube Scanner
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
uses: sonarsource/sonarqube-scan-action@v6
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST_URL }}
|
||||
|
||||
98
gui.py
98
gui.py
@@ -80,6 +80,7 @@ except (ImportError, OSError):
|
||||
|
||||
from config import SETTINGS, get_setting, save_setting, save_volume
|
||||
from discord_rpc import DiscordRPC
|
||||
from mpris import MprisManager
|
||||
from music_db import MusicDB
|
||||
from player import AudioPlayer, SearchWorker, _best_thumbnail
|
||||
|
||||
@@ -607,16 +608,19 @@ class AudioVisualizer(QWidget):
|
||||
_TICK_MS = 16
|
||||
|
||||
# Spectral gradient colour stops (left → right / bass → treble)
|
||||
SPECTRUM_COLORS = [
|
||||
QColor(139, 92, 246), # 0.0 – Violet (bass)
|
||||
QColor(168, 85, 247), # 0.1 – Purple
|
||||
QColor(236, 72, 153), # 0.25 – Pink
|
||||
QColor(251, 146, 134), # 0.4 – Rose
|
||||
QColor(251, 207, 132), # 0.5 – Amber (centre)
|
||||
QColor(251, 207, 132), # 0.6 – Amber
|
||||
QColor(236, 72, 153), # 0.75 – Pink
|
||||
QColor(168, 85, 247), # 0.9 – Purple
|
||||
QColor(139, 92, 246), # 1.0 – Violet (bass mirror)
|
||||
# Stored as raw (R, G, B) tuples to avoid Qt dependency at class-definition
|
||||
# time — needed so the module can be imported for pure-helper-function tests
|
||||
# without a real PySide6 runtime.
|
||||
_SPECTRUM_COLOR_STOPS = [
|
||||
(139, 92, 246), # 0.0 – Violet (bass)
|
||||
(168, 85, 247), # 0.1 – Purple
|
||||
(236, 72, 153), # 0.25 – Pink
|
||||
(251, 146, 134), # 0.4 – Rose
|
||||
(251, 207, 132), # 0.5 – Amber (centre)
|
||||
(251, 207, 132), # 0.6 – Amber
|
||||
(236, 72, 153), # 0.75 – Pink
|
||||
(168, 85, 247), # 0.9 – Purple
|
||||
(139, 92, 246), # 1.0 – Violet (bass mirror)
|
||||
]
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -887,21 +891,23 @@ class AudioVisualizer(QWidget):
|
||||
|
||||
@staticmethod
|
||||
def _spectrum_color(t: float) -> QColor:
|
||||
cols = AudioVisualizer.SPECTRUM_COLORS
|
||||
"""Interpolate between colour stops and return a ``QColor``."""
|
||||
stops = AudioVisualizer._SPECTRUM_COLOR_STOPS
|
||||
if t <= 0.0:
|
||||
return cols[0]
|
||||
r, g, b = stops[0]
|
||||
return QColor(r, g, b)
|
||||
if t >= 1.0:
|
||||
return cols[-1]
|
||||
t_scaled = t * (len(cols) - 1)
|
||||
r, g, b = stops[-1]
|
||||
return QColor(r, g, b)
|
||||
t_scaled = t * (len(stops) - 1)
|
||||
idx = int(t_scaled)
|
||||
frac = t_scaled - idx
|
||||
c1 = cols[idx]
|
||||
c2 = cols[min(idx + 1, len(cols) - 1)]
|
||||
c1 = stops[idx]
|
||||
c2 = stops[min(idx + 1, len(stops) - 1)]
|
||||
return QColor(
|
||||
int(c1.red() + (c2.red() - c1.red()) * frac),
|
||||
int(c1.green() + (c2.green() - c1.green()) * frac),
|
||||
int(c1.blue() + (c2.blue() - c1.blue()) * frac),
|
||||
int(c1.alpha() + (c2.alpha() - c1.alpha()) * frac),
|
||||
int(c1[0] + (c2[0] - c1[0]) * frac),
|
||||
int(c1[1] + (c2[1] - c1[1]) * frac),
|
||||
int(c1[2] + (c2[2] - c1[2]) * frac),
|
||||
)
|
||||
|
||||
|
||||
@@ -2274,6 +2280,9 @@ class TunettiWindow(QMainWindow):
|
||||
self.db = MusicDB(SETTINGS["db_path"])
|
||||
self.rpc = DiscordRPC(SETTINGS["discord_client_id"])
|
||||
self.player = AudioPlayer(self)
|
||||
self.mpris = MprisManager()
|
||||
self.mpris.start()
|
||||
self._connect_mpris()
|
||||
|
||||
# ── Central widget ──
|
||||
central = QWidget()
|
||||
@@ -2942,6 +2951,54 @@ class TunettiWindow(QMainWindow):
|
||||
except Exception as exc:
|
||||
log.error("_poll_minimized error: %s", exc)
|
||||
|
||||
# ── MPRIS integration ──
|
||||
|
||||
def _connect_mpris(self) -> None:
|
||||
"""Wire MPRIS bridge signals to the player."""
|
||||
bridge = self.mpris.get_bridge()
|
||||
if bridge is None:
|
||||
return
|
||||
|
||||
# MPRIS → player (cross-thread via Qt queued connections)
|
||||
bridge.play_requested.connect(self.player.resume)
|
||||
bridge.pause_requested.connect(self.player.pause)
|
||||
bridge.play_pause_requested.connect(self.player.toggle_pause)
|
||||
bridge.stop_requested.connect(self.player.stop_playback)
|
||||
bridge.next_requested.connect(self.player.skip)
|
||||
bridge.previous_requested.connect(self.player.previous)
|
||||
|
||||
# Player → MPRIS state updates
|
||||
self.player.playback_state_changed.connect(
|
||||
self._on_mpris_state_changed
|
||||
)
|
||||
self.player.song_started.connect(self._on_mpris_song_started)
|
||||
self.player.song_ended.connect(self._on_mpris_song_ended)
|
||||
|
||||
def _on_mpris_state_changed(self, state: str) -> None:
|
||||
"""Forward playback state changes to MPRIS."""
|
||||
song = self.player.get_current()
|
||||
self.mpris.notify_state_changed(
|
||||
state, song, self.player.has_previous()
|
||||
)
|
||||
|
||||
def _on_mpris_song_started(self, song: dict) -> None:
|
||||
"""Forward new song metadata to MPRIS."""
|
||||
self.mpris.notify_state_changed(
|
||||
"playing", song, self.player.has_previous()
|
||||
)
|
||||
|
||||
def _on_mpris_song_ended(self, video_id: str) -> None:
|
||||
"""Forward song end to MPRIS."""
|
||||
song = self.player.get_current()
|
||||
if song:
|
||||
self.mpris.notify_state_changed(
|
||||
"playing", song, self.player.has_previous()
|
||||
)
|
||||
else:
|
||||
self.mpris.notify_state_changed(
|
||||
"stopped", None, False
|
||||
)
|
||||
|
||||
# ── Public accessors (for child dialogs) ──
|
||||
|
||||
def get_visualizer(self) -> AudioVisualizer:
|
||||
@@ -2977,6 +3034,7 @@ class TunettiWindow(QMainWindow):
|
||||
def closeEvent(self, event) -> None:
|
||||
# Flush any pending volume save before closing
|
||||
self._playback.flush_volume()
|
||||
self.mpris.stop()
|
||||
self.player.shutdown()
|
||||
self.rpc.stop()
|
||||
self.db.close()
|
||||
|
||||
480
mpris.py
Normal file
480
mpris.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""MPRIS (Media Player Remote Interfacing Specification) integration.
|
||||
|
||||
Implements the ``org.mpris.MediaPlayer2`` and
|
||||
``org.mpris.MediaPlayer2.Player`` D-Bus interfaces so that desktop
|
||||
environment media keys, applets (e.g. GNOME Quick Settings, KDE
|
||||
Plasma Media Player) and third-party tools like ``playerctl`` can
|
||||
control Tunetti and display now-playing metadata.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
A background thread runs a synchronous ``dbus-fast`` ``BaseMessageBus``.
|
||||
MPRIS method calls (Play, Pause, Next, …) are dispatched to the Qt
|
||||
main thread via a ``MprisBridge`` ``QObject`` with Qt signals — this
|
||||
is thread-safe and avoids locking.
|
||||
|
||||
State updates (playback status, current song) are pushed from the
|
||||
player into a simple thread-safe ``_State`` holder which the MPRIS
|
||||
property getters read without blocking the D‑Bus thread.
|
||||
|
||||
When the ``dbus-fast`` library is unavailable or D‑Bus is not running,
|
||||
the manager silently degrades — no crash, no traceback.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("tunetti.mpris")
|
||||
|
||||
_HAS_DBUS = False
|
||||
try:
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
from dbus_fast.message_bus import BusType
|
||||
from dbus_fast.service import ServiceInterface, dbus_method, dbus_property, dbus_signal
|
||||
from dbus_fast import Variant
|
||||
from dbus_fast import NameFlag
|
||||
from dbus_fast.service import PropertyAccess
|
||||
|
||||
_HAS_DBUS = True
|
||||
except ImportError:
|
||||
MessageBus = None # type: ignore[assignment]
|
||||
BusType = None
|
||||
|
||||
class _FallbackServiceInterface:
|
||||
"""Stand-in for ``ServiceInterface`` when dbus-fast is unavailable.
|
||||
|
||||
Accepts an interface name in ``__init__`` so that subclasses like
|
||||
``MprisRootInterface`` do not crash when calling
|
||||
``super().__init__(name)``.
|
||||
"""
|
||||
def __init__(self, name: str) -> None:
|
||||
self._interface_name = name
|
||||
def emit_properties_changed(self, changed, invalidated) -> None:
|
||||
"""Stub — no-op; real implementation emits D-Bus PropertiesChanged."""
|
||||
|
||||
ServiceInterface = _FallbackServiceInterface # type: ignore[assignment,misc]
|
||||
dbus_method = lambda **kw: lambda f: f
|
||||
dbus_property = lambda **kw: lambda f: f
|
||||
dbus_signal = lambda **kw: lambda f: f
|
||||
|
||||
class _FallbackVariant:
|
||||
"""Stand-in for ``Variant`` when dbus-fast is unavailable.
|
||||
|
||||
Stores the signature and value so ``_build_mpris_metadata``
|
||||
can still build metadata dicts without a real D-Bus library.
|
||||
"""
|
||||
def __init__(self, signature: str, value):
|
||||
self.signature = signature
|
||||
self.value = value
|
||||
|
||||
Variant = _FallbackVariant
|
||||
|
||||
class _FallbackNameFlag:
|
||||
REPLACE_EXISTING = 1
|
||||
DO_NOT_QUEUE = 2
|
||||
|
||||
NameFlag = _FallbackNameFlag
|
||||
|
||||
PropertyAccess = type("PropertyAccess", (), {"READ": "read"})
|
||||
|
||||
# ── D-Bus identifiers ────────────────────────────────────────────────────────
|
||||
BUS_NAME = "org.mpris.MediaPlayer2.tunetti"
|
||||
OBJECT_PATH = "/org/mpris/MediaPlayer2"
|
||||
|
||||
# Playback status constants
|
||||
STATUS_PLAYING = "Playing"
|
||||
STATUS_PAUSED = "Paused"
|
||||
STATUS_STOPPED = "Stopped"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Thread-safe state holder
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class _State:
|
||||
"""Thread-safe holder for the MPRIS-visible player state.
|
||||
|
||||
Updated from the Qt main thread and read from the D‑Bus thread.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.status: str = STATUS_STOPPED
|
||||
self.metadata: dict = {}
|
||||
self.can_go_prev: bool = False
|
||||
|
||||
def update(self, status: str, metadata: dict, can_go_prev: bool) -> None:
|
||||
with self._lock:
|
||||
self.status = status
|
||||
self.metadata = metadata
|
||||
self.can_go_prev = can_go_prev
|
||||
|
||||
def get_status(self) -> str:
|
||||
with self._lock:
|
||||
return self.status
|
||||
|
||||
def get_metadata(self) -> dict:
|
||||
with self._lock:
|
||||
return dict(self.metadata)
|
||||
|
||||
def get_can_go_prev(self) -> bool:
|
||||
with self._lock:
|
||||
return self.can_go_prev
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# MPRIS D-Bus interfaces
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _build_mpris_metadata(song: Optional[dict]) -> dict:
|
||||
"""Build an ``a{sv}`` metadata dict from a Tunetti song dict."""
|
||||
if not song:
|
||||
return {}
|
||||
artists = song.get("artists") or []
|
||||
artist_names = [
|
||||
a.get("name", "") for a in artists if isinstance(a, dict) and a.get("name")
|
||||
]
|
||||
video_id = song.get("videoId") or song.get("video_id", "") or "unknown"
|
||||
duration_us = (song.get("duration", 0) or 0) * 1_000_000 # s → µs
|
||||
|
||||
# album may be a dict {name, id} or None — extract the name string.
|
||||
raw_album = song.get("album")
|
||||
album_name = (
|
||||
raw_album.get("name", "")
|
||||
if isinstance(raw_album, dict)
|
||||
else (raw_album or "")
|
||||
)
|
||||
|
||||
return {
|
||||
"mpris:trackid": Variant("o", f"/org/tunetti/track/{video_id}"),
|
||||
"mpris:length": Variant("x", max(duration_us, 0)),
|
||||
"mpris:artUrl": Variant("s", song.get("thumbnail", "")),
|
||||
"xesam:title": Variant("s", song.get("title", "Unknown")),
|
||||
"xesam:artist": Variant("as", artist_names),
|
||||
"xesam:album": Variant("s", album_name),
|
||||
"xesam:url": Variant(
|
||||
"s", f"https://music.youtube.com/watch?v={video_id}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class MprisRootInterface(ServiceInterface):
|
||||
"""Implements ``org.mpris.MediaPlayer2`` — identity & capabilities."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("org.mpris.MediaPlayer2")
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def Identity(self) -> "s":
|
||||
return "Tunetti"
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def DesktopEntry(self) -> "s":
|
||||
return "tunetti"
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanQuit(self) -> "b":
|
||||
return False
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanRaise(self) -> "b":
|
||||
return False
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def HasTrackList(self) -> "b":
|
||||
return False
|
||||
|
||||
|
||||
class MprisPlayerInterface(ServiceInterface):
|
||||
"""Implements ``org.mpris.MediaPlayer2.Player`` — playback control."""
|
||||
|
||||
def __init__(self, state: _State, bridge: "MprisBridge") -> None:
|
||||
super().__init__("org.mpris.MediaPlayer2.Player")
|
||||
self._state = state
|
||||
self._bridge = bridge
|
||||
|
||||
# ── Properties ──────────────────────────────────────────────────────
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def PlaybackStatus(self) -> "s":
|
||||
return self._state.get_status()
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def Metadata(self) -> "a{sv}":
|
||||
return self._state.get_metadata()
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def Position(self) -> "x":
|
||||
return 0 # approximate — real position tracking adds complexity
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def MinimumRate(self) -> "d":
|
||||
return 1.0
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def MaximumRate(self) -> "d":
|
||||
return 1.0
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def Rate(self) -> "d":
|
||||
return 1.0
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def Volume(self) -> "d":
|
||||
return 1.0
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanGoNext(self) -> "b":
|
||||
return True
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanGoPrevious(self) -> "b":
|
||||
return self._state.get_can_go_prev()
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanPlay(self) -> "b":
|
||||
return True
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanPause(self) -> "b":
|
||||
return True
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanSeek(self) -> "b":
|
||||
return True
|
||||
|
||||
@dbus_property(access=PropertyAccess.READ)
|
||||
def CanControl(self) -> "b":
|
||||
return True
|
||||
|
||||
# ── Methods ─────────────────────────────────────────────────────────
|
||||
|
||||
@dbus_method()
|
||||
def Play(self) -> None:
|
||||
self._bridge.play_requested.emit()
|
||||
|
||||
@dbus_method()
|
||||
def Pause(self) -> None:
|
||||
self._bridge.pause_requested.emit()
|
||||
|
||||
@dbus_method()
|
||||
def PlayPause(self) -> None:
|
||||
self._bridge.play_pause_requested.emit()
|
||||
|
||||
@dbus_method()
|
||||
def Stop(self) -> None:
|
||||
self._bridge.stop_requested.emit()
|
||||
|
||||
@dbus_method()
|
||||
def Next(self) -> None:
|
||||
self._bridge.next_requested.emit()
|
||||
|
||||
@dbus_method()
|
||||
def Previous(self) -> None:
|
||||
self._bridge.previous_requested.emit()
|
||||
|
||||
@dbus_method()
|
||||
def Seek(self, offset: "x") -> None:
|
||||
self._bridge.seek_requested.emit(offset)
|
||||
|
||||
# ── Signal ──────────────────────────────────────────────────────────
|
||||
|
||||
@dbus_signal()
|
||||
def PropertiesChanged(
|
||||
self, interface: "s", changed: "a{sv}", invalidated: "ao"
|
||||
) -> None:
|
||||
pass # emitted by dbus-fast when @dbus_property values change
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Qt bridge — cross-thread command dispatch
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
_HAS_QT_BRIDGE = False
|
||||
try:
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
_HAS_QT_BRIDGE = True
|
||||
except ImportError:
|
||||
QObject = object
|
||||
Signal = lambda *a: lambda f: f
|
||||
|
||||
|
||||
class MprisBridge(QObject):
|
||||
"""Thread‑safe Qt signal bridge for MPRIS commands.
|
||||
|
||||
Created on the Qt main thread. Signals are emitted from the D‑Bus
|
||||
thread and delivered to the main thread via Qt's queued connections.
|
||||
"""
|
||||
|
||||
play_requested = Signal()
|
||||
pause_requested = Signal()
|
||||
play_pause_requested = Signal()
|
||||
stop_requested = Signal()
|
||||
next_requested = Signal()
|
||||
previous_requested = Signal()
|
||||
seek_requested = Signal(int)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Manager — lifecycle & integration
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class MprisManager:
|
||||
"""Top-level MPRIS manager.
|
||||
|
||||
Usage::
|
||||
|
||||
manager = MprisManager()
|
||||
manager.start() # starts D‑Bus thread
|
||||
...
|
||||
manager.notify_state_changed(...) # push updates from player
|
||||
...
|
||||
manager.stop() # shut down
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state = _State()
|
||||
self._bridge: Optional[MprisBridge] = None
|
||||
self._bus: Optional[MessageBus] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._prop_queue: list[tuple[str, Optional[dict], bool]] = []
|
||||
self._prop_lock = threading.Lock()
|
||||
|
||||
# ── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the MPRIS D‑Bus service.
|
||||
|
||||
Returns ``True`` if the service was started successfully,
|
||||
``False`` if D‑Bus is unavailable or an error occurred.
|
||||
"""
|
||||
if not _HAS_DBUS:
|
||||
log.info("MPRIS unavailable — dbus-fast not installed")
|
||||
return False
|
||||
|
||||
self._bridge = MprisBridge()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_async,
|
||||
daemon=True,
|
||||
name="mpris",
|
||||
)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the D‑Bus thread to shut down and wait for it."""
|
||||
if self._loop is not None and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._stop_async)
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._thread.join(timeout=3)
|
||||
|
||||
def get_bridge(self) -> Optional[MprisBridge]:
|
||||
"""Return the ``MprisBridge`` for connecting player signals."""
|
||||
return self._bridge
|
||||
|
||||
# ── State updates (called from Qt main thread) ──────────────────────
|
||||
|
||||
def notify_state_changed(
|
||||
self, status: str, song: Optional[dict], can_go_prev: bool = False
|
||||
) -> None:
|
||||
"""Update the MPRIS-visible state and emit D‑Bus property changes.
|
||||
|
||||
*status* is one of ``"playing"``, ``"paused"``, ``"stopped"``.
|
||||
This is thread-safe: the actual D‑Bus signal emission happens on
|
||||
the D‑Bus thread via the asyncio event loop.
|
||||
"""
|
||||
mpris_status = {
|
||||
"playing": STATUS_PLAYING,
|
||||
"paused": STATUS_PAUSED,
|
||||
"stopped": STATUS_STOPPED,
|
||||
}.get(status, STATUS_STOPPED)
|
||||
|
||||
metadata = _build_mpris_metadata(song) if song else {}
|
||||
can_prev = can_go_prev
|
||||
|
||||
# Update the thread-safe state immediately (MPRIS property getters
|
||||
# on the D‑Bus thread will see the new values).
|
||||
self._state.update(mpris_status, metadata, can_prev)
|
||||
|
||||
# Queue the property-change notification for the D‑Bus thread.
|
||||
with self._prop_lock:
|
||||
self._prop_queue.append((mpris_status, metadata, can_prev))
|
||||
if self._loop is not None and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._flush_props)
|
||||
|
||||
# ── Internal ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_props(self) -> None:
|
||||
"""Flush queued property-change notifications (runs on D‑Bus thread)."""
|
||||
if self._bus is None or self._player_iface is None:
|
||||
return
|
||||
with self._prop_lock:
|
||||
items = list(self._prop_queue)
|
||||
self._prop_queue.clear()
|
||||
for mpris_status, metadata, can_prev in items:
|
||||
changed: dict = {}
|
||||
changed["PlaybackStatus"] = mpris_status
|
||||
if metadata:
|
||||
changed["Metadata"] = metadata
|
||||
changed["CanGoPrevious"] = can_prev
|
||||
if changed:
|
||||
try:
|
||||
self._player_iface.emit_properties_changed(
|
||||
changed,
|
||||
[],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _stop_async(self) -> None:
|
||||
"""Disconnect the bus (runs on D‑Bus thread)."""
|
||||
if self._bus is not None:
|
||||
try:
|
||||
self._bus.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_async(self) -> None:
|
||||
"""Run the asyncio event loop — owns the D‑Bus connection."""
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
try:
|
||||
self._loop.run_until_complete(self._connect_and_serve())
|
||||
except Exception as exc:
|
||||
log.debug("MPRIS D‑Bus error: %s", exc)
|
||||
finally:
|
||||
self._loop.close()
|
||||
self._loop = None
|
||||
self._bus = None
|
||||
self._root_iface = None
|
||||
self._player_iface = None
|
||||
log.info("MPRIS service stopped")
|
||||
|
||||
async def _connect_and_serve(self) -> None:
|
||||
"""Connect to D‑Bus, export interfaces, and serve requests."""
|
||||
self._bus = MessageBus(bus_type=BusType.SESSION)
|
||||
await self._bus.connect()
|
||||
|
||||
await self._bus.request_name(
|
||||
BUS_NAME, NameFlag.REPLACE_EXISTING | NameFlag.DO_NOT_QUEUE
|
||||
)
|
||||
|
||||
self._root_iface = MprisRootInterface()
|
||||
self._player_iface = MprisPlayerInterface(self._state, self._bridge)
|
||||
|
||||
self._bus.export(OBJECT_PATH, self._root_iface)
|
||||
self._bus.export(OBJECT_PATH, self._player_iface)
|
||||
|
||||
log.info("MPRIS service '%s' registered on session bus", BUS_NAME)
|
||||
|
||||
# Flush any props that were queued before the bus was ready.
|
||||
self._flush_props()
|
||||
|
||||
await self._bus.wait_for_disconnect()
|
||||
98
player.py
98
player.py
@@ -58,13 +58,103 @@ try:
|
||||
except (ImportError, OSError):
|
||||
_HAS_QT = False
|
||||
QObject = object
|
||||
QThread = type("QThread", (), {"start": lambda s: None,
|
||||
"quit": lambda s: None,
|
||||
"wait": lambda s, t=3000: True})
|
||||
|
||||
class _StubQThread:
|
||||
"""Stub replacement for PySide6.QtCore.QThread."""
|
||||
def start(self) -> None:
|
||||
"""Stub — no-op; real QThread would start the event loop."""
|
||||
def quit(self) -> None:
|
||||
"""Stub — no-op; real QThread would exit the event loop."""
|
||||
def wait(self, msecs: int = 3000) -> bool:
|
||||
"""Stub — returns True immediately without waiting."""
|
||||
return True
|
||||
QThread = _StubQThread
|
||||
|
||||
Signal = lambda *a: lambda f: f
|
||||
Slot = lambda *a: lambda f: f
|
||||
QUrl = None
|
||||
QMediaPlayer = None
|
||||
|
||||
class _StubPlaybackState:
|
||||
"""Stub mirroring QMediaPlayer.PlaybackState enum values."""
|
||||
stopped_state = 0
|
||||
playing_state = 1
|
||||
paused_state = 2
|
||||
# Backward-compatible aliases for code that uses Qt CamelCase names.
|
||||
StoppedState = stopped_state
|
||||
PlayingState = playing_state
|
||||
PausedState = paused_state
|
||||
|
||||
class _StubMediaStatus:
|
||||
"""Stub mirroring QMediaPlayer.MediaStatus enum values."""
|
||||
no_media = 0
|
||||
loading_media = 1
|
||||
loaded_media = 2
|
||||
stalled_media = 3
|
||||
buffering_media = 4
|
||||
buffered_media = 5
|
||||
end_of_media = 6
|
||||
invalid_media = 7
|
||||
# Backward-compatible aliases for code that uses Qt CamelCase names.
|
||||
NoMedia = no_media
|
||||
LoadingMedia = loading_media
|
||||
LoadedMedia = loaded_media
|
||||
StalledMedia = stalled_media
|
||||
BufferingMedia = buffering_media
|
||||
BufferedMedia = buffered_media
|
||||
EndOfMedia = end_of_media
|
||||
InvalidMedia = invalid_media
|
||||
|
||||
class _StubError:
|
||||
"""Stub mirroring QMediaPlayer.Error enum values."""
|
||||
no_error = 0
|
||||
resource_error = 1
|
||||
format_error = 2
|
||||
network_error = 3
|
||||
access_denied_error = 4
|
||||
service_missing_error = 5
|
||||
# Backward-compatible aliases for code that uses Qt CamelCase names.
|
||||
NoError = no_error
|
||||
ResourceError = resource_error
|
||||
FormatError = format_error
|
||||
NetworkError = network_error
|
||||
AccessDeniedError = access_denied_error
|
||||
ServiceMissingError = service_missing_error
|
||||
|
||||
class _StubQMediaPlayer:
|
||||
"""Stub replacement for PySide6.QtMultimedia.QMediaPlayer."""
|
||||
PlaybackState = _StubPlaybackState
|
||||
MediaStatus = _StubMediaStatus
|
||||
Error = _StubError
|
||||
|
||||
def __init__(self):
|
||||
self._playback_state = _StubPlaybackState.stopped_state
|
||||
|
||||
def set_source(self, url) -> None:
|
||||
"""Stub — no-op; real QMediaPlayer would load media from *url*."""
|
||||
def play(self) -> None:
|
||||
"""Stub — no-op; real QMediaPlayer would start playback."""
|
||||
def pause(self) -> None:
|
||||
"""Stub — no-op; real QMediaPlayer would pause playback."""
|
||||
def stop(self) -> None:
|
||||
"""Stub — no-op; real QMediaPlayer would stop playback."""
|
||||
def set_position(self, ms: int) -> None:
|
||||
"""Stub — no-op; real QMediaPlayer would seek to *ms*."""
|
||||
def position(self) -> int:
|
||||
"""Stub — returns 0; real QMediaPlayer returns current position in ms."""
|
||||
return 0
|
||||
def duration(self) -> int:
|
||||
"""Stub — returns 0; real QMediaPlayer returns media duration in ms."""
|
||||
return 0
|
||||
def get_playback_state(self) -> int:
|
||||
"""Stub — returns the current playback state value."""
|
||||
return self._playback_state
|
||||
def get_audio_output(self):
|
||||
"""Stub — returns None; real QMediaPlayer returns its QAudioOutput."""
|
||||
return None
|
||||
def set_audio_output(self, output) -> None:
|
||||
"""Stub — no-op; real QMediaPlayer would set its audio output."""
|
||||
|
||||
QMediaPlayer = _StubQMediaPlayer
|
||||
QAudioOutput = None
|
||||
|
||||
log = logging.getLogger("tunetti.player")
|
||||
|
||||
@@ -2,3 +2,4 @@ yt-dlp>=2024.0.0
|
||||
ytmusicapi>=1.0.0
|
||||
pypresence>=4.0.0
|
||||
PySide6>=6.5.0
|
||||
dbus-fast>=5.0.0
|
||||
|
||||
@@ -176,12 +176,25 @@ def _install_stubs() -> None:
|
||||
|
||||
_QT_AVAILABLE = False
|
||||
try:
|
||||
# Attempt real import — this will fail in headless CI.
|
||||
# Step 1: Try to import PySide6 modules. These can succeed even in
|
||||
# headless CI because no display is needed to import the Python bindings.
|
||||
import PySide6 # noqa: F401
|
||||
import PySide6.QtCore # noqa: F401
|
||||
import PySide6.QtMultimedia # noqa: F401
|
||||
|
||||
# Step 2: Verify that a QApplication can actually be created.
|
||||
# In headless CI without QT_QPA_PLATFORM=offscreen this will raise
|
||||
# a RuntimeError ("Cannot create a QWidget without a QApplication")
|
||||
# or OSError ("Could not connect to display").
|
||||
from PySide6.QtWidgets import QApplication
|
||||
_existing_app = QApplication.instance()
|
||||
if _existing_app is None:
|
||||
_test_app = QApplication([])
|
||||
_test_app.quit()
|
||||
del _test_app
|
||||
|
||||
_QT_AVAILABLE = True
|
||||
except (ImportError, OSError):
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
if not _QT_AVAILABLE:
|
||||
|
||||
@@ -257,6 +257,19 @@ class TestDiscordRPC:
|
||||
assert result is False
|
||||
assert drpc._connected is False
|
||||
|
||||
def test_connect_discord_not_found_direct(self):
|
||||
"""_connect handles DiscordNotFound when called directly (not through thread)."""
|
||||
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||
from discord_rpc import DiscordRPC, DiscordNotFound
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.connect.side_effect = DiscordNotFound()
|
||||
mock_presence_cls.return_value = mock_instance
|
||||
|
||||
drpc = DiscordRPC(client_id="test")
|
||||
result = drpc._connect()
|
||||
assert result is False
|
||||
assert drpc._connected is False
|
||||
|
||||
def test_run_reconnects_on_failure(self):
|
||||
"""_run loop retries connection when _connect fails (covers lines 174-175)."""
|
||||
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||
|
||||
@@ -158,3 +158,68 @@ class TestArtistsFromJson:
|
||||
fn = self._import()
|
||||
j = json.dumps([{"name": "Real"}, 42, "str", {"name": "Also Real"}])
|
||||
assert fn(j) == "Real, Also Real"
|
||||
|
||||
|
||||
# ── _css ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCss:
|
||||
"""Tests for the _css helper."""
|
||||
|
||||
def _import(self):
|
||||
from gui import _css
|
||||
return _css
|
||||
|
||||
def test_joins_multiple_parts(self):
|
||||
fn = self._import()
|
||||
assert fn("color: red;", "background: blue;") == "color: red;background: blue;"
|
||||
|
||||
def test_single_part(self):
|
||||
fn = self._import()
|
||||
assert fn("color: red;") == "color: red;"
|
||||
|
||||
def test_empty_parts(self):
|
||||
fn = self._import()
|
||||
assert fn() == ""
|
||||
|
||||
|
||||
# ── _get_thumbnail_nam ────────────────────────────────────────────────────
|
||||
|
||||
class TestGetThumbnailNam:
|
||||
"""Tests for the _get_thumbnail_nam helper."""
|
||||
|
||||
def _import(self):
|
||||
from gui import _get_thumbnail_nam
|
||||
return _get_thumbnail_nam
|
||||
|
||||
def test_returns_network_access_manager(self):
|
||||
fn = self._import()
|
||||
nam = fn()
|
||||
# Should return some kind of object (QNetworkAccessManager or MagicMock)
|
||||
assert nam is not None
|
||||
|
||||
def test_is_singleton(self):
|
||||
fn = self._import()
|
||||
nam1 = fn()
|
||||
nam2 = fn()
|
||||
assert nam1 is nam2
|
||||
|
||||
|
||||
# ── _cached_thumb_path ────────────────────────────────────────────────────
|
||||
|
||||
class TestCachedThumbPath:
|
||||
"""Tests for the _cached_thumb_path helper."""
|
||||
|
||||
def _import(self):
|
||||
from gui import _cached_thumb_path, THUMB_CACHE_DIR
|
||||
return _cached_thumb_path, THUMB_CACHE_DIR
|
||||
|
||||
def test_uses_cache_dir(self):
|
||||
fn, cache_dir = self._import()
|
||||
path = fn("video123")
|
||||
assert str(path) == str(cache_dir / "video123.jpg")
|
||||
|
||||
def test_ends_with_jpg(self):
|
||||
fn, _ = self._import()
|
||||
path = fn("abc_def")
|
||||
assert path.suffix == ".jpg"
|
||||
assert "abc_def" in path.name
|
||||
|
||||
576
tests/test_gui_widgets.py
Normal file
576
tests/test_gui_widgets.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""Integration tests for GUI widget methods (requires real Qt runtime).
|
||||
|
||||
These tests create actual QWidget instances and test the new/modified
|
||||
methods that were added during the recent code quality refactoring.
|
||||
|
||||
When PySide6 cannot connect to a display server (e.g. headless CI)
|
||||
the conftest.py stubs out all Qt types with MagicMock instances — in that
|
||||
environment widget instantiation returns MagicMock objects that do not
|
||||
execute the real ``__init__`` body, so these tests are skipped.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# The conftest stubs PySide6.QtWidgets as a MagicMock when Qt is
|
||||
# unavailable (headless CI). We check whether we have a real Qt runtime
|
||||
# by verifying QApplication is not a MagicMock and that it can be created.
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
def _qt_available() -> bool:
|
||||
"""Return True if a real QApplication can be created (display available)."""
|
||||
if isinstance(QApplication, mock.MagicMock):
|
||||
return False
|
||||
inst = QApplication.instance()
|
||||
if inst is not None:
|
||||
return True
|
||||
try:
|
||||
app = QApplication([])
|
||||
app.quit()
|
||||
return True
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
|
||||
if not _qt_available():
|
||||
pytest.skip("Real Qt runtime not available (stubs active / headless)",
|
||||
allow_module_level=True)
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cleanup(widget: QWidget, app: QApplication) -> None:
|
||||
"""Safely delete a widget and process pending events."""
|
||||
widget.deleteLater()
|
||||
app.processEvents()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Reuse the singleton QApplication."""
|
||||
inst = QApplication.instance()
|
||||
if inst is None:
|
||||
inst = QApplication([])
|
||||
return inst
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_mock():
|
||||
return mock.MagicMock(name="player")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def visualizer_mock():
|
||||
return mock.MagicMock(name="visualizer")
|
||||
|
||||
|
||||
class TestAudioVisualizer:
|
||||
|
||||
def test_reset_activity(self, app):
|
||||
"""reset_activity sets _last_activity to 0."""
|
||||
from gui import AudioVisualizer
|
||||
|
||||
viz = AudioVisualizer()
|
||||
viz._last_activity = 999
|
||||
viz.reset_activity()
|
||||
assert viz._last_activity == 0
|
||||
_cleanup(viz, app)
|
||||
|
||||
|
||||
class TestPlaybackBar:
|
||||
|
||||
def test_clear_seek_flag(self, app, player_mock, visualizer_mock):
|
||||
"""_clear_seek_flag sets _updating_progress to False."""
|
||||
from gui import PlaybackBar
|
||||
|
||||
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
||||
bar._updating_progress = True
|
||||
bar._clear_seek_flag()
|
||||
assert bar._updating_progress is False
|
||||
_cleanup(bar, app)
|
||||
|
||||
def test_flush_volume_stops_timer_and_saves(self, app, player_mock,
|
||||
visualizer_mock):
|
||||
"""flush_volume persists the volume and stops the debounce timer."""
|
||||
from gui import PlaybackBar
|
||||
|
||||
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
||||
bar._saved_volume = 42
|
||||
|
||||
bar._vol_save_timer = QTimer()
|
||||
bar._vol_save_timer.setSingleShot(True)
|
||||
bar._vol_save_timer.start(5000)
|
||||
assert bar._vol_save_timer.isActive()
|
||||
|
||||
with mock.patch("gui.save_volume") as mock_save:
|
||||
bar.flush_volume()
|
||||
mock_save.assert_called_once_with(42)
|
||||
|
||||
assert not bar._vol_save_timer.isActive()
|
||||
_cleanup(bar, app)
|
||||
|
||||
def test_flush_volume_no_timer(self, app, player_mock, visualizer_mock):
|
||||
"""flush_volume does not crash when _vol_save_timer doesn't exist."""
|
||||
from gui import PlaybackBar
|
||||
|
||||
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
||||
if hasattr(bar, "_vol_save_timer"):
|
||||
del bar._vol_save_timer
|
||||
|
||||
bar._saved_volume = 75
|
||||
with mock.patch("gui.save_volume") as mock_save:
|
||||
bar.flush_volume()
|
||||
mock_save.assert_called_once_with(75)
|
||||
_cleanup(bar, app)
|
||||
|
||||
def test_show_error_uses_method_ref(self, app, player_mock,
|
||||
visualizer_mock):
|
||||
"""show_error schedules _clear_error via method ref, not lambda."""
|
||||
from gui import PlaybackBar
|
||||
|
||||
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
||||
with mock.patch.object(bar, "_clear_error") as mock_clear:
|
||||
bar.show_error("Test error")
|
||||
assert "Test error" in bar._np_title.text()
|
||||
mock_clear.assert_not_called()
|
||||
_cleanup(bar, app)
|
||||
|
||||
|
||||
class TestSearchPage:
|
||||
|
||||
def test_on_show_more_clicked_from_button(self, app):
|
||||
"""_on_show_more_clicked expands the correct category."""
|
||||
from gui import SearchPage
|
||||
|
||||
page = SearchPage()
|
||||
page._videos_expanded = False
|
||||
|
||||
btn = QPushButton("Show more")
|
||||
btn.setProperty("category", "videos")
|
||||
btn.clicked.connect(page._on_show_more_clicked)
|
||||
|
||||
with mock.patch.object(page, "_toggle_expand") as mock_toggle:
|
||||
btn.click()
|
||||
mock_toggle.assert_called_once_with("videos")
|
||||
|
||||
_cleanup(btn, app)
|
||||
_cleanup(page, app)
|
||||
|
||||
def test_on_show_more_clicked_songs(self, app):
|
||||
"""_on_show_more_clicked expands songs category."""
|
||||
from gui import SearchPage
|
||||
|
||||
page = SearchPage()
|
||||
page._songs_expanded = False
|
||||
|
||||
btn = QPushButton("Show more")
|
||||
btn.setProperty("category", "songs")
|
||||
btn.clicked.connect(page._on_show_more_clicked)
|
||||
|
||||
with mock.patch.object(page, "_toggle_expand") as mock_toggle:
|
||||
btn.click()
|
||||
mock_toggle.assert_called_once_with("songs")
|
||||
|
||||
_cleanup(btn, app)
|
||||
_cleanup(page, app)
|
||||
|
||||
def test_toggle_expand_videos(self, app):
|
||||
"""_toggle_expand sets videos expanded flag."""
|
||||
from gui import SearchPage
|
||||
|
||||
page = SearchPage()
|
||||
page._videos_expanded = False
|
||||
|
||||
with mock.patch.object(page, "_rebuild"):
|
||||
page._toggle_expand("videos")
|
||||
assert page._videos_expanded is True
|
||||
_cleanup(page, app)
|
||||
|
||||
def test_toggle_expand_songs(self, app):
|
||||
"""_toggle_expand sets songs expanded flag."""
|
||||
from gui import SearchPage
|
||||
|
||||
page = SearchPage()
|
||||
page._songs_expanded = False
|
||||
|
||||
with mock.patch.object(page, "_rebuild"):
|
||||
page._toggle_expand("songs")
|
||||
assert page._songs_expanded is True
|
||||
_cleanup(page, app)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# TunettiWindow accessors
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestAudioPlayer:
|
||||
"""Tests for AudioPlayer state methods (no playback required)."""
|
||||
|
||||
@pytest.fixture
|
||||
def player(self, app):
|
||||
from player import AudioPlayer
|
||||
p = AudioPlayer()
|
||||
yield p
|
||||
p.shutdown()
|
||||
|
||||
def test_initial_state(self, player):
|
||||
"""Initial player state is stopped, no current song, empty queue."""
|
||||
assert player.is_stopped() is True
|
||||
assert player.is_playing() is False
|
||||
assert player.is_paused() is False
|
||||
assert player.get_current() is None
|
||||
assert player.get_queue() == []
|
||||
assert player.get_queue_length() == 0
|
||||
assert player.has_previous() is False
|
||||
|
||||
def test_volume_default(self, player):
|
||||
"""Default volume should be 50."""
|
||||
assert player.volume() == 50
|
||||
|
||||
def test_set_volume(self, player):
|
||||
"""set_volume changes the volume."""
|
||||
player.set_volume(75)
|
||||
assert player.volume() == 75
|
||||
player.set_volume(100)
|
||||
assert player.volume() == 100
|
||||
player.set_volume(0)
|
||||
assert player.volume() == 0
|
||||
|
||||
def test_set_volume_clamps(self, player):
|
||||
"""set_volume clamps values outside 0-100."""
|
||||
player.set_volume(-10)
|
||||
assert player.volume() == 0
|
||||
player.set_volume(150)
|
||||
assert player.volume() == 100
|
||||
|
||||
def test_loop_default(self, player):
|
||||
"""Loop mode is initially off."""
|
||||
assert player.get_loop() is False
|
||||
|
||||
def test_set_loop(self, player):
|
||||
"""set_loop toggles loop mode."""
|
||||
player.set_loop(True)
|
||||
assert player.get_loop() is True
|
||||
player.set_loop(False)
|
||||
assert player.get_loop() is False
|
||||
|
||||
def test_stop_playback_when_stopped(self, player):
|
||||
"""stop_playback when already stopped doesn't crash."""
|
||||
player.stop_playback()
|
||||
assert player.is_stopped() is True
|
||||
|
||||
def test_queue_operations(self, player):
|
||||
"""queue_song, queue_next, and queue_list work correctly.
|
||||
|
||||
``queue_song`` triggers auto-play when queue is empty, which pops
|
||||
the song immediately. We test queue_list (no auto-play) instead.
|
||||
"""
|
||||
s2 = {"videoId": "b", "title": "B"}
|
||||
s3 = {"videoId": "c", "title": "C"}
|
||||
|
||||
# queue_list adds without triggering auto-play
|
||||
player.queue_list([s2, s3])
|
||||
assert player.get_queue_length() == 2
|
||||
assert player.get_queue()[0]["videoId"] == "b"
|
||||
|
||||
# queue_next inserts at position 0
|
||||
s1 = {"videoId": "a", "title": "A"}
|
||||
player.queue_next(s1)
|
||||
assert player.get_queue_length() == 3
|
||||
assert player.get_queue()[0]["videoId"] == "a"
|
||||
|
||||
def test_seek(self, player):
|
||||
"""seek sets position (no crash test)."""
|
||||
player.seek(0) # should not crash
|
||||
player.seek(5000)
|
||||
|
||||
def test_shutdown(self, player):
|
||||
"""shutdown cleans up without error."""
|
||||
player.shutdown() # already called in fixture cleanup
|
||||
|
||||
def test_cleanup_current_temp_no_current(self, player):
|
||||
"""_cleanup_current_temp does nothing when no song is loaded."""
|
||||
player._cleanup_current_temp() # should not crash
|
||||
|
||||
def test_cleanup_temp_files_no_files(self, player):
|
||||
"""_cleanup_temp_files handles missing files gracefully."""
|
||||
player._current = {"_local_path": "/nonexistent/file.mp3"}
|
||||
player._cleanup_temp_files() # should not crash
|
||||
player._current = None
|
||||
|
||||
def test_cleanup_temp_files_with_cache(self, player, tmp_path):
|
||||
"""_cleanup_temp_files removes cached temp files."""
|
||||
f = tmp_path / "test_song.mp3"
|
||||
f.write_text("audio data")
|
||||
player._prefetch_cache["vid1"] = {"_local_path": str(f)}
|
||||
player._cleanup_temp_files()
|
||||
assert not f.exists()
|
||||
|
||||
def test_position_and_duration_signals(self, player):
|
||||
"""_on_position and _on_duration emit signals."""
|
||||
results = []
|
||||
player.position_changed.connect(lambda ms: results.append(("pos", ms)))
|
||||
player.duration_changed.connect(lambda ms: results.append(("dur", ms)))
|
||||
|
||||
player._on_position(5000)
|
||||
player._on_duration(200000)
|
||||
|
||||
assert ("pos", 5000) in results
|
||||
assert ("dur", 200000) in results
|
||||
|
||||
def test_is_loaded(self, player):
|
||||
"""is_loaded returns False when no current song."""
|
||||
assert player.is_loaded() is False
|
||||
player._current = {"videoId": "x"}
|
||||
assert player.is_loaded() is True
|
||||
player._current = None
|
||||
|
||||
def test_toggle_pause_when_stopped(self, player):
|
||||
"""toggle_pause when stopped does nothing."""
|
||||
result = player.toggle_pause()
|
||||
# When stopped, toggle_pause returns None (no state change)
|
||||
assert result is None or result is False
|
||||
|
||||
def test_previous_no_history(self, player):
|
||||
"""previous does nothing when history is empty."""
|
||||
player.previous() # should not crash
|
||||
assert player.has_previous() is False
|
||||
|
||||
def test_cancel_active_downloads(self, player):
|
||||
"""_cancel_active_downloads emits cancel signal when active."""
|
||||
player._active_task_id = 42
|
||||
with mock.patch.object(player._worker, "cancel_requested") as mock_sig:
|
||||
player._cancel_active_downloads()
|
||||
mock_sig.emit.assert_called_once()
|
||||
|
||||
def test_cancel_active_downloads_noop(self, player):
|
||||
"""_cancel_active_downloads does nothing when no active task."""
|
||||
with mock.patch.object(player._worker, "cancel_requested") as mock_sig:
|
||||
player._cancel_active_downloads()
|
||||
mock_sig.emit.assert_not_called()
|
||||
|
||||
def test_on_download_failed_matching_task(self, player):
|
||||
"""_on_download_failed emits error when task matches active."""
|
||||
results = []
|
||||
player.error.connect(lambda d: results.append(d))
|
||||
player._active_task_id = 42
|
||||
player._on_download_failed(42)
|
||||
assert len(results) == 1
|
||||
assert "error" in results[0]
|
||||
assert player._active_task_id is None
|
||||
|
||||
def test_on_download_failed_stale_task(self, player):
|
||||
"""_on_download_failed ignores stale task IDs."""
|
||||
results = []
|
||||
player.error.connect(lambda d: results.append(d))
|
||||
player._active_task_id = 42
|
||||
player._on_download_failed(99) # different task ID
|
||||
assert results == [] # no signal emitted
|
||||
assert player._active_task_id == 42 # unchanged
|
||||
|
||||
def test_skip_no_current(self, player):
|
||||
"""skip does nothing when no current song."""
|
||||
player.skip() # should not crash
|
||||
|
||||
def test_on_download_succeeded_stale(self, player):
|
||||
"""_on_download_succeeded ignores stale task IDs."""
|
||||
player._active_task_id = 42
|
||||
player._on_download_succeeded(99, {"videoId": "x"}) # different task
|
||||
# No crash, no state change
|
||||
|
||||
def test_on_download_succeeded_missing_file(self, player, tmp_path):
|
||||
"""_on_download_succeeded emits error when file is missing."""
|
||||
results = []
|
||||
player.error.connect(lambda d: results.append(d))
|
||||
player._active_task_id = 42
|
||||
player._on_download_succeeded(42, {"videoId": "x",
|
||||
"_local_path": str(tmp_path / "nonexistent.mp3")})
|
||||
assert len(results) == 1
|
||||
assert player._active_task_id is None
|
||||
|
||||
def test_on_playback_state_playing(self, player):
|
||||
"""_on_playback_state with PlayingState emits 'playing'."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.playback_state_changed.connect(lambda s: results.append(s))
|
||||
player._current = {"videoId": "x", "title": "Test"}
|
||||
player._on_playback_state(QMediaPlayer.PlaybackState.PlayingState)
|
||||
assert "playing" in results
|
||||
player._current = None
|
||||
|
||||
def test_on_playback_state_paused(self, player):
|
||||
"""_on_playback_state with PausedState emits 'paused'."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.playback_state_changed.connect(lambda s: results.append(s))
|
||||
player._current = {"videoId": "x"}
|
||||
player._on_playback_state(QMediaPlayer.PlaybackState.PausedState)
|
||||
assert "paused" in results
|
||||
player._current = None
|
||||
|
||||
def test_on_playback_state_stopped(self, player):
|
||||
"""_on_playback_state with StoppedState emits 'stopped'."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.playback_state_changed.connect(lambda s: results.append(s))
|
||||
player._on_playback_state(QMediaPlayer.PlaybackState.StoppedState)
|
||||
assert "stopped" in results
|
||||
|
||||
def test_on_media_status_end_of_media(self, player):
|
||||
"""_on_media_status with EndOfMedia triggers advance."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.song_ended.connect(lambda vid: results.append(vid))
|
||||
player._current = {"videoId": "x"}
|
||||
player._on_media_status(QMediaPlayer.MediaStatus.EndOfMedia)
|
||||
assert "x" in results
|
||||
player._current = None
|
||||
|
||||
def test_on_media_status_invalid_media_stopped(self, player):
|
||||
"""_on_media_status with InvalidMedia does nothing when player is stopped."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.error.connect(lambda d: results.append(d))
|
||||
player._current = {"videoId": "x"}
|
||||
# When the player is stopped (no media loaded), InvalidMedia is ignored
|
||||
player._on_media_status(QMediaPlayer.MediaStatus.InvalidMedia)
|
||||
assert len(results) == 0 # player is stopped, so early return
|
||||
player._current = None
|
||||
|
||||
def test_on_media_status_loaded(self, player):
|
||||
"""_on_media_status with LoadedMedia does nothing special."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
player._on_media_status(QMediaPlayer.MediaStatus.LoadedMedia) # no crash
|
||||
|
||||
def test_get_position(self, player):
|
||||
"""get_position returns current position (0 when stopped)."""
|
||||
assert player.get_position() == 0
|
||||
|
||||
def test_start_prefetch(self, player):
|
||||
"""_start_prefetch with queue emits download request."""
|
||||
from player import DownloadWorker
|
||||
worker = DownloadWorker()
|
||||
player._worker = worker
|
||||
player._queue = [{"videoId": "prefetch_test"}]
|
||||
with mock.patch.object(worker, "download_requested") as mock_sig:
|
||||
player._start_prefetch()
|
||||
mock_sig.emit.assert_called_once()
|
||||
task_id, song = mock_sig.emit.call_args[0]
|
||||
assert isinstance(task_id, int)
|
||||
assert song["videoId"] == "prefetch_test"
|
||||
|
||||
def test_on_download_succeeded_prefetch(self, player, tmp_path):
|
||||
"""_on_download_succeeded caches prefetch result."""
|
||||
temp_file = tmp_path / "test.mp3"
|
||||
temp_file.write_text("audio")
|
||||
player._prefetch_task_id = 55
|
||||
player._queue = [{"videoId": "prefetch_vid"}]
|
||||
player._on_download_succeeded(55, {"videoId": "prefetch_vid",
|
||||
"_local_path": str(temp_file)})
|
||||
assert "prefetch_vid" in player._prefetch_cache
|
||||
assert player._prefetch_task_id is None
|
||||
|
||||
def test_advance_with_loop(self, player):
|
||||
"""_advance re-queues finished song at front in loop mode."""
|
||||
player._loop_mode = True
|
||||
player._current = {"videoId": "loop_song"}
|
||||
player._queue = [{"videoId": "next_song"}]
|
||||
# _advance inserts at front, then _try_next_song pops the front
|
||||
# leaving the next_song. Verify loop_song was inserted before pop.
|
||||
with mock.patch.object(player, "_try_next_song"):
|
||||
player._advance()
|
||||
# The finished song should be at the front before _try_next_song
|
||||
assert player._queue[0]["videoId"] == "loop_song"
|
||||
assert player._current is None
|
||||
|
||||
def test_try_next_song_with_queue(self, player):
|
||||
"""_try_next_song pops and plays when queue has items."""
|
||||
player._queue = [{"videoId": "queued"}]
|
||||
# This will call _pop_and_play which tries to download
|
||||
# Just check it doesn't crash and processes the queue
|
||||
with mock.patch.object(player, "_pop_and_play") as mock_pop:
|
||||
player._try_next_song()
|
||||
mock_pop.assert_called_once()
|
||||
|
||||
def test_on_player_error_network(self, player):
|
||||
"""_on_player_error_occurred with NetworkError triggers advance."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.error.connect(lambda d: results.append(d))
|
||||
player._current = {"videoId": "err_song"}
|
||||
player._on_player_error_occurred(
|
||||
QMediaPlayer.Error.NetworkError, "Connection lost")
|
||||
assert len(results) == 1
|
||||
assert "error" in results[0]
|
||||
player._current = None
|
||||
|
||||
def test_on_player_error_format(self, player):
|
||||
"""_on_player_error_occurred with FormatError does nothing."""
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
results = []
|
||||
player.error.connect(lambda d: results.append(d))
|
||||
player._on_player_error_occurred(
|
||||
QMediaPlayer.Error.FormatError, "Bad format")
|
||||
# FormatError is not NetworkError or ResourceError, so ignored
|
||||
assert len(results) == 0
|
||||
|
||||
def test_seek_emits_seeked_signal(self, player):
|
||||
"""seek emits seeked signal with the position."""
|
||||
results = []
|
||||
player.seeked.connect(lambda ms: results.append(ms))
|
||||
player.seek(15000)
|
||||
assert 15000 in results
|
||||
|
||||
def test_download_worker_do_cancel(self, player):
|
||||
"""DownloadWorker._do_cancel sets cancelled flag."""
|
||||
from player import DownloadWorker
|
||||
worker = DownloadWorker()
|
||||
assert worker._cancelled is False
|
||||
worker._do_cancel()
|
||||
assert worker._cancelled is True
|
||||
|
||||
def test_on_position_zero(self, player):
|
||||
"""_on_position with 0 emits position_changed."""
|
||||
results = []
|
||||
player.position_changed.connect(lambda ms: results.append(ms))
|
||||
player._on_position(0)
|
||||
assert 0 in results
|
||||
|
||||
|
||||
class TestTunettiWindowAccessors:
|
||||
|
||||
@pytest.fixture
|
||||
def window(self, app):
|
||||
"""Create and properly clean up a TunettiWindow."""
|
||||
from gui import TunettiWindow
|
||||
win = TunettiWindow()
|
||||
yield win
|
||||
win.player.shutdown()
|
||||
win.rpc.stop()
|
||||
win.db.close()
|
||||
win.close()
|
||||
_cleanup(win, app)
|
||||
|
||||
def test_get_visualizer(self, window):
|
||||
"""get_visualizer returns the AudioVisualizer instance."""
|
||||
from gui import AudioVisualizer
|
||||
assert isinstance(window.get_visualizer(), AudioVisualizer)
|
||||
|
||||
def test_get_rpc(self, window):
|
||||
"""get_rpc returns the DiscordRPC instance."""
|
||||
from discord_rpc import DiscordRPC
|
||||
assert isinstance(window.get_rpc(), DiscordRPC)
|
||||
|
||||
def test_get_player(self, window):
|
||||
"""get_player returns the AudioPlayer instance."""
|
||||
from player import AudioPlayer
|
||||
assert isinstance(window.get_player(), AudioPlayer)
|
||||
330
tests/test_import_fallbacks.py
Normal file
330
tests/test_import_fallbacks.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Tests for module import fallback paths when Qt is unavailable.
|
||||
|
||||
These tests verify that ``player.py`` and ``gui.py`` gracefully degrade
|
||||
when PySide6 cannot be imported (e.g. headless CI without PulseAudio/EGL).
|
||||
|
||||
The strategy is:
|
||||
1. Remove any existing ``player``/``gui``/``PySide6`` entries from ``sys.modules``.
|
||||
2. Mock ``builtins.__import__`` so that any import starting with ``PySide6``
|
||||
raises ``ImportError``.
|
||||
3. Import the module under test – the ``except (ImportError, OSError):``
|
||||
branch should now execute.
|
||||
4. Verify that the fallback constants/classes are set correctly.
|
||||
5. Restore the original ``sys.modules`` and ``__import__`` so other tests
|
||||
are not affected.
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clean_pyside_modules():
|
||||
"""Remove PySide6 and target modules from sys.modules."""
|
||||
keys_to_del = []
|
||||
for key in sys.modules:
|
||||
if key.startswith("PySide6") or key in ("player", "gui", "discord_rpc"):
|
||||
keys_to_del.append(key)
|
||||
for key in keys_to_del:
|
||||
del sys.modules[key]
|
||||
|
||||
|
||||
def _make_importer(block_pyside: bool = True):
|
||||
"""Return an ``__import__`` mock side-effect that optionally blocks PySide6.
|
||||
|
||||
When *block_pyside* is ``True``, any import whose name starts with
|
||||
``PySide6`` or ``pyside6`` raises ``ImportError``. All other imports
|
||||
are delegated to the real ``__import__``.
|
||||
"""
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if block_pyside and (
|
||||
name.startswith("PySide6") or name.startswith("pyside6")
|
||||
):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
return _side_effect
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# player.py fallback
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Snapshot the original PySide6 modules so the fallback tests can restore them.
|
||||
_ORIGINAL_PYSIDE_MODS = frozenset(
|
||||
k for k in sys.modules if k.startswith("PySide6")
|
||||
)
|
||||
|
||||
|
||||
class TestPlayerQtFallback:
|
||||
"""When PySide6 is unavailable, player defines stubs and sets _HAS_QT=False."""
|
||||
|
||||
def _cleanup(self):
|
||||
"""Remove the stale fallback player module so subsequent tests
|
||||
re-import the real module with real Qt bindings."""
|
||||
sys.modules.pop("player", None)
|
||||
# PySide6 modules may have been removed, restore them by clearing
|
||||
# the cached import so that a fresh import re-imports the real lib.
|
||||
for key in sys.modules:
|
||||
if key.startswith("PySide6") and key not in _ORIGINAL_PYSIDE_MODS:
|
||||
del sys.modules[key]
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_has_qt_is_false_without_pyside6(self):
|
||||
"""_HAS_QT should be False when PySide6 can't be imported."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player # noqa: F811
|
||||
|
||||
assert player._HAS_QT is False
|
||||
|
||||
def test_qobject_fallback_is_object(self):
|
||||
"""QObject fallback should be ``object``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QObject is object
|
||||
|
||||
def test_qmediaplayer_fallback_is_stub_class(self):
|
||||
"""QMediaPlayer fallback should be a stub class (not None)."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QMediaPlayer is not None
|
||||
assert hasattr(player.QMediaPlayer, "PlaybackState")
|
||||
assert hasattr(player.QMediaPlayer, "MediaStatus")
|
||||
assert hasattr(player.QMediaPlayer, "Error")
|
||||
|
||||
def test_qaudiooutput_fallback_is_none(self):
|
||||
"""QAudioOutput fallback should be ``None``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QAudioOutput is None
|
||||
|
||||
def test_qurl_fallback_is_none(self):
|
||||
"""QUrl fallback should be ``None``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QUrl is None
|
||||
|
||||
def test_qthread_fallback_has_start_quit_wait(self):
|
||||
"""QThread fallback stub should have ``start``, ``quit`` and ``wait``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert callable(player.QThread.start)
|
||||
assert callable(player.QThread.quit)
|
||||
assert callable(player.QThread.wait)
|
||||
# Quick functional check
|
||||
qt_cls = player.QThread
|
||||
inst = qt_cls()
|
||||
inst.start() # should not raise
|
||||
inst.quit() # should not raise
|
||||
assert inst.wait() is True
|
||||
|
||||
def test_signal_fallback_is_callable(self):
|
||||
"""Signal fallback should be a callable returning a callable."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert callable(player.Signal)
|
||||
result = player.Signal(int)
|
||||
assert callable(result) # @Signal → decorator → original function
|
||||
|
||||
def test_slot_fallback_is_callable(self):
|
||||
"""Slot fallback should be a callable returning a callable."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert callable(player.Slot)
|
||||
result = player.Slot(int)
|
||||
assert callable(result) # @Slot → decorator → original function
|
||||
|
||||
def test_oserror_in_import_triggers_fallback(self):
|
||||
"""OSError during PySide6 import also triggers the fallback."""
|
||||
_clean_pyside_modules()
|
||||
# This time we mock __import__ to raise OSError instead of ImportError
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _oserror_side(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise OSError("Cannot load native library")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_oserror_side):
|
||||
import player
|
||||
|
||||
assert player._HAS_QT is False
|
||||
assert player.QMediaPlayer is not None
|
||||
assert hasattr(player.QMediaPlayer, "PlaybackState")
|
||||
|
||||
def test_stub_qmediaplayer_methods(self):
|
||||
"""Stub QMediaPlayer methods can be called without error."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
mp = player.QMediaPlayer()
|
||||
# Methods that should be no-ops
|
||||
mp.set_source("https://example.com/audio.mp3")
|
||||
mp.play()
|
||||
mp.pause()
|
||||
mp.stop()
|
||||
mp.set_position(30000)
|
||||
mp.set_audio_output(None)
|
||||
|
||||
# Methods that return values
|
||||
assert mp.position() == 0
|
||||
assert mp.duration() == 0
|
||||
assert mp.get_playback_state() == 0 # stopped_state
|
||||
assert mp.get_audio_output() is None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# gui.py fallback
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Note: Testing gui.py fallback requires also handling its own imports
|
||||
# (config, discord_rpc, music_db, player). Since the whole test suite already
|
||||
# covers those modules with real Qt, the gui.py fallback is implicitly tested
|
||||
# when the conftest.py stubs are active in a headless environment.
|
||||
# The player.py fallback test above covers the same import-fallback pattern
|
||||
# that gui.py uses.
|
||||
|
||||
class TestGuiQtFallback:
|
||||
"""When PySide6 is unavailable, gui sets _HAS_QT=False and uses fallback stubs."""
|
||||
|
||||
def _cleanup(self):
|
||||
"""Remove stale fallback modules so subsequent tests get the real ones."""
|
||||
for mod_name in ("gui", "player", "discord_rpc"):
|
||||
sys.modules.pop(mod_name, None)
|
||||
for key in sys.modules:
|
||||
if key.startswith("PySide6") and key not in _ORIGINAL_PYSIDE_MODS:
|
||||
del sys.modules[key]
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_gui_has_qt_is_false_without_pyside6(self):
|
||||
"""_HAS_QT should be False in gui when PySide6 can't be imported."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui._HAS_QT is False
|
||||
assert gui.QApplication is object
|
||||
assert gui.QWidget is object
|
||||
assert gui.QTimer is object
|
||||
assert gui.QVBoxLayout is object
|
||||
assert gui.QPushButton is object
|
||||
|
||||
def test_gui_qfont_is_none_without_qt(self):
|
||||
"""QFont fallback in gui should be None."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui.QFont is None
|
||||
|
||||
def test_gui_qurl_is_none_without_qt(self):
|
||||
"""QUrl fallback in gui should be None."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui.QUrl is None
|
||||
|
||||
def test_gui_oserror_triggers_fallback(self):
|
||||
"""OSError during PySide6 import triggers gui fallback."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise OSError("Library load error")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui._HAS_QT is False
|
||||
|
||||
def test_gui_spectrum_stops_no_qt_dependency(self):
|
||||
"""AudioVisualizer._SPECTRUM_COLOR_STOPS uses raw tuples, not QColor."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert hasattr(gui.AudioVisualizer, "_SPECTRUM_COLOR_STOPS")
|
||||
stops = gui.AudioVisualizer._SPECTRUM_COLOR_STOPS
|
||||
assert len(stops) == 9
|
||||
# Each stop is an (R, G, B) tuple
|
||||
for stop in stops:
|
||||
assert isinstance(stop, tuple)
|
||||
assert len(stop) == 3
|
||||
assert all(isinstance(v, int) for v in stop)
|
||||
350
tests/test_main.py
Normal file
350
tests/test_main.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Tests for the main entry point (main.py).
|
||||
|
||||
Strategy
|
||||
--------
|
||||
- Module-level constants (``VERBOSE``, ``_LOG_LEVEL``, ``_LOG_FORMAT``,
|
||||
``_QT_LOG_RULES``) are tested by clearing/modifying ``os.environ`` and
|
||||
then calling ``importlib.reload(main)`` so the top-level code re-evaluates.
|
||||
- The ``main()`` function is tested by mocking ``gui.run_gui`` and
|
||||
``main.VERBOSE`` / ``main._QT_LOG_RULES`` so we never need a real Qt runtime.
|
||||
- The ``if __name__ == "__main__":`` block is tested via AST extraction so we
|
||||
can execute only that ``if``-block (not all module-level code) with a mocked
|
||||
``main``.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _reload_main():
|
||||
"""Re-import the ``main`` module with a fresh module object.
|
||||
|
||||
``importlib.reload`` re-executes the module-level code but preserves
|
||||
the existing module object (and its identity in ``sys.modules``).
|
||||
We remove it entirely first so that even the import machinery runs
|
||||
from scratch — every time.
|
||||
|
||||
This avoids stale state leaking across tests (e.g. the ``logging``
|
||||
handler check inside ``basicConfig``, which is a no-op on subsequent
|
||||
calls).
|
||||
"""
|
||||
sys.modules.pop("main", None)
|
||||
import main as m # noqa: F811
|
||||
return m
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for the ``VERBOSE`` / ``TUNETTI_VERBOSE`` env-var
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVerboseEnvVar:
|
||||
"""Module-level ``VERBOSE`` flag is driven by ``TUNETTI_VERBOSE``."""
|
||||
|
||||
# ── absent / empty ────────────────────────────────────────────────────
|
||||
|
||||
def test_verbose_disabled_when_unset(self):
|
||||
"""``TUNETTI_VERBOSE`` absent → ``main.VERBOSE is False``."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is False
|
||||
|
||||
def test_verbose_disabled_when_empty(self):
|
||||
"""``TUNETTI_VERBOSE=""`` → ``main.VERBOSE is False``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": ""}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is False
|
||||
|
||||
# ── truthy ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_verbose_enabled_with_1(self):
|
||||
"""``TUNETTI_VERBOSE=1`` → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
def test_verbose_enabled_with_true(self):
|
||||
"""``TUNETTI_VERBOSE=true`` → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "true"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
def test_verbose_enabled_with_yes(self):
|
||||
"""``TUNETTI_VERBOSE=yes`` → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "yes"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
def test_verbose_strips_whitespace(self):
|
||||
"""``TUNETTI_VERBOSE=" 1 "`` (whitespace) → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": " 1 "}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
# ── falsy ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("val", ["0", "false", "no"])
|
||||
def test_verbose_falsy_values(self, val):
|
||||
"""``TUNETTI_VERBOSE={val}`` → ``main.VERBOSE is False``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": val}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is False, f"expected False for {val!r}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for derived module-level constants
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestModuleConstants:
|
||||
"""Constants derived from ``VERBOSE`` (``_LOG_LEVEL``, ``_LOG_FORMAT``,
|
||||
``_QT_LOG_RULES``)."""
|
||||
|
||||
# ── _LOG_LEVEL ───────────────────────────────────────────────────────
|
||||
|
||||
def test_log_level_info_by_default(self):
|
||||
"""``_LOG_LEVEL`` is ``logging.INFO`` when not verbose."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod._LOG_LEVEL == logging.INFO
|
||||
|
||||
def test_log_level_debug_when_verbose(self):
|
||||
"""``_LOG_LEVEL`` is ``logging.DEBUG`` when verbose."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod._LOG_LEVEL == logging.DEBUG
|
||||
|
||||
# ── _LOG_FORMAT ──────────────────────────────────────────────────────
|
||||
|
||||
def test_log_format_includes_timestamp_in_verbose(self):
|
||||
"""``_LOG_FORMAT`` contains ``%%(asctime)s`` in verbose mode."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert "%(asctime)s" in main_mod._LOG_FORMAT
|
||||
|
||||
def test_log_format_omits_timestamp_in_normal(self):
|
||||
"""``_LOG_FORMAT`` does **not** contain ``%%(asctime)s`` normally."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert "%(asctime)s" not in main_mod._LOG_FORMAT
|
||||
|
||||
def test_log_format_differs_by_verbosity(self):
|
||||
"""The two log-format strings are different (checks they actually vary)."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
normal_mod = _reload_main()
|
||||
normal_fmt = normal_mod._LOG_FORMAT
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
verbose_mod = _reload_main()
|
||||
assert verbose_mod._LOG_FORMAT != normal_fmt
|
||||
|
||||
# ── _QT_LOG_RULES ────────────────────────────────────────────────────
|
||||
|
||||
def test_qt_rules_suppressed_by_default(self):
|
||||
"""``_QT_LOG_RULES`` contains FFmpeg filter rules in normal mode."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert "qt.multimedia.ffmpeg" in main_mod._QT_LOG_RULES
|
||||
|
||||
def test_qt_rules_empty_when_verbose(self):
|
||||
"""``_QT_LOG_RULES`` is an empty string in verbose mode."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod._QT_LOG_RULES == ""
|
||||
|
||||
def test_qt_rules_multiline(self):
|
||||
"""``_QT_LOG_RULES`` spans multiple lines (one rule per line)."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
lines = [ln for ln in main_mod._QT_LOG_RULES.splitlines() if ln.strip()]
|
||||
assert len(lines) >= 4
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for the ``main()`` function
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMainFunction:
|
||||
"""``main()`` function behaviour in normal and verbose modes."""
|
||||
|
||||
# ── Normal mode ─────────────────────────────────────────────────────
|
||||
|
||||
def test_passes_qt_rules_to_run_gui(self):
|
||||
"""``main()`` forwards ``_QT_LOG_RULES`` to ``run_gui`` normally."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", "qt.foo=false\nqt.bar=false"),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(
|
||||
extra_qt_log_rules="qt.foo=false\nqt.bar=false",
|
||||
)
|
||||
|
||||
def test_does_not_touch_player_verbose_in_normal_mode(self):
|
||||
"""Normal mode should **not** set ``player.VERBOSE``."""
|
||||
main_mod = _reload_main()
|
||||
player_mock = mock.MagicMock()
|
||||
player_mock.VERBOSE = False
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch.dict("sys.modules", {"player": player_mock}),
|
||||
):
|
||||
main_mod.main()
|
||||
assert player_mock.VERBOSE is False
|
||||
|
||||
def test_does_not_log_debug_in_normal_mode(self):
|
||||
"""Normal mode should **not** emit a debug log message."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch("logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock.MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
main_mod.main()
|
||||
mock_logger.debug.assert_not_called()
|
||||
|
||||
# ── Verbose mode ────────────────────────────────────────────────────
|
||||
|
||||
def test_passes_empty_rules_in_verbose_mode(self):
|
||||
"""``main()`` passes empty ``_QT_LOG_RULES`` to ``run_gui`` when verbose."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", True),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(extra_qt_log_rules="")
|
||||
|
||||
def test_sets_player_verbose_in_verbose_mode(self):
|
||||
"""Verbose mode sets ``player.VERBOSE = True``."""
|
||||
main_mod = _reload_main()
|
||||
player_mock = mock.MagicMock()
|
||||
player_mock.VERBOSE = False
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", True),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch.dict("sys.modules", {"player": player_mock}),
|
||||
):
|
||||
main_mod.main()
|
||||
assert player_mock.VERBOSE is True
|
||||
|
||||
def test_emits_debug_log_in_verbose_mode(self):
|
||||
"""Verbose mode logs a debug message about verbose mode being active."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", True),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch("logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock.MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
main_mod.main()
|
||||
mock_logger.debug.assert_called_once()
|
||||
(msg,) = mock_logger.debug.call_args[0]
|
||||
assert "Verbose mode enabled" in msg
|
||||
assert "yt-dlp" in msg
|
||||
assert "FFmpeg" in msg
|
||||
|
||||
# ── Edge cases ──────────────────────────────────────────────────────
|
||||
|
||||
def test_handles_empty_qt_rules_gracefully(self):
|
||||
"""``run_gui`` is called even when ``_QT_LOG_RULES`` is empty."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(extra_qt_log_rules="")
|
||||
|
||||
def test_handles_very_large_qt_rules(self):
|
||||
"""``main()`` passes a large multi-line rules string without issues."""
|
||||
main_mod = _reload_main()
|
||||
large_rules = "\n".join(f"qt.category{i}=false" for i in range(50))
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", large_rules),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(extra_qt_log_rules=large_rules)
|
||||
|
||||
def test_run_gui_exception_propagates(self):
|
||||
"""If ``run_gui`` raises, ``main()`` lets the exception propagate."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui", side_effect=RuntimeError("gui failed")),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="gui failed"):
|
||||
main_mod.main()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for the ``if __name__ == "__main__"`` block
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMainBlock:
|
||||
"""``if __name__ == "__main__":`` entry point guard."""
|
||||
|
||||
def test_name_is_main_when_imported(self):
|
||||
"""Module ``__name__`` is ``"main"`` (not ``"__main__"``) on import."""
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.__name__ == "main"
|
||||
|
||||
def test_block_not_executed_on_import(self):
|
||||
"""The ``__main__`` block does **not** execute when imported normally.
|
||||
|
||||
We verify this by patching ``main()`` to raise an exception — if the
|
||||
block ran, the exception would be raised.
|
||||
"""
|
||||
main_mod = _reload_main()
|
||||
with mock.patch.object(
|
||||
main_mod, "main", side_effect=RuntimeError("main() was called!"),
|
||||
):
|
||||
# A plain re-import should not trigger the error because __name__
|
||||
# is "main", not "__main__".
|
||||
importlib.reload(main_mod)
|
||||
|
||||
def test_source_contains_main_guard(self):
|
||||
"""The source code contains the ``if __name__ == "__main__":`` guard.
|
||||
|
||||
This verifies the guard exists without relying on AST compilation
|
||||
(which varies across Python versions). The guard is a Python
|
||||
language feature — we just need to confirm it's present.
|
||||
"""
|
||||
main_mod = _reload_main()
|
||||
source = Path(main_mod.__file__).read_text()
|
||||
assert 'if __name__ == "__main__":' in source, (
|
||||
"Missing ``if __name__ == '__main__':`` guard. "
|
||||
"This guard must be present so ``main()`` is only called "
|
||||
"when the module is executed as a script."
|
||||
)
|
||||
assert "main()" in source.split('if __name__ == "__main__":')[1]
|
||||
|
||||
def test_main_block_executes_when_run_as_script(self):
|
||||
"""When ``__name__ == '__main__'``, the guard calls ``main()``.
|
||||
|
||||
We re-execute the module source with ``__name__`` set to
|
||||
``'__main__'``, mocking ``gui.run_gui`` so the call does
|
||||
nothing. This covers line 62 (the actual ``main()`` call)
|
||||
for the coverage report.
|
||||
"""
|
||||
main_mod = _reload_main()
|
||||
with mock.patch("gui.run_gui") as mock_run_gui:
|
||||
file_path = Path(main_mod.__file__).resolve()
|
||||
code = compile(file_path.read_text(), str(file_path), "exec")
|
||||
ns = {"__name__": "__main__", "__file__": str(file_path)}
|
||||
exec(code, ns)
|
||||
mock_run_gui.assert_called_once()
|
||||
456
tests/test_mpris.py
Normal file
456
tests/test_mpris.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""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
|
||||
@@ -5,6 +5,7 @@ import struct
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -340,3 +341,60 @@ class TestTmpDir:
|
||||
"""Calling _tmp_dir multiple times returns the same path."""
|
||||
fn = self._import()
|
||||
assert fn() == fn()
|
||||
|
||||
|
||||
# ── SearchWorker (TEST_MODE) ─────────────────────────────────────────────────
|
||||
|
||||
class TestSearchWorkerTestMode:
|
||||
"""Tests for SearchWorker.run() with TEST_MODE enabled.
|
||||
|
||||
Uses ``mock.MagicMock`` to capture signal emissions instead of
|
||||
``.connect()`` because the player module's ``Signal`` binding may
|
||||
be a plain function (the import fallback when PySide6 is unavailable)
|
||||
that does not have a ``.connect()`` method.
|
||||
"""
|
||||
|
||||
def test_returns_dummy_results_in_test_mode(self, monkeypatch):
|
||||
"""With TEST_MODE=True, SearchWorker returns 5 dummy songs."""
|
||||
monkeypatch.setattr("player.TEST_MODE", True)
|
||||
from player import SearchWorker
|
||||
|
||||
worker = SearchWorker("test query")
|
||||
# Use a MagicMock to capture signal emissions — works with both
|
||||
# real Signal, stub _Signal, and fallback lambda.
|
||||
mock_signal = mock.MagicMock()
|
||||
worker.results_ready = mock_signal
|
||||
worker.run()
|
||||
|
||||
mock_signal.emit.assert_called_once()
|
||||
data = mock_signal.emit.call_args[0][0]
|
||||
assert "songs" in data
|
||||
assert "videos" in data
|
||||
assert len(data["songs"]) == 5
|
||||
assert data["videos"] == []
|
||||
assert data["songs"][0]["title"] == "Test Song 0"
|
||||
|
||||
def test_dummy_results_have_required_keys(self, monkeypatch):
|
||||
"""Each dummy song dict has all expected keys."""
|
||||
monkeypatch.setattr("player.TEST_MODE", True)
|
||||
from player import SearchWorker
|
||||
|
||||
worker = SearchWorker("test query")
|
||||
mock_signal = mock.MagicMock()
|
||||
worker.results_ready = mock_signal
|
||||
worker.run()
|
||||
|
||||
mock_signal.emit.assert_called_once()
|
||||
data = mock_signal.emit.call_args[0][0]
|
||||
song = data["songs"][0]
|
||||
assert "videoId" in song
|
||||
assert "title" in song
|
||||
assert "artists" in song
|
||||
assert len(song["artists"]) == 1
|
||||
assert song["artists"][0]["name"] == "Artist 0"
|
||||
|
||||
def test_sets_query_correctly(self):
|
||||
"""SearchWorker stores the query."""
|
||||
from player import SearchWorker
|
||||
worker = SearchWorker("my search query")
|
||||
assert worker._query == "my search query"
|
||||
|
||||
Reference in New Issue
Block a user