Files
Tunetti/tests/conftest.py
NikkeDoy dc3c735655
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
test: 100% coverage on core modules with headless CI support
- Fix dead exception handler in discord_rpc.py (unreachable DiscordNotFound)
- Replace QMediaPlayer=None stub with proper _StubQMediaPlayer in player.py
- Make AudioVisualizer color stops Qt-free (raw tuples instead of QColor)
- Add conftest QApplication creation check for headless CI detection
- Add test_gui_widgets.py _qt_available() guard for headless environments
- Add test_main.py (30 tests) for main.py entry point and env vars
- Add test_import_fallbacks.py (14 tests) for Qt import fallback paths
- Extend test_gui_helpers.py with _css, _get_thumbnail_nam, _cached_thumb_path tests
- Extend test_discord_rpc.py with direct _connect() test for DiscordNotFound
- Extend test_player_helpers.py with SearchWorker test-mode tests
- Ensure import fallback tests clean up sys.modules to avoid cross-test pollution

100% coverage achieved on: config.py, discord_rpc.py, main.py, music_db.py
2026-06-04 20:53:00 +03:00

213 lines
9.5 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``.
Strategy
--------
1. First try to import PySide6/Qt for real.
2. If that succeeds → Qt is available (local dev machine). The try/except
guards in ``player.py`` / ``gui.py`` will hit the ``try`` branch. No
stubs needed.
3. If the real import fails → we install lightweight stubs in ``sys.modules``
so the modules can be imported and pure helper functions tested. The
``except`` branches in the production guards will be exercised instead.
This way **both** branches of the guarded imports get coverage depending on
the runtime environment, and ``new_coverage`` on SonarQube stops complaining.
"""
from __future__ import annotations
import sys
from unittest import mock
# ═══════════════════════════════════════════════════════════════════════════
# Stub definitions (used only when real Qt is unavailable)
# ═══════════════════════════════════════════════════════════════════════════
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.
"""
if len(types) == 1 and callable(types[0]) and not isinstance(types[0], type):
return types[0]
return lambda f: f
def _install_stubs() -> None:
"""Populate sys.modules with stubs for platform-dependent packages.
Called only when PySide6 failed to import for real — guarantees that
pure helper-function tests can still import ``player.py``/``gui.py``
and access their standalone utility functions.
"""
def _stub(name: str) -> mock.MagicMock:
m = mock.MagicMock()
m.__name__ = name
return m
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"),
(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"),
(MOD_QTMULTIMEDIA, "QMediaPlayer"): _stub("QMediaPlayer"),
(MOD_QTMULTIMEDIA, "QAudioOutput"): _stub("QAudioOutput"),
(MOD_QTMULTIMEDIA, "QAudioBufferOutput"): _stub("QAudioBufferOutput"),
(MOD_QTNETWORK, "QNetworkAccessManager"): _stub("QNetworkAccessManager"),
(MOD_QTNETWORK, "QNetworkRequest"): _stub("QNetworkRequest"),
(MOD_QTNETWORK, "QNetworkReply"): _stub("QNetworkReply"),
(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
setattr(sys.modules[mod_name], 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")
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
# ═══════════════════════════════════════════════════════════════════════════
# Bootstrap: try real Qt first, fall back to stubs only if that fails.
# This ensures the try/except guards in player.py / gui.py are exercised
# in both Qt-available and Qt-unavailable environments.
# ═══════════════════════════════════════════════════════════════════════════
_QT_AVAILABLE = False
try:
# Step 1: Try to import PySide6 modules. These can succeed even in
# headless CI because no display is needed to import the Python bindings.
import PySide6 # noqa: F401
import PySide6.QtCore # noqa: F401
import PySide6.QtMultimedia # noqa: F401
# Step 2: Verify that a QApplication can actually be created.
# In headless CI without QT_QPA_PLATFORM=offscreen this will raise
# a RuntimeError ("Cannot create a QWidget without a QApplication")
# or OSError ("Could not connect to display").
from PySide6.QtWidgets import QApplication
_existing_app = QApplication.instance()
if _existing_app is None:
_test_app = QApplication([])
_test_app.quit()
del _test_app
_QT_AVAILABLE = True
except (ImportError, OSError, RuntimeError):
pass
if not _QT_AVAILABLE:
_install_stubs()
# ═══════════════════════════════════════════════════════════════════════════
# Pytest hooks
# ═══════════════════════════════════════════════════════════════════════════
def pytest_configure(config):
config.addinivalue_line(
"markers",
"qt: mark test as requiring a real Qt runtime (skipped if PySide6 unavailable).",
)