Files
Tunetti/tests/test_gui_widgets.py
NikkeDoy 5ce18506c1
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m45s
🐛 | Skip widget tests when Qt is stubbed (headless CI)
2026-06-04 19:23:38 +03:00

244 lines
9.2 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). ``importorskip`` succeeds on the mock
# module, so we check the actual class type instead.
from PySide6.QtWidgets import QApplication, QWidget
if isinstance(QApplication, mock.MagicMock):
pytest.skip("Real Qt runtime not available (stubs active)",
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)