diff --git a/tests/test_gui_widgets.py b/tests/test_gui_widgets.py index de053ca..2b01b45 100644 --- a/tests/test_gui_widgets.py +++ b/tests/test_gui_widgets.py @@ -2,30 +2,50 @@ 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 -pytest.importorskip("PySide6.QtWidgets") +# 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 QApplication, QPushButton +from PySide6.QtWidgets import QPushButton -# ═══════════════════════════════════════════════════════════════════════════════ -# Fixtures -# ═══════════════════════════════════════════════════════════════════════════════ +# ── 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(): - """Create (or reuse) the singleton QApplication.""" + """Reuse the singleton QApplication.""" inst = QApplication.instance() if inst is None: inst = QApplication([]) - yield inst + return inst @pytest.fixture @@ -44,7 +64,6 @@ def visualizer_mock(): class TestAudioVisualizer: - """Tests for AudioVisualizer new methods.""" def test_reset_activity(self, app): """reset_activity sets _last_activity to 0.""" @@ -54,15 +73,7 @@ class TestAudioVisualizer: 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() + _cleanup(viz, app) # ═══════════════════════════════════════════════════════════════════════════════ @@ -71,7 +82,6 @@ class TestAudioVisualizer: 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.""" @@ -81,18 +91,19 @@ class TestPlaybackBar: 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): + 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 + bar._vol_save_timer.start(5000) assert bar._vol_save_timer.isActive() with mock.patch("gui.save_volume") as mock_save: @@ -100,14 +111,13 @@ class TestPlaybackBar: 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) - # 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 @@ -115,18 +125,19 @@ class TestPlaybackBar: 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): + 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() + _cleanup(bar, app) # ═══════════════════════════════════════════════════════════════════════════════ @@ -135,17 +146,15 @@ class TestPlaybackBar: 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.""" + """_on_show_more_clicked expands the correct category.""" 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 = QPushButton("Show more") btn.setProperty("category", "videos") btn.clicked.connect(page._on_show_more_clicked) @@ -153,6 +162,9 @@ class TestSearchPage: 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 @@ -160,7 +172,7 @@ class TestSearchPage: page = SearchPage() page._songs_expanded = False - btn = QPushButton("⋯ Show more") + btn = QPushButton("Show more") btn.setProperty("category", "songs") btn.clicked.connect(page._on_show_more_clicked) @@ -168,6 +180,9 @@ class TestSearchPage: 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 @@ -178,6 +193,7 @@ class TestSearchPage: 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.""" @@ -189,6 +205,7 @@ class TestSearchPage: with mock.patch.object(page, "_rebuild"): page._toggle_expand("songs") assert page._songs_expanded is True + _cleanup(page, app) # ═══════════════════════════════════════════════════════════════════════════════ @@ -197,7 +214,6 @@ class TestSearchPage: class TestTunettiWindowAccessors: - """Tests for the public accessor methods on TunettiWindow.""" @pytest.fixture def window(self, app): @@ -209,6 +225,7 @@ class TestTunettiWindowAccessors: win.rpc.stop() win.db.close() win.close() + _cleanup(win, app) def test_get_visualizer(self, window): """get_visualizer returns the AudioVisualizer instance."""