Files
navipy/tests/test_library_ui.py

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]