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
258 lines
9.6 KiB
Python
258 lines
9.6 KiB
Python
"""Integration tests for GUI widget methods (requires real Qt runtime).
|
|
|
|
These tests create actual QWidget instances and test the new/modified
|
|
methods that were added during the recent code quality refactoring.
|
|
|
|
When PySide6 cannot connect to a display server (e.g. headless CI)
|
|
the conftest.py stubs out all Qt types with MagicMock instances — in that
|
|
environment widget instantiation returns MagicMock objects that do not
|
|
execute the real ``__init__`` body, so these tests are skipped.
|
|
"""
|
|
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
# The conftest stubs PySide6.QtWidgets as a MagicMock when Qt is
|
|
# 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
|
|
|
|
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
|
|
from PySide6.QtWidgets import QPushButton
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _cleanup(widget: QWidget, app: QApplication) -> None:
|
|
"""Safely delete a widget and process pending events."""
|
|
widget.deleteLater()
|
|
app.processEvents()
|
|
app.processEvents()
|
|
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app():
|
|
"""Reuse the singleton QApplication."""
|
|
inst = QApplication.instance()
|
|
if inst is None:
|
|
inst = QApplication([])
|
|
return inst
|
|
|
|
|
|
@pytest.fixture
|
|
def player_mock():
|
|
return mock.MagicMock(name="player")
|
|
|
|
|
|
@pytest.fixture
|
|
def visualizer_mock():
|
|
return mock.MagicMock(name="visualizer")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# AudioVisualizer
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestAudioVisualizer:
|
|
|
|
def test_reset_activity(self, app):
|
|
"""reset_activity sets _last_activity to 0."""
|
|
from gui import AudioVisualizer
|
|
|
|
viz = AudioVisualizer()
|
|
viz._last_activity = 999
|
|
viz.reset_activity()
|
|
assert viz._last_activity == 0
|
|
_cleanup(viz, app)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# PlaybackBar
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestPlaybackBar:
|
|
|
|
def test_clear_seek_flag(self, app, player_mock, visualizer_mock):
|
|
"""_clear_seek_flag sets _updating_progress to False."""
|
|
from gui import PlaybackBar
|
|
|
|
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
|
bar._updating_progress = True
|
|
bar._clear_seek_flag()
|
|
assert bar._updating_progress is False
|
|
_cleanup(bar, app)
|
|
|
|
def test_flush_volume_stops_timer_and_saves(self, app, player_mock,
|
|
visualizer_mock):
|
|
"""flush_volume persists the volume and stops the debounce timer."""
|
|
from gui import PlaybackBar
|
|
|
|
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
|
bar._saved_volume = 42
|
|
|
|
bar._vol_save_timer = QTimer()
|
|
bar._vol_save_timer.setSingleShot(True)
|
|
bar._vol_save_timer.start(5000)
|
|
assert bar._vol_save_timer.isActive()
|
|
|
|
with mock.patch("gui.save_volume") as mock_save:
|
|
bar.flush_volume()
|
|
mock_save.assert_called_once_with(42)
|
|
|
|
assert not bar._vol_save_timer.isActive()
|
|
_cleanup(bar, app)
|
|
|
|
def test_flush_volume_no_timer(self, app, player_mock, visualizer_mock):
|
|
"""flush_volume does not crash when _vol_save_timer doesn't exist."""
|
|
from gui import PlaybackBar
|
|
|
|
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
|
if hasattr(bar, "_vol_save_timer"):
|
|
del bar._vol_save_timer
|
|
|
|
bar._saved_volume = 75
|
|
with mock.patch("gui.save_volume") as mock_save:
|
|
bar.flush_volume()
|
|
mock_save.assert_called_once_with(75)
|
|
_cleanup(bar, app)
|
|
|
|
def test_show_error_uses_method_ref(self, app, player_mock,
|
|
visualizer_mock):
|
|
"""show_error schedules _clear_error via method ref, not lambda."""
|
|
from gui import PlaybackBar
|
|
|
|
bar = PlaybackBar(player=player_mock, visualizer=visualizer_mock)
|
|
with mock.patch.object(bar, "_clear_error") as mock_clear:
|
|
bar.show_error("Test error")
|
|
assert "Test error" in bar._np_title.text()
|
|
mock_clear.assert_not_called()
|
|
_cleanup(bar, app)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# SearchPage
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestSearchPage:
|
|
|
|
def test_on_show_more_clicked_from_button(self, app):
|
|
"""_on_show_more_clicked expands the correct category."""
|
|
from gui import SearchPage
|
|
|
|
page = SearchPage()
|
|
page._videos_expanded = False
|
|
|
|
btn = QPushButton("Show more")
|
|
btn.setProperty("category", "videos")
|
|
btn.clicked.connect(page._on_show_more_clicked)
|
|
|
|
with mock.patch.object(page, "_toggle_expand") as mock_toggle:
|
|
btn.click()
|
|
mock_toggle.assert_called_once_with("videos")
|
|
|
|
_cleanup(btn, app)
|
|
_cleanup(page, app)
|
|
|
|
def test_on_show_more_clicked_songs(self, app):
|
|
"""_on_show_more_clicked expands songs category."""
|
|
from gui import SearchPage
|
|
|
|
page = SearchPage()
|
|
page._songs_expanded = False
|
|
|
|
btn = QPushButton("Show more")
|
|
btn.setProperty("category", "songs")
|
|
btn.clicked.connect(page._on_show_more_clicked)
|
|
|
|
with mock.patch.object(page, "_toggle_expand") as mock_toggle:
|
|
btn.click()
|
|
mock_toggle.assert_called_once_with("songs")
|
|
|
|
_cleanup(btn, app)
|
|
_cleanup(page, app)
|
|
|
|
def test_toggle_expand_videos(self, app):
|
|
"""_toggle_expand sets videos expanded flag."""
|
|
from gui import SearchPage
|
|
|
|
page = SearchPage()
|
|
page._videos_expanded = False
|
|
|
|
with mock.patch.object(page, "_rebuild"):
|
|
page._toggle_expand("videos")
|
|
assert page._videos_expanded is True
|
|
_cleanup(page, app)
|
|
|
|
def test_toggle_expand_songs(self, app):
|
|
"""_toggle_expand sets songs expanded flag."""
|
|
from gui import SearchPage
|
|
|
|
page = SearchPage()
|
|
page._songs_expanded = False
|
|
|
|
with mock.patch.object(page, "_rebuild"):
|
|
page._toggle_expand("songs")
|
|
assert page._songs_expanded is True
|
|
_cleanup(page, app)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# TunettiWindow accessors
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestTunettiWindowAccessors:
|
|
|
|
@pytest.fixture
|
|
def window(self, app):
|
|
"""Create and properly clean up a TunettiWindow."""
|
|
from gui import TunettiWindow
|
|
win = TunettiWindow()
|
|
yield win
|
|
win.player.shutdown()
|
|
win.rpc.stop()
|
|
win.db.close()
|
|
win.close()
|
|
_cleanup(win, app)
|
|
|
|
def test_get_visualizer(self, window):
|
|
"""get_visualizer returns the AudioVisualizer instance."""
|
|
from gui import AudioVisualizer
|
|
assert isinstance(window.get_visualizer(), AudioVisualizer)
|
|
|
|
def test_get_rpc(self, window):
|
|
"""get_rpc returns the DiscordRPC instance."""
|
|
from discord_rpc import DiscordRPC
|
|
assert isinstance(window.get_rpc(), DiscordRPC)
|
|
|
|
def test_get_player(self, window):
|
|
"""get_player returns the AudioPlayer instance."""
|
|
from player import AudioPlayer
|
|
assert isinstance(window.get_player(), AudioPlayer)
|