🐛 | Fix tests
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m44s
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m44s
This commit is contained in:
26
README.md
26
README.md
@@ -110,9 +110,31 @@ Tunetti stores its settings in `~/.config/tunetti/config.json`. Here's what you
|
|||||||
| [PySide6](https://pypi.org/project/PySide6/) | The graphical interface (Qt6) |
|
| [PySide6](https://pypi.org/project/PySide6/) | The graphical interface (Qt6) |
|
||||||
| [pypresence](https://github.com/qwertyquerty/pypresence) | Discord Rich Presence |
|
| [pypresence](https://github.com/qwertyquerty/pypresence) | Discord Rich Presence |
|
||||||
|
|
||||||
## 🧪 Quality checks
|
## 🧪 Testing
|
||||||
|
|
||||||
Code quality is analyzed automatically by SonarQube whenever changes are pushed.
|
Tests use **pytest** with **coverage** and are designed to run both locally and in CI (SonarQube picks up the coverage report automatically).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install test dependencies
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
.venv/bin/python -m pytest tests/ -v
|
||||||
|
|
||||||
|
# Run with coverage (generates coverage.xml for SonarQube)
|
||||||
|
.venv/bin/coverage run -m pytest tests/
|
||||||
|
.venv/bin/coverage xml
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Coverage | What's covered |
|
||||||
|
|---|---|---|
|
||||||
|
| `music_db.py` | 100% | All DB operations: record, favourites, history, stats, edge cases |
|
||||||
|
| `config.py` | 87% | Load, save, migrate, volume clamping, get/set settings |
|
||||||
|
| `discord_rpc.py` | 84% | `_safe_artists` edge cases, DiscordRPC lifecycle, mocked pypresence |
|
||||||
|
| `player.py` | 32% | Helpers: `_best_thumbnail`, `_build_song_dict`, `_generate_test_wav`, `_stale_files`, `_build_resolved_dict` |
|
||||||
|
| `gui.py` | 12% | Helpers: `_fmt_ms`, `_artists_str`, `_norm_artists`, `_artists_from_json` |
|
||||||
|
|
||||||
|
> The Qt GUI/player classes require a display server and PulseAudio — they can't run in headless CI. Guarded imports (in `player.py` and `gui.py`) ensure pure helper functions are still testable anywhere.
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
|
|||||||
88
gui.py
88
gui.py
@@ -11,23 +11,73 @@ import math
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
Qt, QTimer, QThread, Signal, Slot, QRectF, QUrl,
|
# Qt imports are guarded so the module can be imported for its pure helper
|
||||||
QPropertyAnimation, QEasingCurve, QLoggingCategory, QEvent,
|
# functions (``_fmt_ms``, ``_artists_str``, etc.) even when the system
|
||||||
)
|
# lacks EGL / PulseAudio native libraries.
|
||||||
from PySide6.QtGui import (
|
# The GUI classes will **not** be functional without the real Qt runtime.
|
||||||
QFont, QPixmap, QPainter, QColor, QBrush,
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
)
|
_HAS_QT = True
|
||||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
try:
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtCore import (
|
||||||
QApplication, QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit,
|
Qt, QTimer, QThread, Signal, Slot, QRectF, QUrl,
|
||||||
QListWidget, QListWidgetItem, QMainWindow, QMenu, QPushButton,
|
QPropertyAnimation, QEasingCurve, QLoggingCategory, QEvent,
|
||||||
QScrollArea, QSlider, QStackedWidget, QVBoxLayout, QWidget, QFrame,
|
)
|
||||||
QToolButton, QButtonGroup, QSizePolicy,
|
from PySide6.QtGui import (
|
||||||
)
|
QFont, QPixmap, QPainter, QColor, QBrush,
|
||||||
from PySide6.QtMultimedia import (
|
)
|
||||||
QAudioBufferOutput,
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||||
)
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication, QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QListWidget, QListWidgetItem, QMainWindow, QMenu, QPushButton,
|
||||||
|
QScrollArea, QSlider, QStackedWidget, QVBoxLayout, QWidget, QFrame,
|
||||||
|
QToolButton, QButtonGroup, QSizePolicy,
|
||||||
|
)
|
||||||
|
from PySide6.QtMultimedia import (
|
||||||
|
QAudioBufferOutput,
|
||||||
|
)
|
||||||
|
except (ImportError, OSError):
|
||||||
|
_HAS_QT = False
|
||||||
|
Qt = object
|
||||||
|
QTimer = object
|
||||||
|
QThread = type("QThread", (), {"start": lambda s: None})
|
||||||
|
Signal = lambda *a: lambda f: f
|
||||||
|
Slot = lambda *a: lambda f: f
|
||||||
|
QRectF = object
|
||||||
|
QUrl = None
|
||||||
|
QPropertyAnimation = object
|
||||||
|
QEasingCurve = object
|
||||||
|
QLoggingCategory = object
|
||||||
|
QEvent = object
|
||||||
|
QFont = None
|
||||||
|
QPixmap = object
|
||||||
|
QPainter = object
|
||||||
|
QColor = object
|
||||||
|
QBrush = object
|
||||||
|
QNetworkAccessManager = object
|
||||||
|
QNetworkRequest = object
|
||||||
|
QNetworkReply = object
|
||||||
|
QApplication = object
|
||||||
|
QCheckBox = object
|
||||||
|
QDialog = object
|
||||||
|
QHBoxLayout = object
|
||||||
|
QLabel = object
|
||||||
|
QLineEdit = object
|
||||||
|
QListWidget = object
|
||||||
|
QListWidgetItem = object
|
||||||
|
QMainWindow = object
|
||||||
|
QMenu = object
|
||||||
|
QPushButton = object
|
||||||
|
QScrollArea = object
|
||||||
|
QSlider = object
|
||||||
|
QStackedWidget = object
|
||||||
|
QVBoxLayout = object
|
||||||
|
QWidget = object
|
||||||
|
QFrame = object
|
||||||
|
QToolButton = object
|
||||||
|
QButtonGroup = object
|
||||||
|
QSizePolicy = object
|
||||||
|
QAudioBufferOutput = object
|
||||||
|
|
||||||
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
|
||||||
@@ -36,7 +86,7 @@ from player import AudioPlayer, SearchWorker, _best_thumbnail
|
|||||||
|
|
||||||
log = logging.getLogger("tunetti.gui")
|
log = logging.getLogger("tunetti.gui")
|
||||||
|
|
||||||
FONT = QFont("Cantarell, Noto Sans, Segoe UI, sans-serif", 10)
|
FONT = QFont("Cantarell, Noto Sans, Segoe UI, sans-serif", 10) if QFont else None
|
||||||
|
|
||||||
# ── Re-used style constants ──────────────────────────────────────────────
|
# ── Re-used style constants ──────────────────────────────────────────────
|
||||||
_STYLE_BG_TRANSPARENT = "background: transparent;"
|
_STYLE_BG_TRANSPARENT = "background: transparent;"
|
||||||
@@ -73,7 +123,7 @@ def _css(*parts: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Shared thumbnail downloader to avoid spawning many QNAM instances
|
# Shared thumbnail downloader to avoid spawning many QNAM instances
|
||||||
_thumbnail_nam: Optional[QNetworkAccessManager] = None
|
_thumbnail_nam = None # type: ignore[assignment] # set to QNetworkAccessManager when Qt is available
|
||||||
|
|
||||||
|
|
||||||
THUMB_CACHE_DIR = Path.home() / ".cache" / "tunetti"
|
THUMB_CACHE_DIR = Path.home() / ".cache" / "tunetti"
|
||||||
|
|||||||
25
player.py
25
player.py
@@ -40,8 +40,29 @@ import threading
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from PySide6.QtCore import QObject, QThread, Signal, Slot, QUrl
|
|
||||||
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Qt imports are guarded so the module can be imported for its pure helper
|
||||||
|
# functions (``_best_thumbnail``, ``_build_song_dict``, etc.) even when the
|
||||||
|
# system lacks PulseAudio / EGL / other Qt multimedia native libraries.
|
||||||
|
# The player classes (``AudioPlayer``, ``DownloadWorker``, ``SearchWorker``)
|
||||||
|
# will **not** be functional in that case — they need the real Qt runtime.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
_HAS_QT = True
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import QObject, QThread, Signal, Slot, QUrl
|
||||||
|
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||||
|
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})
|
||||||
|
Signal = lambda *a: lambda f: f
|
||||||
|
Slot = lambda *a: lambda f: f
|
||||||
|
QUrl = None
|
||||||
|
QMediaPlayer = None
|
||||||
|
QAudioOutput = None
|
||||||
|
|
||||||
log = logging.getLogger("tunetti.player")
|
log = logging.getLogger("tunetti.player")
|
||||||
|
|
||||||
|
|||||||
182
tests/conftest.py
Normal file
182
tests/conftest.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Pytest configuration: mock platform-dependent libraries for unit tests.
|
||||||
|
|
||||||
|
Pure helper-function tests (``test_player_helpers``, ``test_gui_helpers``)
|
||||||
|
import modules that unconditionally load PySide6 / Qt native libraries at the
|
||||||
|
top level. In CI environments (Docker without PulseAudio, EGL, etc.) these
|
||||||
|
imports fail with ``ImportError`` for ``libpulse.so.0`` or ``libEGL.so.1``.
|
||||||
|
|
||||||
|
This conftest pre-populates ``sys.modules`` with minimal **real** stub classes
|
||||||
|
(not ``MagicMock``) for the key Qt base types, so that modules can be imported
|
||||||
|
and class hierarchies defined without crashing. Integration tests that
|
||||||
|
actually exercise Qt widgets should use
|
||||||
|
``pytest.importorskip("PySide6.QtMultimedia")`` at the call site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Minimal stub classes for PySide6 types that are used as base classes or
|
||||||
|
# decorators. Using real classes (not MagicMock) ensures that:
|
||||||
|
# class Foo(QObject): ...
|
||||||
|
# produces a real class with the methods defined in its body, rather than
|
||||||
|
# a MagicMock that doesn't forward attribute access.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _StubQObject:
|
||||||
|
"""Minimal replacement for PySide6.QtCore.QObject."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _StubQThread:
|
||||||
|
"""Minimal replacement for PySide6.QtCore.QThread."""
|
||||||
|
def start(self) -> None:
|
||||||
|
pass
|
||||||
|
def quit(self) -> None:
|
||||||
|
pass
|
||||||
|
def wait(self, timeout: int = 3000) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_signal(*types):
|
||||||
|
"""Minimal replacement for PySide6.QtCore.Signal."""
|
||||||
|
class _Signal:
|
||||||
|
def __init__(self):
|
||||||
|
self._handlers = set()
|
||||||
|
def connect(self, slot):
|
||||||
|
self._handlers.add(slot)
|
||||||
|
def disconnect(self, slot=None):
|
||||||
|
if slot:
|
||||||
|
self._handlers.discard(slot)
|
||||||
|
else:
|
||||||
|
self._handlers.clear()
|
||||||
|
def emit(self, *args, **kwargs):
|
||||||
|
for handler in self._handlers:
|
||||||
|
handler(*args, **kwargs)
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
return self.emit(*args, **kwargs)
|
||||||
|
return _Signal()
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_slot(*types):
|
||||||
|
"""Minimal replacement for PySide6.QtCore.Slot.
|
||||||
|
|
||||||
|
Handles ``@Slot``, ``@Slot()``, and ``@Slot(int, dict)`` usage.
|
||||||
|
"""
|
||||||
|
# @Slot without parentheses → the function itself is passed
|
||||||
|
if len(types) == 1 and callable(types[0]) and not isinstance(types[0], type):
|
||||||
|
return types[0]
|
||||||
|
# @Slot(int, dict) or @Slot() → return a passthrough decorator
|
||||||
|
return lambda f: f
|
||||||
|
|
||||||
|
|
||||||
|
def _install_stubs() -> None:
|
||||||
|
"""Populate sys.modules with stubs for platform-dependent packages.
|
||||||
|
|
||||||
|
Uses *instances* of MagicMock (``MagicMock()``) for types that need
|
||||||
|
nested attribute access (e.g. ``QMediaPlayer.PlaybackState``). Uses
|
||||||
|
real stub *classes* (``_StubQObject``) for types that are used as
|
||||||
|
base classes in ``class Foo(QObject):`` definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Helper: a fresh MagicMock instance with a readable __name__ ─────
|
||||||
|
def _stub(name: str) -> mock.MagicMock:
|
||||||
|
m = mock.MagicMock()
|
||||||
|
m.__name__ = name
|
||||||
|
return m
|
||||||
|
|
||||||
|
_PYSIDE_STUBS = {
|
||||||
|
("PySide6", "_module"): _stub("PySide6"),
|
||||||
|
# ── QtCore ──────────────────────────────────────────────────
|
||||||
|
("PySide6.QtCore", "QObject"): _StubQObject,
|
||||||
|
("PySide6.QtCore", "QThread"): _StubQThread,
|
||||||
|
("PySide6.QtCore", "Signal"): _stub_signal,
|
||||||
|
("PySide6.QtCore", "Slot"): _stub_slot,
|
||||||
|
("PySide6.QtCore", "QUrl"): _stub("QUrl"),
|
||||||
|
("PySide6.QtCore", "Qt"): _stub("Qt"),
|
||||||
|
("PySide6.QtCore", "QTimer"): _stub("QTimer"),
|
||||||
|
("PySide6.QtCore", "QRectF"): _stub("QRectF"),
|
||||||
|
("PySide6.QtCore", "QPropertyAnimation"): _stub("QPropertyAnimation"),
|
||||||
|
("PySide6.QtCore", "QEasingCurve"): _stub("QEasingCurve"),
|
||||||
|
("PySide6.QtCore", "QLoggingCategory"): _stub("QLoggingCategory"),
|
||||||
|
("PySide6.QtCore", "QEvent"): _stub("QEvent"),
|
||||||
|
# ── QtGui ───────────────────────────────────────────────────
|
||||||
|
("PySide6.QtGui", "QFont"): _stub("QFont"),
|
||||||
|
("PySide6.QtGui", "QPixmap"): _stub("QPixmap"),
|
||||||
|
("PySide6.QtGui", "QPainter"): _stub("QPainter"),
|
||||||
|
("PySide6.QtGui", "QColor"): _stub("QColor"),
|
||||||
|
("PySide6.QtGui", "QBrush"): _stub("QBrush"),
|
||||||
|
# ── QtMultimedia ────────────────────────────────────────────
|
||||||
|
("PySide6.QtMultimedia", "QMediaPlayer"): _stub("QMediaPlayer"),
|
||||||
|
("PySide6.QtMultimedia", "QAudioOutput"): _stub("QAudioOutput"),
|
||||||
|
("PySide6.QtMultimedia", "QAudioBufferOutput"): _stub("QAudioBufferOutput"),
|
||||||
|
# ── QtNetwork ───────────────────────────────────────────────
|
||||||
|
("PySide6.QtNetwork", "QNetworkAccessManager"): _stub("QNetworkAccessManager"),
|
||||||
|
("PySide6.QtNetwork", "QNetworkRequest"): _stub("QNetworkRequest"),
|
||||||
|
("PySide6.QtNetwork", "QNetworkReply"): _stub("QNetworkReply"),
|
||||||
|
# ── QtWidgets ───────────────────────────────────────────────
|
||||||
|
("PySide6.QtWidgets", "QApplication"): _stub("QApplication"),
|
||||||
|
("PySide6.QtWidgets", "QCheckBox"): _stub("QCheckBox"),
|
||||||
|
("PySide6.QtWidgets", "QDialog"): _stub("QDialog"),
|
||||||
|
("PySide6.QtWidgets", "QHBoxLayout"): _stub("QHBoxLayout"),
|
||||||
|
("PySide6.QtWidgets", "QLabel"): _stub("QLabel"),
|
||||||
|
("PySide6.QtWidgets", "QLineEdit"): _stub("QLineEdit"),
|
||||||
|
("PySide6.QtWidgets", "QListWidget"): _stub("QListWidget"),
|
||||||
|
("PySide6.QtWidgets", "QListWidgetItem"): _stub("QListWidgetItem"),
|
||||||
|
("PySide6.QtWidgets", "QMainWindow"): _stub("QMainWindow"),
|
||||||
|
("PySide6.QtWidgets", "QMenu"): _stub("QMenu"),
|
||||||
|
("PySide6.QtWidgets", "QPushButton"): _stub("QPushButton"),
|
||||||
|
("PySide6.QtWidgets", "QScrollArea"): _stub("QScrollArea"),
|
||||||
|
("PySide6.QtWidgets", "QSlider"): _stub("QSlider"),
|
||||||
|
("PySide6.QtWidgets", "QStackedWidget"): _stub("QStackedWidget"),
|
||||||
|
("PySide6.QtWidgets", "QVBoxLayout"): _stub("QVBoxLayout"),
|
||||||
|
("PySide6.QtWidgets", "QWidget"): _stub("QWidget"),
|
||||||
|
("PySide6.QtWidgets", "QFrame"): _stub("QFrame"),
|
||||||
|
("PySide6.QtWidgets", "QToolButton"): _stub("QToolButton"),
|
||||||
|
("PySide6.QtWidgets", "QButtonGroup"): _stub("QButtonGroup"),
|
||||||
|
("PySide6.QtWidgets", "QSizePolicy"): _stub("QSizePolicy"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (mod_name, attr_name), stub_value in _PYSIDE_STUBS.items():
|
||||||
|
if mod_name not in sys.modules:
|
||||||
|
mod = mock.MagicMock(__name__=mod_name.rsplit(".", 1)[-1])
|
||||||
|
sys.modules[mod_name] = mod
|
||||||
|
mod = sys.modules[mod_name]
|
||||||
|
setattr(mod, attr_name, stub_value)
|
||||||
|
|
||||||
|
# ── yt-dlp ─────────────────────────────────────────────────────────
|
||||||
|
if "yt_dlp" not in sys.modules:
|
||||||
|
yt_dlp_mock = mock.MagicMock(__name__="yt_dlp")
|
||||||
|
ydl_mock = mock.MagicMock()
|
||||||
|
yt_dlp_mock.YoutubeDL.return_value.__enter__.return_value = ydl_mock
|
||||||
|
sys.modules["yt_dlp"] = yt_dlp_mock
|
||||||
|
|
||||||
|
# ── pypresence ─────────────────────────────────────────────────────
|
||||||
|
if "pypresence" not in sys.modules:
|
||||||
|
pypresence_mock = mock.MagicMock(__name__="pypresence")
|
||||||
|
# DiscordNotFound must be a real exception so ``except DiscordNotFound:``
|
||||||
|
# in the production code doesn't raise ``TypeError``.
|
||||||
|
pypresence_mock.DiscordNotFound = type("DiscordNotFound", (Exception,), {})
|
||||||
|
sys.modules["pypresence"] = pypresence_mock
|
||||||
|
if "pypresence.types" not in sys.modules:
|
||||||
|
types_mock = mock.MagicMock(__name__="types")
|
||||||
|
types_mock.ActivityType.LISTENING = "listening"
|
||||||
|
sys.modules["pypresence.types"] = types_mock
|
||||||
|
|
||||||
|
|
||||||
|
_install_stubs()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Pytest configuration hooks
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Register custom markers."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"qt: mark test as requiring a real Qt runtime (skipped if PySide6 unavailable).",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user