Rework the ui because the treeviews were not reliable.
This commit is contained in:
1 parent
d9f4ac4135
commit
33bba7282e
6 files changed
+858
-651
No files matched your search
+260
-82
@@ -1,12 +1,13 @@
|
||||
"""Exercise the assembled library UI with fake data and controlled requests."""
|
||||
"""Exercise the assembled tabbed library with fake data and controlled requests."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
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, Song
|
||||
from src.api.models import Album, Artist, Genre, Playlist, Song
|
||||
from src.main_window import MainWindow
|
||||
|
||||
|
||||
@@ -17,113 +18,290 @@ def window(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(name, str(tmp_path / name))
|
||||
monkeypatch.setattr(MainWindow, 'setupMpris', lambda self: None)
|
||||
monkeypatch.setattr(MainWindow, 'showServerSetup', lambda self: None)
|
||||
# Avoid the production close handler's forced process exit.
|
||||
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())
|
||||
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, **kw: pending.append(on_success)
|
||||
w._applyLibraryData({'artists': [Artist('artist', 'Artist', 1)]})
|
||||
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.libraryTree.setFocus()
|
||||
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
|
||||
tree = w.libraryTree
|
||||
artist = w.artistsRoot.child(0)
|
||||
tree.setCurrentItem(artist)
|
||||
QTest.keyClick(tree, Qt.Key_Right)
|
||||
pending.pop()([Album('album', 'Album', 'Artist', songCount=1)])
|
||||
QTest.keyClick(tree, Qt.Key_Right)
|
||||
album = tree.currentItem()
|
||||
assert album.parent() is artist
|
||||
QTest.keyClick(tree, Qt.Key_Right)
|
||||
songs = [Song('song', 'Song', 'Album', 'album', 'Artist')]
|
||||
pending.pop()(songs)
|
||||
QApplication.processEvents()
|
||||
return album, songs
|
||||
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_album_can_collapse_and_reopen_after_loading(window):
|
||||
def test_browse_and_back_replace_rows_without_hidden_descendants(window):
|
||||
w, pending = window
|
||||
album, songs = open_album(window)
|
||||
tree = w.libraryTree
|
||||
assert album.isExpanded()
|
||||
QTest.keyClick(tree, Qt.Key_Right)
|
||||
assert tree.currentItem() is album.child(0)
|
||||
QTest.keyClick(tree, Qt.Key_Left)
|
||||
assert tree.currentItem() is album
|
||||
QTest.keyClick(tree, Qt.Key_Left)
|
||||
assert not album.isExpanded()
|
||||
assert tree.visualItemRect(album.child(0)).isEmpty()
|
||||
QTest.keyClick(tree, Qt.Key_Right)
|
||||
assert album.isExpanded()
|
||||
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() == 2
|
||||
assert not page.backButton.isEnabled()
|
||||
assert not pending
|
||||
assert page.table.hasFocus()
|
||||
w.playback.enqueue.assert_not_called()
|
||||
|
||||
|
||||
def test_playback_completion_respects_album_collapse(window):
|
||||
def test_play_song_once_and_playback_completion_does_not_navigate(window):
|
||||
w, pending = window
|
||||
album, songs = open_album(window)
|
||||
QTest.keyClick(w.libraryTree, Qt.Key_Return)
|
||||
assert len(pending) == 1
|
||||
QTest.keyClick(w.libraryTree, Qt.Key_Left)
|
||||
assert not album.isExpanded()
|
||||
pending.pop()((None, songs))
|
||||
QApplication.processEvents()
|
||||
assert not album.isExpanded()
|
||||
assert w.libraryTree.visualItemRect(album.child(0)).isEmpty()
|
||||
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_album_can_collapse_with_mouse(window):
|
||||
w, _ = window
|
||||
album, _ = open_album(window)
|
||||
tree = w.libraryTree
|
||||
tree.setCurrentItem(album.child(0))
|
||||
rect = tree.visualItemRect(album)
|
||||
# Click the branch indicator just before the album's text rectangle.
|
||||
point = rect.center()
|
||||
point.setX(rect.left() - tree.indentation() // 2)
|
||||
QTest.mouseClick(tree.viewport(), Qt.LeftButton, pos=point)
|
||||
QApplication.processEvents()
|
||||
assert not album.isExpanded()
|
||||
assert tree.currentItem() is album
|
||||
|
||||
|
||||
def test_album_browse_response_does_not_reopen_collapsed_branch(window):
|
||||
def test_back_discards_late_browse_response(window):
|
||||
w, pending = window
|
||||
album, songs = open_album(window)
|
||||
data = album.data(0, Qt.UserRole)
|
||||
data['loaded'] = False
|
||||
album.setData(0, Qt.UserRole, data)
|
||||
QTest.keyClick(w.libraryTree, Qt.Key_Left)
|
||||
QTest.keyClick(w.libraryTree, Qt.Key_Right)
|
||||
assert len(pending) == 1
|
||||
QTest.keyClick(w.libraryTree, Qt.Key_Left)
|
||||
pending.pop()(songs)
|
||||
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 not album.isExpanded()
|
||||
assert not w.libraryTree.isItemNavigable(album.child(0))
|
||||
assert browser.currentWidget() is browser.pages['Albums']
|
||||
finish(pending)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['artist', 'genre'])
|
||||
def test_other_playback_responses_preserve_collapsed_branches(window, kind):
|
||||
def test_native_accessibility_reports_table_headers_and_current_cells(window):
|
||||
w, _ = window
|
||||
album, songs = open_album(window)
|
||||
album.setExpanded(False)
|
||||
if kind == 'artist':
|
||||
w._onArtistLoadedForPlay(album, (None, songs))
|
||||
else:
|
||||
w._onGenreLoadedForPlay('Genre', album, (songs, False))
|
||||
assert not album.isExpanded()
|
||||
w.startPlaybackWithSongs.assert_called_once()
|
||||
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 'Artist' in names and 'Albums' in names
|
||||
assert 'Song' not in names and 'Album' not in names
|
||||
|
||||
|
||||
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_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]
|
||||
Reference in new issue
Block a user