"""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. Used as a base class so that ``class Foo(QObject)`` produces a real class with the methods defined in its body, not a MagicMock. """ pass class _StubQThread: """Minimal replacement for PySide6.QtCore.QThread.""" def start(self) -> None: """Stub — no-op; real QThread would start the event loop.""" 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 # ── Module-name constants (extracted to avoid S1192 duplicates) ── MOD_QTCORE = "PySide6.QtCore" MOD_QTGUI = "PySide6.QtGui" MOD_QTMULTIMEDIA = "PySide6.QtMultimedia" MOD_QTNETWORK = "PySide6.QtNetwork" MOD_QTWIDGETS = "PySide6.QtWidgets" _PYSIDE_STUBS = { (MOD_QTCORE, "QObject"): _StubQObject, (MOD_QTCORE, "QThread"): _StubQThread, (MOD_QTCORE, "Signal"): _stub_signal, (MOD_QTCORE, "Slot"): _stub_slot, (MOD_QTCORE, "QUrl"): _stub("QUrl"), (MOD_QTCORE, "Qt"): _stub("Qt"), (MOD_QTCORE, "QTimer"): _stub("QTimer"), (MOD_QTCORE, "QRectF"): _stub("QRectF"), (MOD_QTCORE, "QPropertyAnimation"): _stub("QPropertyAnimation"), (MOD_QTCORE, "QEasingCurve"): _stub("QEasingCurve"), (MOD_QTCORE, "QLoggingCategory"): _stub("QLoggingCategory"), (MOD_QTCORE, "QEvent"): _stub("QEvent"), # ── QtGui ─────────────────────────────────────────────────── (MOD_QTGUI, "QFont"): _stub("QFont"), (MOD_QTGUI, "QPixmap"): _stub("QPixmap"), (MOD_QTGUI, "QPainter"): _stub("QPainter"), (MOD_QTGUI, "QColor"): _stub("QColor"), (MOD_QTGUI, "QBrush"): _stub("QBrush"), # ── QtMultimedia ──────────────────────────────────────────── (MOD_QTMULTIMEDIA, "QMediaPlayer"): _stub("QMediaPlayer"), (MOD_QTMULTIMEDIA, "QAudioOutput"): _stub("QAudioOutput"), (MOD_QTMULTIMEDIA, "QAudioBufferOutput"): _stub("QAudioBufferOutput"), # ── QtNetwork ─────────────────────────────────────────────── (MOD_QTNETWORK, "QNetworkAccessManager"): _stub("QNetworkAccessManager"), (MOD_QTNETWORK, "QNetworkRequest"): _stub("QNetworkRequest"), (MOD_QTNETWORK, "QNetworkReply"): _stub("QNetworkReply"), # ── QtWidgets ─────────────────────────────────────────────── (MOD_QTWIDGETS, "QApplication"): _stub("QApplication"), (MOD_QTWIDGETS, "QCheckBox"): _stub("QCheckBox"), (MOD_QTWIDGETS, "QDialog"): _stub("QDialog"), (MOD_QTWIDGETS, "QHBoxLayout"): _stub("QHBoxLayout"), (MOD_QTWIDGETS, "QLabel"): _stub("QLabel"), (MOD_QTWIDGETS, "QLineEdit"): _stub("QLineEdit"), (MOD_QTWIDGETS, "QListWidget"): _stub("QListWidget"), (MOD_QTWIDGETS, "QListWidgetItem"): _stub("QListWidgetItem"), (MOD_QTWIDGETS, "QMainWindow"): _stub("QMainWindow"), (MOD_QTWIDGETS, "QMenu"): _stub("QMenu"), (MOD_QTWIDGETS, "QPushButton"): _stub("QPushButton"), (MOD_QTWIDGETS, "QScrollArea"): _stub("QScrollArea"), (MOD_QTWIDGETS, "QSlider"): _stub("QSlider"), (MOD_QTWIDGETS, "QStackedWidget"): _stub("QStackedWidget"), (MOD_QTWIDGETS, "QVBoxLayout"): _stub("QVBoxLayout"), (MOD_QTWIDGETS, "QWidget"): _stub("QWidget"), (MOD_QTWIDGETS, "QFrame"): _stub("QFrame"), (MOD_QTWIDGETS, "QToolButton"): _stub("QToolButton"), (MOD_QTWIDGETS, "QButtonGroup"): _stub("QButtonGroup"), (MOD_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).", )