"""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") def test_stub_qmediaplayer_methods(self): """Stub QMediaPlayer methods can be called without error.""" _clean_pyside_modules() side_effect = _make_importer(block_pyside=True) with mock.patch("builtins.__import__", side_effect=side_effect): import player mp = player.QMediaPlayer() # Methods that should be no-ops mp.set_source("https://example.com/audio.mp3") mp.play() mp.pause() mp.stop() mp.set_position(30000) mp.set_audio_output(None) # Methods that return values assert mp.position() == 0 assert mp.duration() == 0 assert mp.get_playback_state() == 0 # stopped_state assert mp.get_audio_output() is None # ═══════════════════════════════════════════════════════════════════════════════ # 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)