Few bug fixes, new PKGBUILD added.
This commit is contained in:
1 parent
d5545dadcd
commit
d2e68bdcb4
12 files changed
+529
-53
No files matched your search
@@ -23,6 +23,7 @@ class FakeClient:
|
||||
"""Minimal fake SubsonicClient for incremental sync tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.username = "test"
|
||||
self.serverUrl = "https://fake.example"
|
||||
self._artists = [
|
||||
Artist(id="a1", name="Artist One", albumCount=1),
|
||||
|
||||
@@ -66,6 +66,7 @@ def test_update_starred_sets_and_clears(tmp_path: Path):
|
||||
def test_playlist_changed_triggers_refetch(tmp_path: Path):
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.username = "test"
|
||||
self.serverUrl = "https://fake.example"
|
||||
now = datetime.now()
|
||||
self._playlists = [
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Regression coverage for background work, cache isolation, and playback."""
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
from PySide6.QtWidgets import QApplication, QWidget, QTreeWidgetItem
|
||||
|
||||
from src.api.client import SubsonicClient
|
||||
from src.api.models import Song, Playlist
|
||||
from src.cache import CachedClient, CacheDatabase, CacheManager
|
||||
from src.main_window import MainWindow
|
||||
from src.managers.playback_manager import PlaybackManager
|
||||
from src.widgets.search_dialog import SearchDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def wait_for(app, predicate):
|
||||
deadline = time.monotonic() + 3
|
||||
while not predicate() and time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.005)
|
||||
assert predicate()
|
||||
|
||||
|
||||
def song(identifier):
|
||||
return Song(id=identifier, title=identifier, album="Album", albumId="a", artist="Artist")
|
||||
|
||||
|
||||
def test_background_error_survives_until_ui_delivery(app):
|
||||
callbacks, errors = [], []
|
||||
with ThreadPoolExecutor(1) as executor:
|
||||
harness = SimpleNamespace(_executor=executor, logger=logging.getLogger("test"),
|
||||
_on_ui=callbacks.append)
|
||||
def fail():
|
||||
raise ValueError("synthetic failure")
|
||||
MainWindow._run_background(harness, "test", fail, on_error=errors.append)
|
||||
assert len(callbacks) == 1
|
||||
callbacks[0]()
|
||||
assert str(errors[0]) == "synthetic failure"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("refresh", ["forced", "full", "incremental"])
|
||||
def test_refresh_bypasses_api_memory_cache(tmp_path, refresh):
|
||||
raw = SubsonicClient("https://fake.example", "user", "fake")
|
||||
state = {"name": "Old"}
|
||||
def request(endpoint, params=None):
|
||||
if endpoint == "getArtists":
|
||||
return {"artists": {"index": [{"artist": [{"id": "a", "name": state["name"], "albumCount": 0}]}]}}
|
||||
if endpoint == "getArtist":
|
||||
return {"artist": {"id": "a", "name": state["name"]}}
|
||||
return {}
|
||||
raw._makeRequest = Mock(side_effect=request)
|
||||
cached = CachedClient(raw, tmp_path)
|
||||
try:
|
||||
assert cached.getArtists()[0].name == "Old"
|
||||
state["name"] = "New"
|
||||
if refresh == "forced":
|
||||
result = cached.getArtists(forceRefresh=True)
|
||||
else:
|
||||
getattr(cached, "syncFull" if refresh == "full" else "syncIncremental")()
|
||||
result = cached.getArtists()
|
||||
assert result[0].name == "New"
|
||||
finally:
|
||||
cached.db.close()
|
||||
|
||||
|
||||
def test_accounts_have_separate_persistent_caches(tmp_path):
|
||||
first = CachedClient(SubsonicClient("https://fake.example", "alice", "fake"), tmp_path)
|
||||
second = CachedClient(SubsonicClient("https://fake.example", "bob", "fake"), tmp_path)
|
||||
again = CachedClient(SubsonicClient("https://fake.example", "alice", "new password"), tmp_path)
|
||||
try:
|
||||
first.cache.setPlaylist(Playlist(id="p", name="Private", songs=[song("s")]))
|
||||
assert second.cache.getPlaylists() == []
|
||||
assert again.cache.getPlaylist("p").name == "Private"
|
||||
assert first.db.dbPath != second.db.dbPath
|
||||
finally:
|
||||
for client in (first, second, again):
|
||||
client.db.close()
|
||||
|
||||
|
||||
def test_playlist_metadata_preserves_order_and_empty_detail_clears(tmp_path):
|
||||
db = CacheDatabase("https://fake.example", tmp_path)
|
||||
cache = CacheManager(db)
|
||||
try:
|
||||
cache.setPlaylist(Playlist(id="p", name="Before", songs=[song("b"), song("a"), song("b")]))
|
||||
cache.setPlaylists([Playlist(id="p", name="After", songCount=3)])
|
||||
assert [s.id for s in cache.getPlaylist("p").songs] == ["b", "a", "b"]
|
||||
assert cache.getPlaylist("p").name == "After"
|
||||
cache.setPlaylist(Playlist(id="p", name="Empty", songs=[]))
|
||||
assert cache.getPlaylist("p").songs == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_search_activation_enqueues_once(app):
|
||||
parent = QWidget()
|
||||
parent.playback = SimpleNamespace(enqueue=Mock())
|
||||
parent.enablePlaybackActions = Mock()
|
||||
parent.statusBar = SimpleNamespace(showMessage=Mock())
|
||||
dialog = SearchDialog(Mock(), parent)
|
||||
dialog.songActivated.connect(lambda s: MainWindow.onSearchSongChosen(parent, s))
|
||||
chosen = song("s")
|
||||
item = QTreeWidgetItem(["song"])
|
||||
item.setData(0, Qt.UserRole, {"type": "song", "song": chosen})
|
||||
dialog.handleActivate(item)
|
||||
parent.playback.enqueue.assert_called_once_with(chosen, playNow=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playback(app, monkeypatch):
|
||||
# Exercise the real manager with a fake media backend; no audio or network.
|
||||
import src.managers.playback_manager as module
|
||||
factory = Mock()
|
||||
factory.PlayingState = QMediaPlayer.PlayingState
|
||||
factory.PausedState = QMediaPlayer.PausedState
|
||||
factory.StoppedState = QMediaPlayer.StoppedState
|
||||
monkeypatch.setattr(module, "QMediaPlayer", factory)
|
||||
monkeypatch.setattr(module, "QAudioOutput", Mock())
|
||||
manager = PlaybackManager()
|
||||
manager.player.playbackState.return_value = QMediaPlayer.PlayingState
|
||||
manager.setStreamResolver(lambda s: "https://fake.example/" + s.id)
|
||||
manager.queue = [song(str(i)) for i in range(4)]
|
||||
manager.currentIndex = 1
|
||||
manager.shuffleEnabled = True
|
||||
manager._shuffleOrder = [2, 1, 3, 0]
|
||||
manager._shufflePosition = 1
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.mark.parametrize("removed", [0, 2, 3])
|
||||
def test_queue_removal_preserves_current_and_valid_shuffle(playback, removed):
|
||||
current = playback.currentSong()
|
||||
playback.removeIndex(removed)
|
||||
assert playback.currentSong() is current
|
||||
assert sorted(playback._shuffleOrder) == list(range(3))
|
||||
assert playback._shuffleOrder[playback._shufflePosition] == playback.currentIndex
|
||||
playback.player.setSource.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", [QMediaPlayer.PlayingState, QMediaPlayer.PausedState, QMediaPlayer.StoppedState])
|
||||
def test_removing_current_selects_shuffle_successor(playback, state):
|
||||
playback.player.playbackState.return_value = state
|
||||
changes = []
|
||||
playback.trackChanged.connect(changes.append)
|
||||
playback.removeIndex(1)
|
||||
assert playback.currentSong().id == "3"
|
||||
assert changes[-1].id == "3"
|
||||
assert sorted(playback._shuffleOrder) == [0, 1, 2]
|
||||
if state == QMediaPlayer.PausedState:
|
||||
playback.player.pause.assert_called_once()
|
||||
elif state == QMediaPlayer.StoppedState:
|
||||
playback.player.stop.assert_called_once()
|
||||
|
||||
|
||||
def test_removing_last_track_clears_source(playback):
|
||||
playback.queue = [song("only")]
|
||||
playback.currentIndex = 0
|
||||
playback.removeIndex(0)
|
||||
assert playback.currentSong() is None
|
||||
assert playback.queue == []
|
||||
assert playback._shuffleOrder == []
|
||||
assert playback.player.setSource.call_args.args[0].isEmpty()
|
||||
|
||||
|
||||
def test_search_is_nonblocking_and_discards_old_results(app):
|
||||
release = threading.Event()
|
||||
started = threading.Event()
|
||||
def search(query, **kwargs):
|
||||
if query == "old":
|
||||
started.set()
|
||||
assert release.wait(3)
|
||||
return {"songs": [song(query)]}
|
||||
dialog = SearchDialog(SimpleNamespace(search=search))
|
||||
try:
|
||||
dialog.queryEdit.setText("old")
|
||||
dialog.runSearch()
|
||||
assert started.wait(1)
|
||||
dialog.queryEdit.setText("new")
|
||||
dialog.runSearch()
|
||||
wait_for(app, lambda: dialog._resultsLoaded)
|
||||
assert dialog.resultsTree.topLevelItem(0).child(0).data(0, Qt.UserRole)["song"].id == "new"
|
||||
release.set()
|
||||
dialog._executor.shutdown(wait=True)
|
||||
app.processEvents()
|
||||
assert dialog.resultsTree.topLevelItem(0).child(0).data(0, Qt.UserRole)["song"].id == "new"
|
||||
finally:
|
||||
release.set()
|
||||
dialog.reject()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalidate", ["search", "close"])
|
||||
def test_expansion_results_are_ignored_after_search_or_close(app, invalidate):
|
||||
release = threading.Event()
|
||||
started = threading.Event()
|
||||
def get_album(identifier):
|
||||
started.set()
|
||||
assert release.wait(3)
|
||||
return {"songs": [song("late")]}
|
||||
client = SimpleNamespace(getAlbum=get_album, search=lambda *args, **kw: {})
|
||||
dialog = SearchDialog(client)
|
||||
item = QTreeWidgetItem(["album"])
|
||||
item.setData(0, Qt.UserRole, {"type": "album", "id": "a", "loaded": False})
|
||||
dialog.resultsTree.addTopLevelItem(item)
|
||||
try:
|
||||
dialog.onItemExpanded(item)
|
||||
assert started.wait(1)
|
||||
if invalidate == "search":
|
||||
dialog.queryEdit.setText("new")
|
||||
dialog.runSearch()
|
||||
else:
|
||||
dialog.reject()
|
||||
release.set()
|
||||
dialog._executor.shutdown(wait=True)
|
||||
app.processEvents()
|
||||
assert dialog.resultsTree.topLevelItemCount() == (0 if invalidate == "search" else 1)
|
||||
finally:
|
||||
release.set()
|
||||
dialog.reject()
|
||||
|
||||
|
||||
def test_unchanged_playlist_keeps_tracks_across_incremental_sync(tmp_path):
|
||||
from test_cache_basic import FakeClient
|
||||
client = CachedClient(FakeClient(), tmp_path)
|
||||
try:
|
||||
client.syncIncremental()
|
||||
client.syncIncremental()
|
||||
assert [s.id for s in client.cache.getPlaylist("p1").songs] == ["s1"]
|
||||
finally:
|
||||
client.db.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index, expected", [(1, "2"), (3, "2")])
|
||||
def test_removing_current_without_shuffle(playback, index, expected):
|
||||
playback.setShuffle(False)
|
||||
playback.currentIndex = index
|
||||
playback.removeIndex(index)
|
||||
assert playback.currentSong().id == expected
|
||||
assert playback._shuffleOrder == []
|
||||
|
||||
|
||||
def test_expansion_fetches_off_thread_and_updates_ui(app):
|
||||
main_thread = threading.get_ident()
|
||||
worker_threads = []
|
||||
def get_album(identifier):
|
||||
worker_threads.append(threading.get_ident())
|
||||
return {"songs": [song("loaded")]}
|
||||
dialog = SearchDialog(SimpleNamespace(getAlbum=get_album))
|
||||
item = QTreeWidgetItem(["album"])
|
||||
item.setData(0, Qt.UserRole, {"type": "album", "id": "a", "loaded": False})
|
||||
dialog.resultsTree.addTopLevelItem(item)
|
||||
try:
|
||||
dialog.onItemExpanded(item)
|
||||
wait_for(app, lambda: item.data(0, Qt.UserRole).get("loaded"))
|
||||
assert item.child(0).data(0, Qt.UserRole)["song"].id == "loaded"
|
||||
assert worker_threads[0] != main_thread
|
||||
finally:
|
||||
dialog.reject()
|
||||
|
||||
|
||||
def test_search_error_delivered_on_ui_thread(app, monkeypatch):
|
||||
from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
||||
errors = []
|
||||
monkeypatch.setattr(AccessibleTextDialog, "showError",
|
||||
lambda title, message, parent: errors.append((message, threading.get_ident())))
|
||||
def fail(*args, **kwargs):
|
||||
raise ValueError("synthetic search failure")
|
||||
dialog = SearchDialog(SimpleNamespace(search=fail))
|
||||
try:
|
||||
dialog.queryEdit.setText("query")
|
||||
dialog.runSearch()
|
||||
wait_for(app, lambda: bool(errors))
|
||||
assert errors == [("synthetic search failure", threading.get_ident())]
|
||||
assert dialog.statusLabel.text() == "Search failed."
|
||||
finally:
|
||||
dialog.reject()
|
||||
Reference in new issue
Block a user