"""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)