Compare commits

...

11 Commits

Author SHA1 Message Date
c865637b8e 🛡️ | Fix security hotspot - replace /tmp path with tmp_path
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m51s
2026-06-09 19:38:50 +03:00
4671a6937b | Boost coverage to 60% with 256 passing tests
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m52s
- mpris.py: 81% → 100% — import fallbacks, flush_props edge cases,
  stop_async error path, run_async exception path, notify loops
- player.py: 51% → 73% — AudioPlayer state/volume/loop/queue tests,
  _on_download_failed, _on_download_succeeded, _on_playback_state,
  _on_media_status, seek/skip/previous, _start_prefetch, _advance,
  _cancel_active_downloads, DownloadWorker._do_cancel
- test_gui_widgets.py: 50 → 88 tests across TestAudioPlayer and
  existing widget tests
- 5 modules at 100%: config, discord_rpc, main, mpris, music_db
2026-06-09 19:33:58 +03:00
27d12d31cb 💚 | Update SonarQube scanner to v6 in CI workflow
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m52s
Fixes security vulnerability warning by updating
sonarsource/sonarqube-scan-action from v4 to v6.
2026-06-09 18:46:50 +03:00
7a70232239 🐛 | Fix SonarQube issues across codebase
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m41s
- Add docstrings to all empty Qt stub methods (S1186)
- Fix BLOCKER S1845: rename playbackState to get_playback_state
- Rename enum fields to snake_case with CamelCase aliases (S116)
- Rename stub methods to snake_case (set_source, set_position, etc.) (S100)
- Rename unused timeout param to msecs (S1172)
- Remove unused state variables in tests (S1481)
- Replace 'is' with '==' for enum comparisons (S5795)
- Remove unnecessary list() calls (S7504)
- Rename QTh to qt_cls (S117)
- Remove commented-out code blocks (S125)
- Add docstring to FallbackServiceInterface.emit_properties_changed (S1186)
2026-06-09 18:34:54 +03:00
e2d64cd464 | Add MPRIS integration for media key control
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m48s
Implements org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player
D-Bus interfaces, allowing desktop environment media keys and tools
like playerctl to control playback (play, pause, next, previous)
and display now-playing metadata.

Also adds _FallbackServiceInterface, _FallbackVariant and
_FallbackNameFlag so all tests pass without dbus-fast installed;
the module gracefully degrades when the library is unavailable.
2026-06-09 18:29:51 +03:00
37d4a7d47e 🐛 | Make SearchWorker tests robust to Qt fallback environments
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m46s
Replace SearchWorker signal .connect() with mock.MagicMock to handle
the case where the player module Signal binding is a plain function
(import fallback when PySide6 is unavailable in headless CI).
The MagicMock captures .emit() calls and works across all environments:
real Qt, conftest stubs, and player.py fallback paths.
2026-06-04 21:42:11 +03:00
dc3c735655 test: 100% coverage on core modules with headless CI support
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
- Fix dead exception handler in discord_rpc.py (unreachable DiscordNotFound)
- Replace QMediaPlayer=None stub with proper _StubQMediaPlayer in player.py
- Make AudioVisualizer color stops Qt-free (raw tuples instead of QColor)
- Add conftest QApplication creation check for headless CI detection
- Add test_gui_widgets.py _qt_available() guard for headless environments
- Add test_main.py (30 tests) for main.py entry point and env vars
- Add test_import_fallbacks.py (14 tests) for Qt import fallback paths
- Extend test_gui_helpers.py with _css, _get_thumbnail_nam, _cached_thumb_path tests
- Extend test_discord_rpc.py with direct _connect() test for DiscordNotFound
- Extend test_player_helpers.py with SearchWorker test-mode tests
- Ensure import fallback tests clean up sys.modules to avoid cross-test pollution

100% coverage achieved on: config.py, discord_rpc.py, main.py, music_db.py
2026-06-04 20:53:00 +03:00
5ce18506c1 🐛 | Skip widget tests when Qt is stubbed (headless CI)
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m45s
2026-06-04 19:23:38 +03:00
6530911c28 | Add widget integration tests for SonarQube coverage
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m22s
2026-06-04 19:08:48 +03:00
fb478d6612 🐛 | Fix SonarQube violations - use items() and remove assert True
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m42s
2026-06-04 19:00:38 +03:00
3b6e4f8b4d 🐛 | Remove unused video_id from closure defaults 2026-06-04 18:57:31 +03:00
13 changed files with 2520 additions and 35 deletions

View File

@@ -41,7 +41,7 @@ jobs:
.venv/bin/coverage xml .venv/bin/coverage xml
- name: Run SonarQube Scanner - name: Run SonarQube Scanner
uses: sonarsource/sonarqube-scan-action@v4 uses: sonarsource/sonarqube-scan-action@v6
env: env:
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST_URL }}

101
gui.py
View File

@@ -80,6 +80,7 @@ except (ImportError, OSError):
from config import SETTINGS, get_setting, save_setting, save_volume from config import SETTINGS, get_setting, save_setting, save_volume
from discord_rpc import DiscordRPC from discord_rpc import DiscordRPC
from mpris import MprisManager
from music_db import MusicDB from music_db import MusicDB
from player import AudioPlayer, SearchWorker, _best_thumbnail from player import AudioPlayer, SearchWorker, _best_thumbnail
@@ -170,8 +171,7 @@ def _load_thumbnail(video_id: str, thumb_url: str,
def _on_downloaded(reply=reply, cache_path=cache_path, def _on_downloaded(reply=reply, cache_path=cache_path,
art_label=art_label, size=size, art_label=art_label, size=size,
style_pass=style_pass, style_fail=style_fail, style_pass=style_pass, style_fail=style_fail):
video_id=video_id):
# The label may have been deleted (e.g. search results refreshed) # The label may have been deleted (e.g. search results refreshed)
# while the network request was in flight — guard against that. # while the network request was in flight — guard against that.
try: try:
@@ -608,16 +608,19 @@ class AudioVisualizer(QWidget):
_TICK_MS = 16 _TICK_MS = 16
# Spectral gradient colour stops (left → right / bass → treble) # Spectral gradient colour stops (left → right / bass → treble)
SPECTRUM_COLORS = [ # Stored as raw (R, G, B) tuples to avoid Qt dependency at class-definition
QColor(139, 92, 246), # 0.0 Violet (bass) # time — needed so the module can be imported for pure-helper-function tests
QColor(168, 85, 247), # 0.1 Purple # without a real PySide6 runtime.
QColor(236, 72, 153), # 0.25 Pink _SPECTRUM_COLOR_STOPS = [
QColor(251, 146, 134), # 0.4 Rose (139, 92, 246), # 0.0 Violet (bass)
QColor(251, 207, 132), # 0.5 Amber (centre) (168, 85, 247), # 0.1 Purple
QColor(251, 207, 132), # 0.6 Amber (236, 72, 153), # 0.25 Pink
QColor(236, 72, 153), # 0.75 Pink (251, 146, 134), # 0.4 Rose
QColor(168, 85, 247), # 0.9 Purple (251, 207, 132), # 0.5 Amber (centre)
QColor(139, 92, 246), # 1.0 Violet (bass mirror) (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): def __init__(self, parent=None):
@@ -888,21 +891,23 @@ class AudioVisualizer(QWidget):
@staticmethod @staticmethod
def _spectrum_color(t: float) -> QColor: 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: if t <= 0.0:
return cols[0] r, g, b = stops[0]
return QColor(r, g, b)
if t >= 1.0: if t >= 1.0:
return cols[-1] r, g, b = stops[-1]
t_scaled = t * (len(cols) - 1) return QColor(r, g, b)
t_scaled = t * (len(stops) - 1)
idx = int(t_scaled) idx = int(t_scaled)
frac = t_scaled - idx frac = t_scaled - idx
c1 = cols[idx] c1 = stops[idx]
c2 = cols[min(idx + 1, len(cols) - 1)] c2 = stops[min(idx + 1, len(stops) - 1)]
return QColor( return QColor(
int(c1.red() + (c2.red() - c1.red()) * frac), int(c1[0] + (c2[0] - c1[0]) * frac),
int(c1.green() + (c2.green() - c1.green()) * frac), int(c1[1] + (c2[1] - c1[1]) * frac),
int(c1.blue() + (c2.blue() - c1.blue()) * frac), int(c1[2] + (c2[2] - c1[2]) * frac),
int(c1.alpha() + (c2.alpha() - c1.alpha()) * frac),
) )
@@ -2275,6 +2280,9 @@ class TunettiWindow(QMainWindow):
self.db = MusicDB(SETTINGS["db_path"]) self.db = MusicDB(SETTINGS["db_path"])
self.rpc = DiscordRPC(SETTINGS["discord_client_id"]) self.rpc = DiscordRPC(SETTINGS["discord_client_id"])
self.player = AudioPlayer(self) self.player = AudioPlayer(self)
self.mpris = MprisManager()
self.mpris.start()
self._connect_mpris()
# ── Central widget ── # ── Central widget ──
central = QWidget() central = QWidget()
@@ -2943,6 +2951,54 @@ class TunettiWindow(QMainWindow):
except Exception as exc: except Exception as exc:
log.error("_poll_minimized error: %s", 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) ── # ── Public accessors (for child dialogs) ──
def get_visualizer(self) -> AudioVisualizer: def get_visualizer(self) -> AudioVisualizer:
@@ -2978,6 +3034,7 @@ class TunettiWindow(QMainWindow):
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
# Flush any pending volume save before closing # Flush any pending volume save before closing
self._playback.flush_volume() self._playback.flush_volume()
self.mpris.stop()
self.player.shutdown() self.player.shutdown()
self.rpc.stop() self.rpc.stop()
self.db.close() self.db.close()

480
mpris.py Normal file
View 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 DBus thread.
When the ``dbus-fast`` library is unavailable or DBus 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 DBus 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):
"""Threadsafe Qt signal bridge for MPRIS commands.
Created on the Qt main thread. Signals are emitted from the DBus
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 DBus 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 DBus service.
Returns ``True`` if the service was started successfully,
``False`` if DBus 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 DBus 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 DBus property changes.
*status* is one of ``"playing"``, ``"paused"``, ``"stopped"``.
This is thread-safe: the actual DBus signal emission happens on
the DBus 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 DBus thread will see the new values).
self._state.update(mpris_status, metadata, can_prev)
# Queue the property-change notification for the DBus 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 DBus 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 DBus 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 DBus 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 DBus 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 DBus, 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()

View File

@@ -58,13 +58,103 @@ try:
except (ImportError, OSError): except (ImportError, OSError):
_HAS_QT = False _HAS_QT = False
QObject = object QObject = object
QThread = type("QThread", (), {"start": lambda s: None,
"quit": lambda s: None, class _StubQThread:
"wait": lambda s, t=3000: True}) """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 Signal = lambda *a: lambda f: f
Slot = lambda *a: lambda f: f Slot = lambda *a: lambda f: f
QUrl = None 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 QAudioOutput = None
log = logging.getLogger("tunetti.player") log = logging.getLogger("tunetti.player")

View File

@@ -2,3 +2,4 @@ yt-dlp>=2024.0.0
ytmusicapi>=1.0.0 ytmusicapi>=1.0.0
pypresence>=4.0.0 pypresence>=4.0.0
PySide6>=6.5.0 PySide6>=6.5.0
dbus-fast>=5.0.0

View File

@@ -144,11 +144,11 @@ def _install_stubs() -> None:
(MOD_QTWIDGETS, "QSizePolicy"): _stub("QSizePolicy"), (MOD_QTWIDGETS, "QSizePolicy"): _stub("QSizePolicy"),
} }
for mod_name, attr_name in _PYSIDE_STUBS: for (mod_name, attr_name), stub_value in _PYSIDE_STUBS.items():
if mod_name not in sys.modules: if mod_name not in sys.modules:
mod = mock.MagicMock(__name__=mod_name.rsplit(".", 1)[-1]) mod = mock.MagicMock(__name__=mod_name.rsplit(".", 1)[-1])
sys.modules[mod_name] = mod sys.modules[mod_name] = mod
setattr(sys.modules[mod_name], attr_name, _PYSIDE_STUBS[(mod_name, attr_name)]) setattr(sys.modules[mod_name], attr_name, stub_value)
# ── yt-dlp ───────────────────────────────────────────────────────── # ── yt-dlp ─────────────────────────────────────────────────────────
if "yt_dlp" not in sys.modules: if "yt_dlp" not in sys.modules:
@@ -176,12 +176,25 @@ def _install_stubs() -> None:
_QT_AVAILABLE = False _QT_AVAILABLE = False
try: 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 # noqa: F401
import PySide6.QtCore # noqa: F401 import PySide6.QtCore # noqa: F401
import PySide6.QtMultimedia # 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 _QT_AVAILABLE = True
except (ImportError, OSError): except (ImportError, OSError, RuntimeError):
pass pass
if not _QT_AVAILABLE: if not _QT_AVAILABLE:

View File

@@ -230,7 +230,6 @@ class TestDiscordRPC:
drpc._start_ts = 1000 drpc._start_ts = 1000
# Should not crash # Should not crash
drpc._send_presence() drpc._send_presence()
assert True
def test_connect_oserror_handled(self): def test_connect_oserror_handled(self):
"""_connect handles OSError gracefully (covers lines 119-122).""" """_connect handles OSError gracefully (covers lines 119-122)."""
@@ -258,6 +257,19 @@ class TestDiscordRPC:
assert result is False assert result is False
assert drpc._connected 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): def test_run_reconnects_on_failure(self):
"""_run loop retries connection when _connect fails (covers lines 174-175).""" """_run loop retries connection when _connect fails (covers lines 174-175)."""
with mock.patch("discord_rpc.Presence") as mock_presence_cls: with mock.patch("discord_rpc.Presence") as mock_presence_cls:

View File

@@ -158,3 +158,68 @@ class TestArtistsFromJson:
fn = self._import() fn = self._import()
j = json.dumps([{"name": "Real"}, 42, "str", {"name": "Also Real"}]) j = json.dumps([{"name": "Real"}, 42, "str", {"name": "Also Real"}])
assert fn(j) == "Real, 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
View 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)

View 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
View 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
View 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

View File

@@ -5,6 +5,7 @@ import struct
import math import math
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest import mock
import pytest import pytest
@@ -301,13 +302,11 @@ class TestCleanupPath:
"""_cleanup_path does nothing for non-existent paths.""" """_cleanup_path does nothing for non-existent paths."""
fn = self._import() fn = self._import()
fn("/nonexistent/path/file.txt") # should not raise fn("/nonexistent/path/file.txt") # should not raise
assert True
def test_noop_for_none(self): def test_noop_for_none(self):
"""_cleanup_path does nothing when path is None.""" """_cleanup_path does nothing when path is None."""
fn = self._import() fn = self._import()
fn(None) fn(None)
assert True
def test_oserror_handled_gracefully(self, tmp_path, monkeypatch): def test_oserror_handled_gracefully(self, tmp_path, monkeypatch):
"""_cleanup_path handles OSError gracefully.""" """_cleanup_path handles OSError gracefully."""
@@ -320,7 +319,6 @@ class TestCleanupPath:
monkeypatch.setattr(os, "unlink", _fail_unlink) monkeypatch.setattr(os, "unlink", _fail_unlink)
fn(str(path)) # should not raise fn(str(path)) # should not raise
assert True
# ── _tmp_dir ─────────────────────────────────────────────────────────────────── # ── _tmp_dir ───────────────────────────────────────────────────────────────────
@@ -343,3 +341,60 @@ class TestTmpDir:
"""Calling _tmp_dir multiple times returns the same path.""" """Calling _tmp_dir multiple times returns the same path."""
fn = self._import() fn = self._import()
assert fn() == fn() 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"