From 6530911c28369b89d8274b090ab2f5dd9bbe632a Mon Sep 17 00:00:00 2001 From: NikkeDoy Date: Thu, 4 Jun 2026 19:08:48 +0300 Subject: [PATCH] :white_check_mark: | Add widget integration tests for SonarQube coverage --- tests/test_gui_widgets.py | 226 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/test_gui_widgets.py diff --git a/tests/test_gui_widgets.py b/tests/test_gui_widgets.py new file mode 100644 index 0000000..de053ca --- /dev/null +++ b/tests/test_gui_widgets.py @@ -0,0 +1,226 @@ +"""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. +""" + +from unittest import mock + +import pytest + +pytest.importorskip("PySide6.QtWidgets") + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication, QPushButton + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Fixtures +# ═══════════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture(scope="module") +def app(): + """Create (or reuse) the singleton QApplication.""" + inst = QApplication.instance() + if inst is None: + inst = QApplication([]) + yield inst + + +@pytest.fixture +def player_mock(): + return mock.MagicMock(name="player") + + +@pytest.fixture +def visualizer_mock(): + return mock.MagicMock(name="visualizer") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# AudioVisualizer +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestAudioVisualizer: + """Tests for AudioVisualizer new methods.""" + + 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 + + def test_show_and_hide(self, app): + """Visualizer can be shown and hidden without error.""" + from gui import AudioVisualizer + + viz = AudioVisualizer() + viz.show() + viz.hide() + assert not viz.isVisible() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# PlaybackBar +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestPlaybackBar: + """Tests for PlaybackBar new/modified methods.""" + + 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 + + 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 + + # Create and start a timer so we can verify it gets stopped + bar._vol_save_timer = QTimer() + bar._vol_save_timer.setSingleShot(True) + bar._vol_save_timer.start(5000) # long interval so it stays active + 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() + + 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) + # In PlaybackBar.__init__, _vol_save_timer is created lazily. + # Delete it to test the graceful fallback. + 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) + + 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") + # The error text should be set + assert "Test error" in bar._np_title.text() + # _clear_error should be scheduled (not called immediately) + mock_clear.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SearchPage +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestSearchPage: + """Tests for SearchPage new methods.""" + + def test_on_show_more_clicked_from_button(self, app): + """_on_show_more_clicked expands the correct category via button click.""" + from gui import SearchPage + + page = SearchPage() + page._videos_expanded = False + + # Create a button and set it up like _add_show_more does + 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") + + 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") + + 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 + + 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 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# TunettiWindow accessors +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestTunettiWindowAccessors: + """Tests for the public accessor methods on TunettiWindow.""" + + @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() + + 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)