From 2870f790bdd9f65cc03bdd9d830418641ac94c42 Mon Sep 17 00:00:00 2001 From: NikkeDoy Date: Wed, 3 Jun 2026 19:05:17 +0300 Subject: [PATCH] :bug: | Fix tests --- README.md | 26 ++++++- gui.py | 88 +++++++++++++++++----- player.py | 25 ++++++- tests/conftest.py | 182 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 tests/conftest.py diff --git a/README.md b/README.md index 25be12f..564012c 100644 --- a/README.md +++ b/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) | | [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 diff --git a/gui.py b/gui.py index 9e1ebae..4813783 100644 --- a/gui.py +++ b/gui.py @@ -11,23 +11,73 @@ import math from pathlib import Path from typing import Optional -from PySide6.QtCore import ( - Qt, QTimer, QThread, Signal, Slot, QRectF, QUrl, - QPropertyAnimation, QEasingCurve, QLoggingCategory, QEvent, -) -from PySide6.QtGui import ( - QFont, QPixmap, QPainter, QColor, QBrush, -) -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, -) +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# Qt imports are guarded so the module can be imported for its pure helper +# functions (``_fmt_ms``, ``_artists_str``, etc.) even when the system +# lacks EGL / PulseAudio native libraries. +# The GUI classes will **not** be functional without the real Qt runtime. +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +_HAS_QT = True +try: + from PySide6.QtCore import ( + Qt, QTimer, QThread, Signal, Slot, QRectF, QUrl, + QPropertyAnimation, QEasingCurve, QLoggingCategory, QEvent, + ) + from PySide6.QtGui import ( + QFont, QPixmap, QPainter, QColor, QBrush, + ) + 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 discord_rpc import DiscordRPC @@ -36,7 +86,7 @@ from player import AudioPlayer, SearchWorker, _best_thumbnail 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _STYLE_BG_TRANSPARENT = "background: transparent;" @@ -73,7 +123,7 @@ def _css(*parts: str) -> str: # 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" diff --git a/player.py b/player.py index dcf72ca..adf35c4 100644 --- a/player.py +++ b/player.py @@ -40,8 +40,29 @@ import threading from typing import Optional 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") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d7c776e --- /dev/null +++ b/tests/conftest.py @@ -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).", + )