Files
Tunetti/tests/conftest.py
NikkeDoy 2870f790bd
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m44s
🐛 | Fix tests
2026-06-03 19:05:17 +03:00

183 lines
9.2 KiB
Python

"""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).",
)