"""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") 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) 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) 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 TestAudioPlayer: """Tests for AudioPlayer state methods (no playback required).""" @pytest.fixture def player(self, app): from player import AudioPlayer p = AudioPlayer() yield p p.shutdown() def test_initial_state(self, player): """Initial player state is stopped, no current song, empty queue.""" assert player.is_stopped() is True assert player.is_playing() is False assert player.is_paused() is False assert player.get_current() is None assert player.get_queue() == [] assert player.get_queue_length() == 0 assert player.has_previous() is False def test_volume_default(self, player): """Default volume should be 50.""" assert player.volume() == 50 def test_set_volume(self, player): """set_volume changes the volume.""" player.set_volume(75) assert player.volume() == 75 player.set_volume(100) assert player.volume() == 100 player.set_volume(0) assert player.volume() == 0 def test_set_volume_clamps(self, player): """set_volume clamps values outside 0-100.""" player.set_volume(-10) assert player.volume() == 0 player.set_volume(150) assert player.volume() == 100 def test_loop_default(self, player): """Loop mode is initially off.""" assert player.get_loop() is False def test_set_loop(self, player): """set_loop toggles loop mode.""" player.set_loop(True) assert player.get_loop() is True player.set_loop(False) assert player.get_loop() is False def test_stop_playback_when_stopped(self, player): """stop_playback when already stopped doesn't crash.""" player.stop_playback() assert player.is_stopped() is True def test_queue_operations(self, player): """queue_song, queue_next, and queue_list work correctly. ``queue_song`` triggers auto-play when queue is empty, which pops the song immediately. We test queue_list (no auto-play) instead. """ s2 = {"videoId": "b", "title": "B"} s3 = {"videoId": "c", "title": "C"} # queue_list adds without triggering auto-play player.queue_list([s2, s3]) assert player.get_queue_length() == 2 assert player.get_queue()[0]["videoId"] == "b" # queue_next inserts at position 0 s1 = {"videoId": "a", "title": "A"} player.queue_next(s1) assert player.get_queue_length() == 3 assert player.get_queue()[0]["videoId"] == "a" def test_seek(self, player): """seek sets position (no crash test).""" player.seek(0) # should not crash player.seek(5000) def test_shutdown(self, player): """shutdown cleans up without error.""" player.shutdown() # already called in fixture cleanup def test_cleanup_current_temp_no_current(self, player): """_cleanup_current_temp does nothing when no song is loaded.""" player._cleanup_current_temp() # should not crash def test_cleanup_temp_files_no_files(self, player): """_cleanup_temp_files handles missing files gracefully.""" player._current = {"_local_path": "/nonexistent/file.mp3"} player._cleanup_temp_files() # should not crash player._current = None def test_cleanup_temp_files_with_cache(self, player, tmp_path): """_cleanup_temp_files removes cached temp files.""" f = tmp_path / "test_song.mp3" f.write_text("audio data") player._prefetch_cache["vid1"] = {"_local_path": str(f)} player._cleanup_temp_files() assert not f.exists() def test_position_and_duration_signals(self, player): """_on_position and _on_duration emit signals.""" results = [] player.position_changed.connect(lambda ms: results.append(("pos", ms))) player.duration_changed.connect(lambda ms: results.append(("dur", ms))) player._on_position(5000) player._on_duration(200000) assert ("pos", 5000) in results assert ("dur", 200000) in results def test_is_loaded(self, player): """is_loaded returns False when no current song.""" assert player.is_loaded() is False player._current = {"videoId": "x"} assert player.is_loaded() is True player._current = None def test_toggle_pause_when_stopped(self, player): """toggle_pause when stopped does nothing.""" result = player.toggle_pause() # When stopped, toggle_pause returns None (no state change) assert result is None or result is False def test_previous_no_history(self, player): """previous does nothing when history is empty.""" player.previous() # should not crash assert player.has_previous() is False def test_cancel_active_downloads(self, player): """_cancel_active_downloads emits cancel signal when active.""" player._active_task_id = 42 with mock.patch.object(player._worker, "cancel_requested") as mock_sig: player._cancel_active_downloads() mock_sig.emit.assert_called_once() def test_cancel_active_downloads_noop(self, player): """_cancel_active_downloads does nothing when no active task.""" with mock.patch.object(player._worker, "cancel_requested") as mock_sig: player._cancel_active_downloads() mock_sig.emit.assert_not_called() def test_on_download_failed_matching_task(self, player): """_on_download_failed emits error when task matches active.""" results = [] player.error.connect(lambda d: results.append(d)) player._active_task_id = 42 player._on_download_failed(42) assert len(results) == 1 assert "error" in results[0] assert player._active_task_id is None def test_on_download_failed_stale_task(self, player): """_on_download_failed ignores stale task IDs.""" results = [] player.error.connect(lambda d: results.append(d)) player._active_task_id = 42 player._on_download_failed(99) # different task ID assert results == [] # no signal emitted assert player._active_task_id == 42 # unchanged def test_skip_no_current(self, player): """skip does nothing when no current song.""" player.skip() # should not crash def test_on_download_succeeded_stale(self, player): """_on_download_succeeded ignores stale task IDs.""" player._active_task_id = 42 player._on_download_succeeded(99, {"videoId": "x"}) # different task # No crash, no state change def test_on_download_succeeded_missing_file(self, player, tmp_path): """_on_download_succeeded emits error when file is missing.""" results = [] player.error.connect(lambda d: results.append(d)) player._active_task_id = 42 player._on_download_succeeded(42, {"videoId": "x", "_local_path": str(tmp_path / "nonexistent.mp3")}) assert len(results) == 1 assert player._active_task_id is None def test_on_playback_state_playing(self, player): """_on_playback_state with PlayingState emits 'playing'.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.playback_state_changed.connect(lambda s: results.append(s)) player._current = {"videoId": "x", "title": "Test"} player._on_playback_state(QMediaPlayer.PlaybackState.PlayingState) assert "playing" in results player._current = None def test_on_playback_state_paused(self, player): """_on_playback_state with PausedState emits 'paused'.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.playback_state_changed.connect(lambda s: results.append(s)) player._current = {"videoId": "x"} player._on_playback_state(QMediaPlayer.PlaybackState.PausedState) assert "paused" in results player._current = None def test_on_playback_state_stopped(self, player): """_on_playback_state with StoppedState emits 'stopped'.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.playback_state_changed.connect(lambda s: results.append(s)) player._on_playback_state(QMediaPlayer.PlaybackState.StoppedState) assert "stopped" in results def test_on_media_status_end_of_media(self, player): """_on_media_status with EndOfMedia triggers advance.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.song_ended.connect(lambda vid: results.append(vid)) player._current = {"videoId": "x"} player._on_media_status(QMediaPlayer.MediaStatus.EndOfMedia) assert "x" in results player._current = None def test_on_media_status_invalid_media_stopped(self, player): """_on_media_status with InvalidMedia does nothing when player is stopped.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.error.connect(lambda d: results.append(d)) player._current = {"videoId": "x"} # When the player is stopped (no media loaded), InvalidMedia is ignored player._on_media_status(QMediaPlayer.MediaStatus.InvalidMedia) assert len(results) == 0 # player is stopped, so early return player._current = None def test_on_media_status_loaded(self, player): """_on_media_status with LoadedMedia does nothing special.""" from PySide6.QtMultimedia import QMediaPlayer player._on_media_status(QMediaPlayer.MediaStatus.LoadedMedia) # no crash def test_get_position(self, player): """get_position returns current position (0 when stopped).""" assert player.get_position() == 0 def test_start_prefetch(self, player): """_start_prefetch with queue emits download request.""" from player import DownloadWorker worker = DownloadWorker() player._worker = worker player._queue = [{"videoId": "prefetch_test"}] with mock.patch.object(worker, "download_requested") as mock_sig: player._start_prefetch() mock_sig.emit.assert_called_once() task_id, song = mock_sig.emit.call_args[0] assert isinstance(task_id, int) assert song["videoId"] == "prefetch_test" def test_on_download_succeeded_prefetch(self, player, tmp_path): """_on_download_succeeded caches prefetch result.""" temp_file = tmp_path / "test.mp3" temp_file.write_text("audio") player._prefetch_task_id = 55 player._queue = [{"videoId": "prefetch_vid"}] player._on_download_succeeded(55, {"videoId": "prefetch_vid", "_local_path": str(temp_file)}) assert "prefetch_vid" in player._prefetch_cache assert player._prefetch_task_id is None def test_advance_with_loop(self, player): """_advance re-queues finished song at front in loop mode.""" player._loop_mode = True player._current = {"videoId": "loop_song"} player._queue = [{"videoId": "next_song"}] # _advance inserts at front, then _try_next_song pops the front # leaving the next_song. Verify loop_song was inserted before pop. with mock.patch.object(player, "_try_next_song"): player._advance() # The finished song should be at the front before _try_next_song assert player._queue[0]["videoId"] == "loop_song" assert player._current is None def test_try_next_song_with_queue(self, player): """_try_next_song pops and plays when queue has items.""" player._queue = [{"videoId": "queued"}] # This will call _pop_and_play which tries to download # Just check it doesn't crash and processes the queue with mock.patch.object(player, "_pop_and_play") as mock_pop: player._try_next_song() mock_pop.assert_called_once() def test_on_player_error_network(self, player): """_on_player_error_occurred with NetworkError triggers advance.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.error.connect(lambda d: results.append(d)) player._current = {"videoId": "err_song"} player._on_player_error_occurred( QMediaPlayer.Error.NetworkError, "Connection lost") assert len(results) == 1 assert "error" in results[0] player._current = None def test_on_player_error_format(self, player): """_on_player_error_occurred with FormatError does nothing.""" from PySide6.QtMultimedia import QMediaPlayer results = [] player.error.connect(lambda d: results.append(d)) player._on_player_error_occurred( QMediaPlayer.Error.FormatError, "Bad format") # FormatError is not NetworkError or ResourceError, so ignored assert len(results) == 0 def test_seek_emits_seeked_signal(self, player): """seek emits seeked signal with the position.""" results = [] player.seeked.connect(lambda ms: results.append(ms)) player.seek(15000) assert 15000 in results def test_download_worker_do_cancel(self, player): """DownloadWorker._do_cancel sets cancelled flag.""" from player import DownloadWorker worker = DownloadWorker() assert worker._cancelled is False worker._do_cancel() assert worker._cancelled is True def test_on_position_zero(self, player): """_on_position with 0 emits position_changed.""" results = [] player.position_changed.connect(lambda ms: results.append(ms)) player._on_position(0) assert 0 in results 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)