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:
45
gui.py
45
gui.py
@@ -607,16 +607,19 @@ class AudioVisualizer(QWidget):
|
||||
_TICK_MS = 16
|
||||
|
||||
# Spectral gradient colour stops (left → right / bass → treble)
|
||||
SPECTRUM_COLORS = [
|
||||
QColor(139, 92, 246), # 0.0 – Violet (bass)
|
||||
QColor(168, 85, 247), # 0.1 – Purple
|
||||
QColor(236, 72, 153), # 0.25 – Pink
|
||||
QColor(251, 146, 134), # 0.4 – Rose
|
||||
QColor(251, 207, 132), # 0.5 – Amber (centre)
|
||||
QColor(251, 207, 132), # 0.6 – Amber
|
||||
QColor(236, 72, 153), # 0.75 – Pink
|
||||
QColor(168, 85, 247), # 0.9 – Purple
|
||||
QColor(139, 92, 246), # 1.0 – Violet (bass mirror)
|
||||
# Stored as raw (R, G, B) tuples to avoid Qt dependency at class-definition
|
||||
# time — needed so the module can be imported for pure-helper-function tests
|
||||
# without a real PySide6 runtime.
|
||||
_SPECTRUM_COLOR_STOPS = [
|
||||
(139, 92, 246), # 0.0 – Violet (bass)
|
||||
(168, 85, 247), # 0.1 – Purple
|
||||
(236, 72, 153), # 0.25 – Pink
|
||||
(251, 146, 134), # 0.4 – Rose
|
||||
(251, 207, 132), # 0.5 – Amber (centre)
|
||||
(251, 207, 132), # 0.6 – Amber
|
||||
(236, 72, 153), # 0.75 – Pink
|
||||
(168, 85, 247), # 0.9 – Purple
|
||||
(139, 92, 246), # 1.0 – Violet (bass mirror)
|
||||
]
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -887,21 +890,23 @@ class AudioVisualizer(QWidget):
|
||||
|
||||
@staticmethod
|
||||
def _spectrum_color(t: float) -> QColor:
|
||||
cols = AudioVisualizer.SPECTRUM_COLORS
|
||||
"""Interpolate between colour stops and return a ``QColor``."""
|
||||
stops = AudioVisualizer._SPECTRUM_COLOR_STOPS
|
||||
if t <= 0.0:
|
||||
return cols[0]
|
||||
r, g, b = stops[0]
|
||||
return QColor(r, g, b)
|
||||
if t >= 1.0:
|
||||
return cols[-1]
|
||||
t_scaled = t * (len(cols) - 1)
|
||||
r, g, b = stops[-1]
|
||||
return QColor(r, g, b)
|
||||
t_scaled = t * (len(stops) - 1)
|
||||
idx = int(t_scaled)
|
||||
frac = t_scaled - idx
|
||||
c1 = cols[idx]
|
||||
c2 = cols[min(idx + 1, len(cols) - 1)]
|
||||
c1 = stops[idx]
|
||||
c2 = stops[min(idx + 1, len(stops) - 1)]
|
||||
return QColor(
|
||||
int(c1.red() + (c2.red() - c1.red()) * frac),
|
||||
int(c1.green() + (c2.green() - c1.green()) * frac),
|
||||
int(c1.blue() + (c2.blue() - c1.blue()) * frac),
|
||||
int(c1.alpha() + (c2.alpha() - c1.alpha()) * frac),
|
||||
int(c1[0] + (c2[0] - c1[0]) * frac),
|
||||
int(c1[1] + (c2[1] - c1[1]) * frac),
|
||||
int(c1[2] + (c2[2] - c1[2]) * frac),
|
||||
)
|
||||
|
||||
|
||||
|
||||
57
player.py
57
player.py
@@ -58,13 +58,62 @@ try:
|
||||
except (ImportError, OSError):
|
||||
_HAS_QT = False
|
||||
QObject = object
|
||||
QThread = type("QThread", (), {"start": lambda s: None,
|
||||
"quit": lambda s: None,
|
||||
"wait": lambda s, t=3000: True})
|
||||
|
||||
class _StubQThread:
|
||||
"""Stub replacement for PySide6.QtCore.QThread."""
|
||||
def start(self) -> None: pass
|
||||
def quit(self) -> None: pass
|
||||
def wait(self, timeout: int = 3000) -> bool: return True
|
||||
QThread = _StubQThread
|
||||
|
||||
Signal = lambda *a: lambda f: f
|
||||
Slot = lambda *a: lambda f: f
|
||||
QUrl = None
|
||||
QMediaPlayer = None
|
||||
|
||||
class _StubPlaybackState:
|
||||
StoppedState = 0
|
||||
PlayingState = 1
|
||||
PausedState = 2
|
||||
|
||||
class _StubMediaStatus:
|
||||
NoMedia = 0
|
||||
LoadingMedia = 1
|
||||
LoadedMedia = 2
|
||||
StalledMedia = 3
|
||||
BufferingMedia = 4
|
||||
BufferedMedia = 5
|
||||
EndOfMedia = 6
|
||||
InvalidMedia = 7
|
||||
|
||||
class _StubError:
|
||||
NoError = 0
|
||||
ResourceError = 1
|
||||
FormatError = 2
|
||||
NetworkError = 3
|
||||
AccessDeniedError = 4
|
||||
ServiceMissingError = 5
|
||||
|
||||
class _StubQMediaPlayer:
|
||||
PlaybackState = _StubPlaybackState
|
||||
MediaStatus = _StubMediaStatus
|
||||
Error = _StubError
|
||||
|
||||
def __init__(self):
|
||||
self._playback_state = _StubPlaybackState.StoppedState
|
||||
|
||||
def setSource(self, url) -> None: pass
|
||||
def play(self) -> None: pass
|
||||
def pause(self) -> None: pass
|
||||
def stop(self) -> None: pass
|
||||
def setPosition(self, ms: int) -> None: pass
|
||||
def position(self) -> int: return 0
|
||||
def duration(self) -> int: return 0
|
||||
def playbackState(self) -> int:
|
||||
return self._playback_state
|
||||
def audioOutput(self): return None
|
||||
def setAudioOutput(self, output) -> None: pass
|
||||
|
||||
QMediaPlayer = _StubQMediaPlayer
|
||||
QAudioOutput = None
|
||||
|
||||
log = logging.getLogger("tunetti.player")
|
||||
|
||||
@@ -176,12 +176,25 @@ def _install_stubs() -> None:
|
||||
|
||||
_QT_AVAILABLE = False
|
||||
try:
|
||||
# Attempt real import — this will fail in headless CI.
|
||||
# Step 1: Try to import PySide6 modules. These can succeed even in
|
||||
# headless CI because no display is needed to import the Python bindings.
|
||||
import PySide6 # noqa: F401
|
||||
import PySide6.QtCore # noqa: F401
|
||||
import PySide6.QtMultimedia # noqa: F401
|
||||
|
||||
# Step 2: Verify that a QApplication can actually be created.
|
||||
# In headless CI without QT_QPA_PLATFORM=offscreen this will raise
|
||||
# a RuntimeError ("Cannot create a QWidget without a QApplication")
|
||||
# or OSError ("Could not connect to display").
|
||||
from PySide6.QtWidgets import QApplication
|
||||
_existing_app = QApplication.instance()
|
||||
if _existing_app is None:
|
||||
_test_app = QApplication([])
|
||||
_test_app.quit()
|
||||
del _test_app
|
||||
|
||||
_QT_AVAILABLE = True
|
||||
except (ImportError, OSError):
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
if not _QT_AVAILABLE:
|
||||
|
||||
@@ -257,6 +257,19 @@ class TestDiscordRPC:
|
||||
assert result is False
|
||||
assert drpc._connected is False
|
||||
|
||||
def test_connect_discord_not_found_direct(self):
|
||||
"""_connect handles DiscordNotFound when called directly (not through thread)."""
|
||||
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||
from discord_rpc import DiscordRPC, DiscordNotFound
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.connect.side_effect = DiscordNotFound()
|
||||
mock_presence_cls.return_value = mock_instance
|
||||
|
||||
drpc = DiscordRPC(client_id="test")
|
||||
result = drpc._connect()
|
||||
assert result is False
|
||||
assert drpc._connected is False
|
||||
|
||||
def test_run_reconnects_on_failure(self):
|
||||
"""_run loop retries connection when _connect fails (covers lines 174-175)."""
|
||||
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||
|
||||
@@ -158,3 +158,68 @@ class TestArtistsFromJson:
|
||||
fn = self._import()
|
||||
j = json.dumps([{"name": "Real"}, 42, "str", {"name": "Also Real"}])
|
||||
assert fn(j) == "Real, Also Real"
|
||||
|
||||
|
||||
# ── _css ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCss:
|
||||
"""Tests for the _css helper."""
|
||||
|
||||
def _import(self):
|
||||
from gui import _css
|
||||
return _css
|
||||
|
||||
def test_joins_multiple_parts(self):
|
||||
fn = self._import()
|
||||
assert fn("color: red;", "background: blue;") == "color: red;background: blue;"
|
||||
|
||||
def test_single_part(self):
|
||||
fn = self._import()
|
||||
assert fn("color: red;") == "color: red;"
|
||||
|
||||
def test_empty_parts(self):
|
||||
fn = self._import()
|
||||
assert fn() == ""
|
||||
|
||||
|
||||
# ── _get_thumbnail_nam ────────────────────────────────────────────────────
|
||||
|
||||
class TestGetThumbnailNam:
|
||||
"""Tests for the _get_thumbnail_nam helper."""
|
||||
|
||||
def _import(self):
|
||||
from gui import _get_thumbnail_nam
|
||||
return _get_thumbnail_nam
|
||||
|
||||
def test_returns_network_access_manager(self):
|
||||
fn = self._import()
|
||||
nam = fn()
|
||||
# Should return some kind of object (QNetworkAccessManager or MagicMock)
|
||||
assert nam is not None
|
||||
|
||||
def test_is_singleton(self):
|
||||
fn = self._import()
|
||||
nam1 = fn()
|
||||
nam2 = fn()
|
||||
assert nam1 is nam2
|
||||
|
||||
|
||||
# ── _cached_thumb_path ────────────────────────────────────────────────────
|
||||
|
||||
class TestCachedThumbPath:
|
||||
"""Tests for the _cached_thumb_path helper."""
|
||||
|
||||
def _import(self):
|
||||
from gui import _cached_thumb_path, THUMB_CACHE_DIR
|
||||
return _cached_thumb_path, THUMB_CACHE_DIR
|
||||
|
||||
def test_uses_cache_dir(self):
|
||||
fn, cache_dir = self._import()
|
||||
path = fn("video123")
|
||||
assert str(path) == str(cache_dir / "video123.jpg")
|
||||
|
||||
def test_ends_with_jpg(self):
|
||||
fn, _ = self._import()
|
||||
path = fn("abc_def")
|
||||
assert path.suffix == ".jpg"
|
||||
assert "abc_def" in path.name
|
||||
|
||||
@@ -14,12 +14,26 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
# The conftest stubs PySide6.QtWidgets as a MagicMock when Qt is
|
||||
# unavailable (headless CI). ``importorskip`` succeeds on the mock
|
||||
# module, so we check the actual class type instead.
|
||||
# unavailable (headless CI). We check whether we have a real Qt runtime
|
||||
# by verifying QApplication is not a MagicMock and that it can be created.
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
if isinstance(QApplication, mock.MagicMock):
|
||||
pytest.skip("Real Qt runtime not available (stubs active)",
|
||||
def _qt_available() -> bool:
|
||||
"""Return True if a real QApplication can be created (display available)."""
|
||||
if isinstance(QApplication, mock.MagicMock):
|
||||
return False
|
||||
inst = QApplication.instance()
|
||||
if inst is not None:
|
||||
return True
|
||||
try:
|
||||
app = QApplication([])
|
||||
app.quit()
|
||||
return True
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
|
||||
if not _qt_available():
|
||||
pytest.skip("Real Qt runtime not available (stubs active / headless)",
|
||||
allow_module_level=True)
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
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)
|
||||
354
tests/test_main.py
Normal file
354
tests/test_main.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""Tests for the main entry point (main.py).
|
||||
|
||||
Strategy
|
||||
--------
|
||||
- Module-level constants (``VERBOSE``, ``_LOG_LEVEL``, ``_LOG_FORMAT``,
|
||||
``_QT_LOG_RULES``) are tested by clearing/modifying ``os.environ`` and
|
||||
then calling ``importlib.reload(main)`` so the top-level code re-evaluates.
|
||||
- The ``main()`` function is tested by mocking ``gui.run_gui`` and
|
||||
``main.VERBOSE`` / ``main._QT_LOG_RULES`` so we never need a real Qt runtime.
|
||||
- The ``if __name__ == "__main__":`` block is tested via AST extraction so we
|
||||
can execute only that ``if``-block (not all module-level code) with a mocked
|
||||
``main``.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _reload_main():
|
||||
"""Re-import the ``main`` module with a fresh module object.
|
||||
|
||||
``importlib.reload`` re-executes the module-level code but preserves
|
||||
the existing module object (and its identity in ``sys.modules``).
|
||||
We remove it entirely first so that even the import machinery runs
|
||||
from scratch — every time.
|
||||
|
||||
This avoids stale state leaking across tests (e.g. the ``logging``
|
||||
handler check inside ``basicConfig``, which is a no-op on subsequent
|
||||
calls).
|
||||
"""
|
||||
sys.modules.pop("main", None)
|
||||
import main as m # noqa: F811
|
||||
return m
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for the ``VERBOSE`` / ``TUNETTI_VERBOSE`` env-var
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVerboseEnvVar:
|
||||
"""Module-level ``VERBOSE`` flag is driven by ``TUNETTI_VERBOSE``."""
|
||||
|
||||
# ── absent / empty ────────────────────────────────────────────────────
|
||||
|
||||
def test_verbose_disabled_when_unset(self):
|
||||
"""``TUNETTI_VERBOSE`` absent → ``main.VERBOSE is False``."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is False
|
||||
|
||||
def test_verbose_disabled_when_empty(self):
|
||||
"""``TUNETTI_VERBOSE=""`` → ``main.VERBOSE is False``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": ""}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is False
|
||||
|
||||
# ── truthy ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_verbose_enabled_with_1(self):
|
||||
"""``TUNETTI_VERBOSE=1`` → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
def test_verbose_enabled_with_true(self):
|
||||
"""``TUNETTI_VERBOSE=true`` → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "true"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
def test_verbose_enabled_with_yes(self):
|
||||
"""``TUNETTI_VERBOSE=yes`` → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "yes"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
def test_verbose_strips_whitespace(self):
|
||||
"""``TUNETTI_VERBOSE=" 1 "`` (whitespace) → ``main.VERBOSE is True``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": " 1 "}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is True
|
||||
|
||||
# ── falsy ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("val", ["0", "false", "no"])
|
||||
def test_verbose_falsy_values(self, val):
|
||||
"""``TUNETTI_VERBOSE={val}`` → ``main.VERBOSE is False``."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": val}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.VERBOSE is False, f"expected False for {val!r}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for derived module-level constants
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestModuleConstants:
|
||||
"""Constants derived from ``VERBOSE`` (``_LOG_LEVEL``, ``_LOG_FORMAT``,
|
||||
``_QT_LOG_RULES``)."""
|
||||
|
||||
# ── _LOG_LEVEL ───────────────────────────────────────────────────────
|
||||
|
||||
def test_log_level_info_by_default(self):
|
||||
"""``_LOG_LEVEL`` is ``logging.INFO`` when not verbose."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod._LOG_LEVEL is logging.INFO
|
||||
|
||||
def test_log_level_debug_when_verbose(self):
|
||||
"""``_LOG_LEVEL`` is ``logging.DEBUG`` when verbose."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod._LOG_LEVEL is logging.DEBUG
|
||||
|
||||
# ── _LOG_FORMAT ──────────────────────────────────────────────────────
|
||||
|
||||
def test_log_format_includes_timestamp_in_verbose(self):
|
||||
"""``_LOG_FORMAT`` contains ``%%(asctime)s`` in verbose mode."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert "%(asctime)s" in main_mod._LOG_FORMAT
|
||||
|
||||
def test_log_format_omits_timestamp_in_normal(self):
|
||||
"""``_LOG_FORMAT`` does **not** contain ``%%(asctime)s`` normally."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert "%(asctime)s" not in main_mod._LOG_FORMAT
|
||||
|
||||
def test_log_format_differs_by_verbosity(self):
|
||||
"""The two log-format strings are different (checks they actually vary)."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
normal_mod = _reload_main()
|
||||
normal_fmt = normal_mod._LOG_FORMAT
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
verbose_mod = _reload_main()
|
||||
assert verbose_mod._LOG_FORMAT != normal_fmt
|
||||
|
||||
# ── _QT_LOG_RULES ────────────────────────────────────────────────────
|
||||
|
||||
def test_qt_rules_suppressed_by_default(self):
|
||||
"""``_QT_LOG_RULES`` contains FFmpeg filter rules in normal mode."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert "qt.multimedia.ffmpeg" in main_mod._QT_LOG_RULES
|
||||
|
||||
def test_qt_rules_empty_when_verbose(self):
|
||||
"""``_QT_LOG_RULES`` is an empty string in verbose mode."""
|
||||
with mock.patch.dict("os.environ", {"TUNETTI_VERBOSE": "1"}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
assert main_mod._QT_LOG_RULES == ""
|
||||
|
||||
def test_qt_rules_multiline(self):
|
||||
"""``_QT_LOG_RULES`` spans multiple lines (one rule per line)."""
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
main_mod = _reload_main()
|
||||
lines = [ln for ln in main_mod._QT_LOG_RULES.splitlines() if ln.strip()]
|
||||
assert len(lines) >= 4
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for the ``main()`` function
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMainFunction:
|
||||
"""``main()`` function behaviour in normal and verbose modes."""
|
||||
|
||||
# ── Normal mode ─────────────────────────────────────────────────────
|
||||
|
||||
def test_passes_qt_rules_to_run_gui(self):
|
||||
"""``main()`` forwards ``_QT_LOG_RULES`` to ``run_gui`` normally."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", "qt.foo=false\nqt.bar=false"),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(
|
||||
extra_qt_log_rules="qt.foo=false\nqt.bar=false",
|
||||
)
|
||||
|
||||
def test_does_not_touch_player_verbose_in_normal_mode(self):
|
||||
"""Normal mode should **not** set ``player.VERBOSE``."""
|
||||
main_mod = _reload_main()
|
||||
player_mock = mock.MagicMock()
|
||||
player_mock.VERBOSE = False
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch.dict("sys.modules", {"player": player_mock}),
|
||||
):
|
||||
main_mod.main()
|
||||
assert player_mock.VERBOSE is False
|
||||
|
||||
def test_does_not_log_debug_in_normal_mode(self):
|
||||
"""Normal mode should **not** emit a debug log message."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch("logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock.MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
main_mod.main()
|
||||
mock_logger.debug.assert_not_called()
|
||||
|
||||
# ── Verbose mode ────────────────────────────────────────────────────
|
||||
|
||||
def test_passes_empty_rules_in_verbose_mode(self):
|
||||
"""``main()`` passes empty ``_QT_LOG_RULES`` to ``run_gui`` when verbose."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", True),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(extra_qt_log_rules="")
|
||||
|
||||
def test_sets_player_verbose_in_verbose_mode(self):
|
||||
"""Verbose mode sets ``player.VERBOSE = True``."""
|
||||
main_mod = _reload_main()
|
||||
player_mock = mock.MagicMock()
|
||||
player_mock.VERBOSE = False
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", True),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch.dict("sys.modules", {"player": player_mock}),
|
||||
):
|
||||
main_mod.main()
|
||||
assert player_mock.VERBOSE is True
|
||||
|
||||
def test_emits_debug_log_in_verbose_mode(self):
|
||||
"""Verbose mode logs a debug message about verbose mode being active."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", True),
|
||||
mock.patch("gui.run_gui"),
|
||||
mock.patch("logging.getLogger") as mock_get_logger,
|
||||
):
|
||||
mock_logger = mock.MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
main_mod.main()
|
||||
mock_logger.debug.assert_called_once()
|
||||
(msg,) = mock_logger.debug.call_args[0]
|
||||
assert "Verbose mode enabled" in msg
|
||||
assert "yt-dlp" in msg
|
||||
assert "FFmpeg" in msg
|
||||
|
||||
# ── Edge cases ──────────────────────────────────────────────────────
|
||||
|
||||
def test_handles_empty_qt_rules_gracefully(self):
|
||||
"""``run_gui`` is called even when ``_QT_LOG_RULES`` is empty."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(extra_qt_log_rules="")
|
||||
|
||||
def test_handles_very_large_qt_rules(self):
|
||||
"""``main()`` passes a large multi-line rules string without issues."""
|
||||
main_mod = _reload_main()
|
||||
large_rules = "\n".join(f"qt.category{i}=false" for i in range(50))
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", large_rules),
|
||||
mock.patch("gui.run_gui") as mock_run_gui,
|
||||
):
|
||||
main_mod.main()
|
||||
mock_run_gui.assert_called_once_with(extra_qt_log_rules=large_rules)
|
||||
|
||||
def test_run_gui_exception_propagates(self):
|
||||
"""If ``run_gui`` raises, ``main()`` lets the exception propagate."""
|
||||
main_mod = _reload_main()
|
||||
with (
|
||||
mock.patch.object(main_mod, "VERBOSE", False),
|
||||
mock.patch.object(main_mod, "_QT_LOG_RULES", ""),
|
||||
mock.patch("gui.run_gui", side_effect=RuntimeError("gui failed")),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="gui failed"):
|
||||
main_mod.main()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Tests for the ``if __name__ == "__main__"`` block
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMainBlock:
|
||||
"""``if __name__ == "__main__":`` entry point guard."""
|
||||
|
||||
def test_name_is_main_when_imported(self):
|
||||
"""Module ``__name__`` is ``"main"`` (not ``"__main__"``) on import."""
|
||||
main_mod = _reload_main()
|
||||
assert main_mod.__name__ == "main"
|
||||
|
||||
def test_block_not_executed_on_import(self):
|
||||
"""The ``__main__`` block does **not** execute when imported normally.
|
||||
|
||||
We verify this by patching ``main()`` to raise an exception — if the
|
||||
block ran, the exception would be raised.
|
||||
"""
|
||||
main_mod = _reload_main()
|
||||
with mock.patch.object(
|
||||
main_mod, "main", side_effect=RuntimeError("main() was called!"),
|
||||
):
|
||||
# A plain re-import should not trigger the error because __name__
|
||||
# is "main", not "__main__".
|
||||
importlib.reload(main_mod)
|
||||
|
||||
def test_source_contains_main_guard(self):
|
||||
"""The source code contains the ``if __name__ == "__main__":`` guard.
|
||||
|
||||
This verifies the guard exists without relying on AST compilation
|
||||
(which varies across Python versions). The guard is a Python
|
||||
language feature — we just need to confirm it's present.
|
||||
"""
|
||||
main_mod = _reload_main()
|
||||
source = Path(main_mod.__file__).read_text()
|
||||
assert 'if __name__ == "__main__":' in source, (
|
||||
"Missing ``if __name__ == '__main__':`` guard. "
|
||||
"This guard must be present so ``main()`` is only called "
|
||||
"when the module is executed as a script."
|
||||
)
|
||||
assert "main()" in source.split('if __name__ == "__main__":')[1]
|
||||
|
||||
def test_main_block_executes_when_run_as_script(self):
|
||||
"""When ``__name__ == '__main__'``, the guard calls ``main()``.
|
||||
|
||||
We re-execute the module source with ``__name__`` set to
|
||||
``'__main__'``, mocking ``gui.run_gui`` so the call does
|
||||
nothing. This covers line 62 (the actual ``main()`` call)
|
||||
for the coverage report.
|
||||
"""
|
||||
main_mod = _reload_main()
|
||||
with mock.patch("gui.run_gui") as mock_run_gui:
|
||||
file_path = Path(main_mod.__file__).resolve()
|
||||
code = compile(file_path.read_text(), str(file_path), "exec")
|
||||
ns = {"__name__": "__main__", "__file__": str(file_path)}
|
||||
exec(code, ns)
|
||||
mock_run_gui.assert_called_once()
|
||||
@@ -340,3 +340,50 @@ class TestTmpDir:
|
||||
"""Calling _tmp_dir multiple times returns the same path."""
|
||||
fn = self._import()
|
||||
assert fn() == fn()
|
||||
|
||||
|
||||
# ── SearchWorker (TEST_MODE) ─────────────────────────────────────────────────
|
||||
|
||||
class TestSearchWorkerTestMode:
|
||||
"""Tests for SearchWorker.run() with TEST_MODE enabled."""
|
||||
|
||||
def test_returns_dummy_results_in_test_mode(self, monkeypatch):
|
||||
"""With TEST_MODE=True, SearchWorker returns 5 dummy songs."""
|
||||
monkeypatch.setattr("player.TEST_MODE", True)
|
||||
from player import SearchWorker
|
||||
|
||||
results = []
|
||||
worker = SearchWorker("test query")
|
||||
worker.results_ready.connect(results.append)
|
||||
worker.run()
|
||||
|
||||
assert len(results) == 1
|
||||
data = results[0]
|
||||
assert "songs" in data
|
||||
assert "videos" in data
|
||||
assert len(data["songs"]) == 5
|
||||
assert data["videos"] == []
|
||||
assert data["songs"][0]["title"] == "Test Song 0"
|
||||
|
||||
def test_dummy_results_have_required_keys(self, monkeypatch):
|
||||
"""Each dummy song dict has all expected keys."""
|
||||
monkeypatch.setattr("player.TEST_MODE", True)
|
||||
from player import SearchWorker
|
||||
|
||||
results = []
|
||||
worker = SearchWorker("test query")
|
||||
worker.results_ready.connect(lambda r: results.append(r))
|
||||
worker.run()
|
||||
|
||||
song = results[0]["songs"][0]
|
||||
assert "videoId" in song
|
||||
assert "title" in song
|
||||
assert "artists" in song
|
||||
assert len(song["artists"]) == 1
|
||||
assert song["artists"][0]["name"] == "Artist 0"
|
||||
|
||||
def test_sets_query_correctly(self):
|
||||
"""SearchWorker stores the query."""
|
||||
from player import SearchWorker
|
||||
worker = SearchWorker("my search query")
|
||||
assert worker._query == "my search query"
|
||||
|
||||
Reference in New Issue
Block a user