From d9f4ac4135b5b16cbdf5798de3ef13efc735512d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 15 Sep 2026 14:48:55 -0400 Subject: [PATCH] More work on the UI accessibility. --- distro-packages/ArchLinux/navipy-git/PKGBUILD | 2 +- src/main_window.py | 6 +- tests/test_library_ui.py | 129 ++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/test_library_ui.py diff --git a/distro-packages/ArchLinux/navipy-git/PKGBUILD b/distro-packages/ArchLinux/navipy-git/PKGBUILD index 5f26c84..1c1cca6 100644 --- a/distro-packages/ArchLinux/navipy-git/PKGBUILD +++ b/distro-packages/ArchLinux/navipy-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=navipy-git -pkgver=r9.gd2e68bd +pkgver=r10.gc4ccc39 pkgrel=1 pkgdesc='Accessible Subsonic desktop client tested with Navidrome' arch=('any') diff --git a/src/main_window.py b/src/main_window.py index 8c88ae8..32f4238 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -1657,7 +1657,6 @@ class MainWindow(QMainWindow): self.startPlaybackWithSongs(songs, f"album {album.name if album else albumId}") if item and songs: self._populateAlbumItemWithSongs(item, songs) - item.setExpanded(True) def _fetchArtistSongs(self, artistId: str) -> Tuple[Optional[Artist], List[Song]]: """Fetch all songs for an artist across albums with throttling.""" @@ -1678,12 +1677,10 @@ class MainWindow(QMainWindow): return artist, songs def _onArtistLoadedForPlay(self, item: Optional[QTreeWidgetItem], payload: Tuple[Optional[Artist], List[Song]]): - """Start playback for a loaded artist and expand UI if present.""" + """Start playback without changing the user's tree expansion state.""" artist, songs = payload label = artist.name if artist else "artist" self.startPlaybackWithSongs(songs, label) - if item: - item.setExpanded(True) def _progressiveLoadAllArtists(self) -> int: """Fetch songs across all artists in chunks and enqueue progressively.""" @@ -1747,7 +1744,6 @@ class MainWindow(QMainWindow): self.startPlaybackWithSongs(songs, f"genre {genreName}") if item and songs: self._populateGenreItemWithSongs(item, songs) - item.setExpanded(True) def _onPlaylistLoadedForPlay(self, item: Optional[QTreeWidgetItem], playlist: Playlist): """Start playback for a playlist and populate UI if present.""" diff --git a/tests/test_library_ui.py b/tests/test_library_ui.py new file mode 100644 index 0000000..0ebd8d0 --- /dev/null +++ b/tests/test_library_ui.py @@ -0,0 +1,129 @@ +"""Exercise the assembled library UI with fake data and controlled requests.""" +from unittest.mock import Mock + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from src.api.models import Album, Artist, 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) + # 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()) + w = MainWindow() + w.client = Mock() + w.startPlaybackWithSongs = Mock() + pending = [] + w._run_background = lambda label, work, on_success=None, **kw: pending.append(on_success) + w._applyLibraryData({'artists': [Artist('artist', 'Artist', 1)]}) + w.show() + w.libraryTree.setFocus() + app.processEvents() + yield w, pending + w._executor.shutdown(wait=True) + w.close() + w.deleteLater() + app.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 + + +def test_album_can_collapse_and_reopen_after_loading(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() + assert not pending + + +def test_playback_completion_respects_album_collapse(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() + 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): + 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) + QApplication.processEvents() + assert not album.isExpanded() + assert not w.libraryTree.isItemNavigable(album.child(0)) + + +@pytest.mark.parametrize('kind', ['artist', 'genre']) +def test_other_playback_responses_preserve_collapsed_branches(window, kind): + 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()