"""Exercise the assembled tabbed library with fake data and controlled requests.""" from unittest.mock import Mock import pytest from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAccessible from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication from src.api.models import Album, Artist, Genre, Playlist, Song from src.main_window import MainWindow @pytest.fixture def window(monkeypatch, tmp_path): app = QApplication.instance() or QApplication([]) for name in ('XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME'): monkeypatch.setenv(name, str(tmp_path / name)) monkeypatch.setattr(MainWindow, 'setupMpris', lambda self: None) monkeypatch.setattr(MainWindow, 'showServerSetup', lambda self: None) monkeypatch.setattr(MainWindow, 'closeEvent', lambda self, event: event.accept()) monkeypatch.setattr('src.managers.playback_manager.QMediaPlayer', Mock()) monkeypatch.setattr('src.managers.playback_manager.QAudioOutput', Mock()) w = MainWindow() w.client = Mock() w.startPlaybackWithSongs = Mock() w.playback.enqueue = Mock() w.client.getArtist.return_value = {'albums': [Album('album', 'Album', 'Artist', songCount=1)]} song = Song('song', 'Song', 'Album', 'album', 'Artist') w.client.getAlbum.return_value = {'songs': [song]} w.client.getAlbumList.return_value = [Album('album', 'Album', 'Artist', songCount=1)] w.client.getPlaylist.return_value = Playlist('playlist', 'Playlist', songs=[song]) w.client.getSongsByGenre.return_value = [song] w.client.getNowPlaying.return_value = [song] w.client.getSimilarSongs.return_value = [song] w.playback.currentSong = Mock(return_value=song) pending = [] w._run_background = lambda label, work, on_success=None, on_error=None: pending.append( (work, on_success, on_error)) w._applyLibraryData({'artists': [Artist('artist', 'Artist', 1)], 'favorites': {'songs': [song]}, 'playlists': [Playlist('playlist', 'Playlist', songCount=1)], 'genres': [Genre('Rock', songCount=1, albumCount=1)]}) w.show() w.activateWindow() w.libraryBrowser.focusTable() app.processEvents() yield w, pending w.libraryBrowser.clear() w._executor.shutdown(wait=True) w.close() w.deleteLater() app.processEvents() def finish(pending, index=0): work, apply, failed = pending.pop(index) apply(work()) QApplication.processEvents() def open_album(window): w, pending = window page = w.libraryBrowser.currentWidget() QTest.keyClick(page.table, Qt.Key_Return) finish(pending) assert page.selectedData()['type'] == 'album' QTest.keyClick(page.table, Qt.Key_Return) finish(pending) assert page.selectedData()['type'] == 'song' return page def test_browse_and_back_replace_rows_without_hidden_descendants(window): w, pending = window page = open_album(window) assert page.model.headers == ('Title', 'Artist', 'Album', 'Duration') QTest.keyClick(page.table, Qt.Key_Backspace) assert page.selectedData()['type'] == 'album' QTest.keyClick(page.table, Qt.Key_Backspace) assert page.selectedData()['type'] == 'artist' assert page.model.rowCount() == 1 assert page.model.columnCount() == 3 assert not page.backButton.isEnabled() assert not pending assert page.table.hasFocus() w.playback.enqueue.assert_not_called() def test_play_song_once_and_playback_completion_does_not_navigate(window): w, pending = window page = open_album(window) QTest.keyClick(page.table, Qt.Key_Return) w.playback.enqueue.assert_called_once_with(w.client.getAlbum.return_value['songs'][0], playNow=True) page.goBack() page.playButton.click() page.goBack() finish(pending) assert page.selectedData()['type'] == 'artist' w.startPlaybackWithSongs.assert_called_once() def test_back_discards_late_browse_response(window): w, pending = window page = w.libraryBrowser.currentWidget() QTest.keyClick(page.table, Qt.Key_Return) assert page.model.rowCount() == 0 QTest.keyClick(page.table, Qt.Key_Backspace) finish(pending) assert page.selectedData()['type'] == 'artist' assert len(page.history) == 1 def test_tab_switch_during_load_keeps_active_table_and_focus(window): w, pending = window browser = w.libraryBrowser page = browser.currentWidget() QTest.keyClick(page.table, Qt.Key_Return) browser.setCurrentWidget(browser.pages['Favorites']) browser.focusTable() finish(pending) assert browser.selectedData()['type'] == 'song' assert browser.pages['Favorites'].table.hasFocus() browser.setCurrentWidget(page) assert page.selectedData()['type'] == 'album' def test_refresh_invalidates_pending_browse(window): w, pending = window page = w.libraryBrowser.currentWidget() page.openSelected() w._applyLibraryData({'artists': [Artist('new', 'New artist')]}) finish(pending) assert page.selectedData()['id'] == 'new' assert len(page.history) == 1 def test_failure_can_retry_without_fake_rows(window): w, pending = window page = w.libraryBrowser.currentWidget() page.openSelected() _, _, fail = pending.pop() fail(RuntimeError('synthetic failure')) assert page.model.rowCount() == 0 assert page.retryButton.isVisible() page.retryButton.click() finish(pending) assert page.selectedData()['type'] == 'album' assert not page.retryButton.isVisible() def test_albums_pagination_does_not_drop_or_repeat_rows(window): w, pending = window browser = w.libraryBrowser browser.PAGE_SIZE = 2 # Rebuild loaders with the small test page size. w._applyLibraryData({}) w.client.getAlbumList.side_effect = [ [Album('a', 'A', 'Artist'), Album('b', 'B', 'Artist')], [Album('c', 'C', 'Artist')], ] browser.setCurrentWidget(browser.pages['Albums']) finish(pending) page = browser.currentWidget() page.table.setCurrentIndex(page.model.index(1, 0)) page.moreButton.click() finish(pending) assert [row.data['id'] for row in page.model.rows] == ['a', 'b', 'c'] assert page.selectedData()['id'] == 'b' assert not page.moreButton.isVisible() assert [call.kwargs['offset'] for call in w.client.getAlbumList.call_args_list] == [0, 2] @pytest.mark.parametrize('name', ['Playlists', 'Genres', 'Discover']) def test_other_tabs_open_collections(window, name): w, pending = window browser = w.libraryBrowser browser.setCurrentWidget(browser.pages[name]) page = browser.currentWidget() page.openSelected() finish(pending) assert page.selectedData()['type'] == ('album' if name == 'Discover' else 'song') page.backButton.click() assert len(page.history) == 1 def test_keyboard_tab_switch_and_context_menu(window, monkeypatch): w, pending = window browser = w.libraryBrowser page = browser.currentWidget() menus = [] def inspect_menu(): menu = QApplication.activePopupWidget() menus.append([action.text() for action in menu.actions()]) menu.close() QTimer.singleShot(0, inspect_menu) QTest.keyClick(page.table, Qt.Key_F10, Qt.ShiftModifier) assert 'Open' in menus[-1] assert 'Play' in menus[-1] assert 'Add to Queue' in menus[-1] QTest.keyClick(page.table, Qt.Key_Tab, Qt.ControlModifier) QApplication.processEvents() assert browser.currentWidget() is browser.pages['Albums'] finish(pending) def test_native_accessibility_reports_table_headers_and_current_cells(window): w, _ = window page = open_album(window) page.goBack() page.goBack() QApplication.processEvents() interface = QAccessible.queryAccessibleInterface(page.table) assert interface.role() == QAccessible.Table names = [interface.child(i).text(QAccessible.Name) for i in range(interface.childCount())] assert 'Selected' in names and 'Artist' in names and 'Albums' in names assert 'Song' not in names and 'Album' not in names assert page.table.currentIndex().column() == 1 def test_checkbox_cell_exposes_checked_state_and_accessible_text(window): w, _ = window page = w.libraryBrowser.currentWidget() checkboxIndex = page.model.index(0, 0) announcements = [] w.libraryBrowser.checkedStateAnnouncement.connect(announcements.append) assert page.model.headerData(0, Qt.Horizontal) == 'Selected' assert page.model.data(checkboxIndex, Qt.AccessibleTextRole) == 'Artist, unchecked' assert page.model.data(checkboxIndex, Qt.AccessibleDescriptionRole) is None assert page.model.flags(checkboxIndex) & Qt.ItemIsUserCheckable interface = QAccessible.queryAccessibleInterface(page.table) checkbox = next(interface.child(i) for i in range(interface.childCount()) if interface.child(i).text(QAccessible.Name) == 'Artist, unchecked') assert checkbox.state().checkable assert not checkbox.state().checked QTest.keyClick(page.table, Qt.Key_Space) QApplication.processEvents() assert page.model.data(checkboxIndex, Qt.CheckStateRole) == Qt.Checked assert page.model.data(checkboxIndex, Qt.AccessibleTextRole) == 'Artist, checked' assert announcements == ['Artist checked; 1 item checked.'] interface = QAccessible.queryAccessibleInterface(page.table) checkbox = next(interface.child(i) for i in range(interface.childCount()) if interface.child(i).text(QAccessible.Name) == 'Artist, checked') assert checkbox.state().checkable assert checkbox.state().checked def test_collection_actions_and_queue_use_current_payload(window, monkeypatch): w, _ = window page = w.libraryBrowser.currentWidget() targets = [] def choose_queue(): menu = QApplication.activePopupWidget() action = next(action for action in menu.actions() if action.text() == 'Add to Queue') menu.close() action.trigger() w.addItemToQueue = targets.append QTimer.singleShot(0, choose_queue) page.collectionButton.click() assert targets[-1]['type'] == 'artists_root' QTimer.singleShot(0, choose_queue) page.actionsButton.click() assert targets[-1]['type'] == 'artist' def test_checked_items_persist_across_tabs_and_alt_a_uses_the_group(window): w, pending = window browser = w.libraryBrowser artistsPage = browser.pages['Artists'] QTest.keyClick(artistsPage.table, Qt.Key_Space) assert artistsPage.model.data(artistsPage.model.index(0, 0), Qt.CheckStateRole) == Qt.Checked browser.setCurrentWidget(browser.pages['Albums']) finish(pending) albumsPage = browser.currentWidget() QTest.keyClick(albumsPage.table, Qt.Key_Space) browser.setCurrentWidget(browser.pages['Favorites']) favoritesPage = browser.currentWidget() QTest.keyClick(favoritesPage.table, Qt.Key_Space) group = browser.actionData(favoritesPage.selectedData()) assert group['type'] == 'checked_items' assert [item['type'] for item in group['items']] == ['artist', 'album', 'song'] assert favoritesPage.actionsButton.text() == 'Actions (3 checked)' assert '3 items checked' in favoritesPage.status.text() targets = [] w.addItemToQueue = targets.append def choose_queue(): menu = QApplication.activePopupWidget() action = next(action for action in menu.actions() if action.text() == 'Add Checked Items to Queue') menu.close() action.trigger() QTimer.singleShot(0, choose_queue) QTest.keyClick(favoritesPage.table, Qt.Key_A, Qt.AltModifier) assert targets[-1]['type'] == 'checked_items' assert len(targets[-1]['items']) == 3 favoritesPage.clearCheckedButton.click() assert browser.checkedCount() == 0 assert artistsPage.model.data(artistsPage.model.index(0, 0), Qt.CheckStateRole) == Qt.Unchecked def test_checked_collections_expand_and_deduplicate_songs(window): w, _ = window selectedSong = w.client.getAlbum.return_value['songs'][0] result, label, truncated = w.collectSongsForItem({ 'type': 'checked_items', 'label': '3 checked items', 'items': [ {'type': 'artist', 'id': 'artist', 'label': 'Artist'}, {'type': 'album', 'id': 'album', 'label': 'Album'}, {'type': 'song', 'id': selectedSong.id, 'label': selectedSong.title, 'song': selectedSong}, ], }) assert result == [selectedSong] assert label == '3 checked items' assert not truncated def test_checked_favorite_action_attempts_every_item(window): w, _ = window w.client.star.side_effect = [None, RuntimeError('synthetic failure'), None] items = [ {'type': 'artist', 'id': 'artist', 'label': 'Artist'}, {'type': 'album', 'id': 'album', 'label': 'Album'}, {'type': 'song', 'id': 'song', 'label': 'Song'}, ] updated, failed = w._setCheckedItemsFavorite(items, True) assert updated == 2 assert failed == ['Album'] assert [entry.args for entry in w.client.star.call_args_list] == [ ('artist', 'artist'), ('album', 'album'), ('song', 'song'), ] def test_actual_background_request_does_not_block_back_navigation(window): import threading import time w, _ = window started, release, done = threading.Event(), threading.Event(), threading.Event() threads = [] def fetch(identifier): threads.append(threading.get_ident()) started.set() assert release.wait(3) done.set() return {'albums': [Album('late', 'Late album', 'Artist')]} w.client.getArtist.side_effect = fetch w._run_background = MainWindow._run_background.__get__(w) page = w.libraryBrowser.currentWidget() try: page.openSelected() assert started.wait(1) page.goBack() release.set() assert done.wait(1) w._executor.shutdown(wait=True) deadline = time.monotonic() + 0.1 while time.monotonic() < deadline: QApplication.processEvents() time.sleep(0.005) assert page.selectedData()['type'] == 'artist' assert len(threads) == 1 assert threads[0] != threading.get_ident() finally: release.set() def test_back_restores_selection_and_empty_tables_have_no_actions(window): w, pending = window w._applyLibraryData({'artists': [Artist('first', 'First'), Artist('second', 'Second')]}) page = w.libraryBrowser.currentWidget() page.table.setCurrentIndex(page.model.index(1, 0)) w.client.getArtist.return_value = {'albums': []} page.openSelected() finish(pending) assert page.model.rowCount() == 0 assert not page.playButton.isEnabled() assert not page.actionsButton.isEnabled() page.goBack() assert page.selectedData()['id'] == 'second' def test_search_queue_uses_payload_independent_of_removed_search_item(window): from PySide6.QtWidgets import QTreeWidgetItem from src.widgets.search_dialog import SearchDialog w, pending = window item = QTreeWidgetItem(['Album from search']) item.setData(0, Qt.UserRole, {'type': 'album', 'id': 'album'}) w.playback.enqueueMany = Mock() w.addItemToQueue(SearchDialog.actionData(item)) del item finish(pending) w.playback.enqueueMany.assert_called_once_with(w.client.getAlbum.return_value['songs'], playFirst=False) def test_genre_playback_and_queue_collect_all_pages(window): w, _ = window songs = [Song(str(i), f'Song {i}', 'Album', 'album', 'Artist') for i in range(201)] w.client.getSongsByGenre.side_effect = [songs[:200], songs[200:]] collected, label, truncated = w.collectSongsForItem( {'type': 'genre', 'name': 'Rock', 'label': 'Rock'}) assert collected == songs assert not truncated assert [call.kwargs['offset'] for call in w.client.getSongsByGenre.call_args_list] == [0, 200]