All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m41s
- Add docstrings to all empty Qt stub methods (S1186) - Fix BLOCKER S1845: rename playbackState to get_playback_state - Rename enum fields to snake_case with CamelCase aliases (S116) - Rename stub methods to snake_case (set_source, set_position, etc.) (S100) - Rename unused timeout param to msecs (S1172) - Remove unused state variables in tests (S1481) - Replace 'is' with '==' for enum comparisons (S5795) - Remove unnecessary list() calls (S7504) - Rename QTh to qt_cls (S117) - Remove commented-out code blocks (S125) - Add docstring to FallbackServiceInterface.emit_properties_changed (S1186)
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""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 sys.modules:
|
||
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 sys.modules:
|
||
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
|
||
qt_cls = player.QThread
|
||
inst = qt_cls()
|
||
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 sys.modules:
|
||
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)
|