419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""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]
|