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
|
_TICK_MS = 16
|
||||||
|
|
||||||
# Spectral gradient colour stops (left → right / bass → treble)
|
# Spectral gradient colour stops (left → right / bass → treble)
|
||||||
SPECTRUM_COLORS = [
|
# Stored as raw (R, G, B) tuples to avoid Qt dependency at class-definition
|
||||||
QColor(139, 92, 246), # 0.0 – Violet (bass)
|
# time — needed so the module can be imported for pure-helper-function tests
|
||||||
QColor(168, 85, 247), # 0.1 – Purple
|
# without a real PySide6 runtime.
|
||||||
QColor(236, 72, 153), # 0.25 – Pink
|
_SPECTRUM_COLOR_STOPS = [
|
||||||
QColor(251, 146, 134), # 0.4 – Rose
|
(139, 92, 246), # 0.0 – Violet (bass)
|
||||||
QColor(251, 207, 132), # 0.5 – Amber (centre)
|
(168, 85, 247), # 0.1 – Purple
|
||||||
QColor(251, 207, 132), # 0.6 – Amber
|
(236, 72, 153), # 0.25 – Pink
|
||||||
QColor(236, 72, 153), # 0.75 – Pink
|
(251, 146, 134), # 0.4 – Rose
|
||||||
QColor(168, 85, 247), # 0.9 – Purple
|
(251, 207, 132), # 0.5 – Amber (centre)
|
||||||
QColor(139, 92, 246), # 1.0 – Violet (bass mirror)
|
(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):
|
def __init__(self, parent=None):
|
||||||
@@ -887,21 +890,23 @@ class AudioVisualizer(QWidget):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _spectrum_color(t: float) -> QColor:
|
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:
|
if t <= 0.0:
|
||||||
return cols[0]
|
r, g, b = stops[0]
|
||||||
|
return QColor(r, g, b)
|
||||||
if t >= 1.0:
|
if t >= 1.0:
|
||||||
return cols[-1]
|
r, g, b = stops[-1]
|
||||||
t_scaled = t * (len(cols) - 1)
|
return QColor(r, g, b)
|
||||||
|
t_scaled = t * (len(stops) - 1)
|
||||||
idx = int(t_scaled)
|
idx = int(t_scaled)
|
||||||
frac = t_scaled - idx
|
frac = t_scaled - idx
|
||||||
c1 = cols[idx]
|
c1 = stops[idx]
|
||||||
c2 = cols[min(idx + 1, len(cols) - 1)]
|
c2 = stops[min(idx + 1, len(stops) - 1)]
|
||||||
return QColor(
|
return QColor(
|
||||||
int(c1.red() + (c2.red() - c1.red()) * frac),
|
int(c1[0] + (c2[0] - c1[0]) * frac),
|
||||||
int(c1.green() + (c2.green() - c1.green()) * frac),
|
int(c1[1] + (c2[1] - c1[1]) * frac),
|
||||||
int(c1.blue() + (c2.blue() - c1.blue()) * frac),
|
int(c1[2] + (c2[2] - c1[2]) * frac),
|
||||||
int(c1.alpha() + (c2.alpha() - c1.alpha()) * frac),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
player.py
57
player.py
@@ -58,13 +58,62 @@ try:
|
|||||||
except (ImportError, OSError):
|
except (ImportError, OSError):
|
||||||
_HAS_QT = False
|
_HAS_QT = False
|
||||||
QObject = object
|
QObject = object
|
||||||
QThread = type("QThread", (), {"start": lambda s: None,
|
|
||||||
"quit": lambda s: None,
|
class _StubQThread:
|
||||||
"wait": lambda s, t=3000: True})
|
"""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
|
Signal = lambda *a: lambda f: f
|
||||||
Slot = lambda *a: lambda f: f
|
Slot = lambda *a: lambda f: f
|
||||||
QUrl = None
|
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
|
QAudioOutput = None
|
||||||
|
|
||||||
log = logging.getLogger("tunetti.player")
|
log = logging.getLogger("tunetti.player")
|
||||||
|
|||||||
@@ -176,12 +176,25 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
_QT_AVAILABLE = False
|
_QT_AVAILABLE = False
|
||||||
try:
|
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 # noqa: F401
|
||||||
import PySide6.QtCore # noqa: F401
|
import PySide6.QtCore # noqa: F401
|
||||||
import PySide6.QtMultimedia # 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
|
_QT_AVAILABLE = True
|
||||||
except (ImportError, OSError):
|
except (ImportError, OSError, RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not _QT_AVAILABLE:
|
if not _QT_AVAILABLE:
|
||||||
|
|||||||
@@ -257,6 +257,19 @@ class TestDiscordRPC:
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert drpc._connected 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):
|
def test_run_reconnects_on_failure(self):
|
||||||
"""_run loop retries connection when _connect fails (covers lines 174-175)."""
|
"""_run loop retries connection when _connect fails (covers lines 174-175)."""
|
||||||
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||||
|
|||||||
@@ -158,3 +158,68 @@ class TestArtistsFromJson:
|
|||||||
fn = self._import()
|
fn = self._import()
|
||||||
j = json.dumps([{"name": "Real"}, 42, "str", {"name": "Also Real"}])
|
j = json.dumps([{"name": "Real"}, 42, "str", {"name": "Also Real"}])
|
||||||
assert fn(j) == "Real, 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
|
import pytest
|
||||||
|
|
||||||
# The conftest stubs PySide6.QtWidgets as a MagicMock when Qt is
|
# The conftest stubs PySide6.QtWidgets as a MagicMock when Qt is
|
||||||
# unavailable (headless CI). ``importorskip`` succeeds on the mock
|
# unavailable (headless CI). We check whether we have a real Qt runtime
|
||||||
# module, so we check the actual class type instead.
|
# by verifying QApplication is not a MagicMock and that it can be created.
|
||||||
from PySide6.QtWidgets import QApplication, QWidget
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
def _qt_available() -> bool:
|
||||||
|
"""Return True if a real QApplication can be created (display available)."""
|
||||||
if isinstance(QApplication, mock.MagicMock):
|
if isinstance(QApplication, mock.MagicMock):
|
||||||
pytest.skip("Real Qt runtime not available (stubs active)",
|
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)
|
allow_module_level=True)
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer
|
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."""
|
"""Calling _tmp_dir multiple times returns the same path."""
|
||||||
fn = self._import()
|
fn = self._import()
|
||||||
assert fn() == fn()
|
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