Rework the ui because the treeviews were not reliable.

This commit is contained in:
Storm Dragon committed 2026-09-15 15:10:27 -04:00
1 parent d9f4ac4135
commit 33bba7282e
6 files changed
+858 -651

No files matched your search

+260 -82
View File
@@ -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]