test: 100% coverage on core modules with headless CI support
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
- 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
This commit is contained in:
307
tests/test_import_fallbacks.py
Normal file
307
tests/test_import_fallbacks.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""Tests for module import fallback paths when Qt is unavailable.
|
||||
|
||||
These tests verify that ``player.py`` and ``gui.py`` gracefully degrade
|
||||
when PySide6 cannot be imported (e.g. headless CI without PulseAudio/EGL).
|
||||
|
||||
The strategy is:
|
||||
1. Remove any existing ``player``/``gui``/``PySide6`` entries from ``sys.modules``.
|
||||
2. Mock ``builtins.__import__`` so that any import starting with ``PySide6``
|
||||
raises ``ImportError``.
|
||||
3. Import the module under test – the ``except (ImportError, OSError):``
|
||||
branch should now execute.
|
||||
4. Verify that the fallback constants/classes are set correctly.
|
||||
5. Restore the original ``sys.modules`` and ``__import__`` so other tests
|
||||
are not affected.
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clean_pyside_modules():
|
||||
"""Remove PySide6 and target modules from sys.modules."""
|
||||
keys_to_del = []
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith("PySide6") or key in ("player", "gui", "discord_rpc"):
|
||||
keys_to_del.append(key)
|
||||
for key in keys_to_del:
|
||||
del sys.modules[key]
|
||||
|
||||
|
||||
def _make_importer(block_pyside: bool = True):
|
||||
"""Return an ``__import__`` mock side-effect that optionally blocks PySide6.
|
||||
|
||||
When *block_pyside* is ``True``, any import whose name starts with
|
||||
``PySide6`` or ``pyside6`` raises ``ImportError``. All other imports
|
||||
are delegated to the real ``__import__``.
|
||||
"""
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if block_pyside and (
|
||||
name.startswith("PySide6") or name.startswith("pyside6")
|
||||
):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
return _side_effect
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# player.py fallback
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Snapshot the original PySide6 modules so the fallback tests can restore them.
|
||||
_ORIGINAL_PYSIDE_MODS = frozenset(
|
||||
k for k in sys.modules if k.startswith("PySide6")
|
||||
)
|
||||
|
||||
|
||||
class TestPlayerQtFallback:
|
||||
"""When PySide6 is unavailable, player defines stubs and sets _HAS_QT=False."""
|
||||
|
||||
def _cleanup(self):
|
||||
"""Remove the stale fallback player module so subsequent tests
|
||||
re-import the real module with real Qt bindings."""
|
||||
sys.modules.pop("player", None)
|
||||
# PySide6 modules may have been removed, restore them by clearing
|
||||
# the cached import so that a fresh import re-imports the real lib.
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith("PySide6") and key not in _ORIGINAL_PYSIDE_MODS:
|
||||
del sys.modules[key]
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_has_qt_is_false_without_pyside6(self):
|
||||
"""_HAS_QT should be False when PySide6 can't be imported."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player # noqa: F811
|
||||
|
||||
assert player._HAS_QT is False
|
||||
|
||||
def test_qobject_fallback_is_object(self):
|
||||
"""QObject fallback should be ``object``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QObject is object
|
||||
|
||||
def test_qmediaplayer_fallback_is_stub_class(self):
|
||||
"""QMediaPlayer fallback should be a stub class (not None)."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QMediaPlayer is not None
|
||||
assert hasattr(player.QMediaPlayer, "PlaybackState")
|
||||
assert hasattr(player.QMediaPlayer, "MediaStatus")
|
||||
assert hasattr(player.QMediaPlayer, "Error")
|
||||
|
||||
def test_qaudiooutput_fallback_is_none(self):
|
||||
"""QAudioOutput fallback should be ``None``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QAudioOutput is None
|
||||
|
||||
def test_qurl_fallback_is_none(self):
|
||||
"""QUrl fallback should be ``None``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert player.QUrl is None
|
||||
|
||||
def test_qthread_fallback_has_start_quit_wait(self):
|
||||
"""QThread fallback stub should have ``start``, ``quit`` and ``wait``."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert callable(player.QThread.start)
|
||||
assert callable(player.QThread.quit)
|
||||
assert callable(player.QThread.wait)
|
||||
# Quick functional check
|
||||
QTh = player.QThread
|
||||
inst = QTh()
|
||||
inst.start() # should not raise
|
||||
inst.quit() # should not raise
|
||||
assert inst.wait() is True
|
||||
|
||||
def test_signal_fallback_is_callable(self):
|
||||
"""Signal fallback should be a callable returning a callable."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert callable(player.Signal)
|
||||
result = player.Signal(int)
|
||||
assert callable(result) # @Signal → decorator → original function
|
||||
|
||||
def test_slot_fallback_is_callable(self):
|
||||
"""Slot fallback should be a callable returning a callable."""
|
||||
_clean_pyside_modules()
|
||||
side_effect = _make_importer(block_pyside=True)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=side_effect):
|
||||
import player
|
||||
|
||||
assert callable(player.Slot)
|
||||
result = player.Slot(int)
|
||||
assert callable(result) # @Slot → decorator → original function
|
||||
|
||||
def test_oserror_in_import_triggers_fallback(self):
|
||||
"""OSError during PySide6 import also triggers the fallback."""
|
||||
_clean_pyside_modules()
|
||||
# This time we mock __import__ to raise OSError instead of ImportError
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _oserror_side(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise OSError("Cannot load native library")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_oserror_side):
|
||||
import player
|
||||
|
||||
assert player._HAS_QT is False
|
||||
assert player.QMediaPlayer is not None
|
||||
assert hasattr(player.QMediaPlayer, "PlaybackState")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# gui.py fallback
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Note: Testing gui.py fallback requires also handling its own imports
|
||||
# (config, discord_rpc, music_db, player). Since the whole test suite already
|
||||
# covers those modules with real Qt, the gui.py fallback is implicitly tested
|
||||
# when the conftest.py stubs are active in a headless environment.
|
||||
# The player.py fallback test above covers the same import-fallback pattern
|
||||
# that gui.py uses.
|
||||
|
||||
class TestGuiQtFallback:
|
||||
"""When PySide6 is unavailable, gui sets _HAS_QT=False and uses fallback stubs."""
|
||||
|
||||
def _cleanup(self):
|
||||
"""Remove stale fallback modules so subsequent tests get the real ones."""
|
||||
for mod_name in ("gui", "player", "discord_rpc"):
|
||||
sys.modules.pop(mod_name, None)
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith("PySide6") and key not in _ORIGINAL_PYSIDE_MODS:
|
||||
del sys.modules[key]
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_gui_has_qt_is_false_without_pyside6(self):
|
||||
"""_HAS_QT should be False in gui when PySide6 can't be imported."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui._HAS_QT is False
|
||||
assert gui.QApplication is object
|
||||
assert gui.QWidget is object
|
||||
assert gui.QTimer is object
|
||||
assert gui.QVBoxLayout is object
|
||||
assert gui.QPushButton is object
|
||||
|
||||
def test_gui_qfont_is_none_without_qt(self):
|
||||
"""QFont fallback in gui should be None."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui.QFont is None
|
||||
|
||||
def test_gui_qurl_is_none_without_qt(self):
|
||||
"""QUrl fallback in gui should be None."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui.QUrl is None
|
||||
|
||||
def test_gui_oserror_triggers_fallback(self):
|
||||
"""OSError during PySide6 import triggers gui fallback."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise OSError("Library load error")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert gui._HAS_QT is False
|
||||
|
||||
def test_gui_spectrum_stops_no_qt_dependency(self):
|
||||
"""AudioVisualizer._SPECTRUM_COLOR_STOPS uses raw tuples, not QColor."""
|
||||
_clean_pyside_modules()
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _side_effect(name, *args, **kwargs):
|
||||
if name.startswith("PySide6") or name.startswith("pyside6"):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=_side_effect):
|
||||
import gui
|
||||
|
||||
assert hasattr(gui.AudioVisualizer, "_SPECTRUM_COLOR_STOPS")
|
||||
stops = gui.AudioVisualizer._SPECTRUM_COLOR_STOPS
|
||||
assert len(stops) == 9
|
||||
# Each stop is an (R, G, B) tuple
|
||||
for stop in stops:
|
||||
assert isinstance(stop, tuple)
|
||||
assert len(stop) == 3
|
||||
assert all(isinstance(v, int) for v in stop)
|
||||
Reference in New Issue
Block a user