Files
Tunetti/tests/test_import_fallbacks.py
NikkeDoy 4671a6937b
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m52s
| Boost coverage to 60% with 256 passing tests
- mpris.py: 81% → 100% — import fallbacks, flush_props edge cases,
  stop_async error path, run_async exception path, notify loops
- player.py: 51% → 73% — AudioPlayer state/volume/loop/queue tests,
  _on_download_failed, _on_download_succeeded, _on_playback_state,
  _on_media_status, seek/skip/previous, _start_prefetch, _advance,
  _cancel_active_downloads, DownloadWorker._do_cancel
- test_gui_widgets.py: 50 → 88 tests across TestAudioPlayer and
  existing widget tests
- 5 modules at 100%: config, discord_rpc, main, mpris, music_db
2026-06-09 19:33:58 +03:00

331 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)