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