test: 100% coverage on core modules with headless CI support
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:
2026-06-04 20:53:00 +03:00
parent 5ce18506c1
commit dc3c735655
9 changed files with 897 additions and 30 deletions

View 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)