queue: add serialized, origin-aware mutations

This commit is contained in:
austinried
2026-11-14 12:03:42 +00:00
parent f43bc877f0
commit 44e2eb7ae8
16 changed files with 3208 additions and 431 deletions
@@ -313,14 +313,14 @@ class DownloadRepositoryTest {
}
@Test
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() =
fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
assertNull(row(1, "s1"))
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
@@ -0,0 +1,49 @@
package com.subtracks.data.repo
import com.subtracks.data.model.QueueEntry
suspend fun QueueRepository.replace(
entries: List<QueueEntry>,
shuffleSeed: Long? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
) = withMutationLock {
replace(entries, shuffleSeed, upNext, upNextAnchor)
}
suspend fun QueueRepository.setShuffle(seed: Long?) =
withMutationLock {
setShuffle(seed)
}
suspend fun QueueRepository.addUpNext(
currentPosition: Long,
entry: QueueEntry,
playNext: Boolean,
): Long = withMutationLock { addUpNext(currentPosition, entry, playNext) }
suspend fun QueueRepository.reanchorFor(
snapshot: QueueSnapshot,
target: Long,
): Reanchored = withMutationLock { reanchorFor(snapshot, target) }
suspend fun QueueRepository.clearUpNext() =
withMutationLock {
clearUpNext()
}
suspend fun QueueRepository.removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
): Boolean = withMutationLock { removeUpNextAt(snapshot, index) }
suspend fun QueueRepository.removeAt(
snapshot: QueueSnapshot,
position: Long,
): Boolean = withMutationLock { removeAt(snapshot, position) }
suspend fun QueueRepository.move(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean = withMutationLock { move(snapshot, from, to) }
@@ -11,13 +11,18 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -79,6 +84,20 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s3"), resolveAll(snapshot))
}
@Test
fun downloadedEntryIgnoresRetainedRowsWhoseSongsArePruned() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
db.libraryDao().deleteSongs(1, listOf("s2"))
val snapshot = repository.snapshotAfter(listOf(repository.downloadedEntry(1)))
assertEquals(1, snapshot.size)
assertEquals(listOf("s1"), resolveAll(snapshot))
}
@Test
fun starredEntryResolvesStarredTracksNewestFirst() =
runTest {
@@ -416,28 +435,427 @@ class QueueRepositoryTest {
assertEquals(4, repository.snapshot().size)
}
@Test
fun aChangedOriginResetMergesEditsAndRemapsTheCurrentItem() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
repository.setCursor(1)
val point = repository.captureResetPoint()
setPlaylist("p1", "s0", "s1", "s2", "s3")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val updated = repository.snapshot()
val entry = updated.entries.single().entry
assertEquals(QueueKind.Playlist, entry.kind)
assertEquals("p1", entry.refId)
assertNull(entry.rangeStart)
assertNull(entry.rangeEnd)
assertEquals(3L, repository.remapPosition(updated, point.current!!))
}
@Test
fun anUnrelatedOriginResetRemapsWithinTheSecondUnchangedFragment() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
setPlaylist("p2", "s4")
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p2"), repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 2)
repository.setCursor(2)
val point = repository.captureResetPoint()
setPlaylist("p2", "s0", "s4")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point)
assertEquals(3L, repository.remapPosition(repository.snapshot(), point.current!!))
assertEquals(3L, repository.cursor())
}
@Test
fun anUnrelatedOriginResetPreservesInterleavedFragmentStarts() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.albumEntry(1, "al2")))
repository.move(snapshot, 3, 1)
repository.setCursor(3)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
assertEquals(4L, repository.remapPosition(repository.snapshot(), point.current!!))
assertEquals(4L, repository.cursor())
}
@Test
fun aMovedPlaylistOccurrenceSurvivesReconstructionAndReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s1")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.move(snapshot, 2, 0)
repository.setCursor(0)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(2L, repository.remapPosition(repository.snapshot(), point.current!!))
assertEquals(2L, repository.cursor())
}
@Test
fun aRemovedDuplicateOccurrenceDoesNotLeaveItsOffsetOnTheSurvivor() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s1")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.setCursor(1)
repository.setPosition(42_000L)
val point = repository.captureResetPoint()
setPlaylist("p1", "s1")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(0L, repository.cursorPositionMs())
}
@Test
fun anUnrelatedOriginResetKeepsTheEditedQueue() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
val edited = repository.snapshot().entries.map { it.entry }
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point)
assertEquals(edited, repository.snapshot().entries.map { it.entry })
}
@Test
fun anOriginResetRebuildsOnlyTheChangedUpNextBlock() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false)
repository.addUpNext(
0,
repository.albumEntry(1, "al2").copy(rangeStart = 1, rangeEnd = 1),
playNext = false,
)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val upNext = repository.snapshot().upNext.map { it.entry }
assertEquals(QueueKind.Playlist, upNext[0].kind)
assertNull(upNext[0].rangeStart)
assertEquals(QueueKind.Album, upNext[1].kind)
assertEquals(1L, upNext[1].rangeStart)
assertEquals(1L, upNext[1].rangeEnd)
}
@Test
fun anOriginResetPreservesDuplicateWholeUpNextEntries() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val updated = repository.snapshot()
assertEquals(2, updated.upNext.size)
assertEquals(6L, updated.upNextSize)
assertTrue(updated.upNext.all { it.entry.rangeStart == null && it.entry.rangeEnd == null })
}
@Test
fun aResetRemapsCurrentWithinTheSecondDuplicateUpNextEntry() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2")
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
repository.setCursor(4)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(4L, repository.cursor())
}
@Test
fun aResetRemapsCurrentAndAnchorWithinTheSecondDuplicateContextEntry() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"), repository.playlistEntry(1, "p1")))
repository.addUpNext(3, repository.songEntry(1, "s4"), playNext = false)
repository.setCursor(3)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(3L, repository.cursor())
assertEquals(3L, repository.snapshot().upNextAnchor)
}
@Test
fun aResetRemapsTheSecondOccurrenceOfASongWithinAPlaylist() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s1")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.setCursor(2)
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(2L, repository.cursor())
}
@Test
fun aRemovedShuffledAnchorFallbackStaysInPlayCoordinates() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3", "s4", "s5")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.addUpNext(2, repository.songEntry(1, "s4"), playNext = false)
val seed = (1L..100L).first { Shuffle.toSequence(it, 4, 2) != 2L }
repository.setShuffle(seed)
val point = repository.captureResetPoint()
setPlaylist("p1", "s1", "s2", "s4", "s5")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
repository.setShuffle(seed)
val updated = repository.snapshot()
assertEquals(updated.flatContext(point.anchor!!.position), updated.upNextAnchor)
}
@Test
fun aResetDoesNotRestoreShuffleAfterTheUserDisablesIt() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
repository.setShuffle(42L)
val point = repository.captureResetPoint()
repository.setShuffle(null)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertFalse(repository.snapshot().shuffled)
}
@Test
fun aResetPreservesShuffleEnabledDuringSync() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
val point = repository.captureResetPoint()
repository.setShuffle(99L)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertTrue(repository.snapshot().shuffled)
assertEquals(99L, repository.snapshot().shuffleSeed)
}
@Test
fun aConcurrentUpNextEditSurvivesAReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 1)
val point = repository.captureResetPoint()
val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
val edit = launch { repository.addUpNext(0, repository.songEntry(1, "s4"), playNext = false) }
reset.join()
edit.join()
assertEquals(1L, repository.snapshot().upNextSize)
}
@Test
fun staleContextEditsCannotOverwriteACompletedReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val initial = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(initial, 1)
val stale = repository.snapshot()
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val canonical = repository.snapshot().entries.map { it.entry }
repository.removeAt(stale, 0)
assertFalse(repository.move(stale, 0, 1))
assertEquals(canonical, repository.snapshot().entries.map { it.entry })
}
@Test
fun staleUpNextRemovalCannotOverwriteACompletedReset() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false)
val stale = repository.snapshot()
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
val canonical = repository.snapshot().upNext.map { it.entry }
repository.removeUpNextAt(stale, 0)
assertEquals(canonical, repository.snapshot().upNext.map { it.entry })
}
@Test
fun cacheInvalidationDoesNotAdvanceTheQueueContentVersion() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
val before = repository.snapshot().version
repository.invalidateLibraryCache()
assertEquals(before, repository.snapshot().version)
}
@Test
fun aResetPrefersTheLatestUpNextAnchorOverTheCapturedOne() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false)
val point = repository.captureResetPoint()
val navigated = repository.snapshot()
repository.reanchorFor(navigated, 2)
repository.setCursor(repository.snapshot().anchorPlay + 1)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
assertEquals(1L, repository.snapshot().upNextAnchor)
}
@Test
fun aResetPreservesANewBeforeFirstUpNextAnchor() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false)
val point = repository.captureResetPoint()
repository.removeAt(repository.snapshot(), 0)
assertEquals(-1L, repository.snapshot().upNextAnchor)
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
assertEquals(-1L, repository.snapshot().upNextAnchor)
}
@Test
fun aDeletedCurrentSongDoesNotLeaveItsPositionOnTheSuccessor() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 2)))
repository.setCursor(0)
repository.setPosition(50_000)
val point = repository.captureResetPoint()
setPlaylist("p1", "s2", "s3")
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
assertEquals(0L, repository.cursorPositionMs())
}
@Test
fun resetWaitsForCursorUpdatesBeforeWritingItsCursor() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2", "s3")
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.removeAt(snapshot, 0)
val point = repository.captureResetPoint()
repository.cursorMutex.lock()
val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
val repeat = launch { repository.setRepeat(2) }
try {
yield()
assertFalse(reset.isCompleted)
assertFalse(repeat.isCompleted)
} finally {
repository.cursorMutex.unlock()
}
reset.join()
repeat.join()
assertEquals(2, repository.modes().repeat)
}
@Test
fun upNextResolvesAfterTheCurrentTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
}
@Test
fun reAddingUpNextAfterItsLibraryOriginChangedResolvesFreshRows() =
runTest {
seedLibrary()
setPlaylist("p1", "s1", "s2")
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
val initial = repository.snapshot()
assertEquals(listOf("s1", "s2"), (1L..2L).map { repository.itemAt(initial, it)?.song?.id })
repository.clearUpNext()
setPlaylist("p1", "s1", "s2", "s3")
val point = repository.captureResetPoint()
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
val updated = repository.snapshot()
assertEquals(3L, updated.upNextSize)
assertEquals(listOf("s1", "s2", "s3"), (1L..3L).map { repository.itemAt(updated, it)?.song?.id })
}
@Test
fun playNextGoesToTheFrontOfTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = true,
@@ -451,7 +869,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.removeAt(repository.snapshot(), 1)
@@ -465,9 +883,8 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = false,
@@ -486,7 +903,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.move(repository.snapshot(), from = 2, to = 1)
@@ -509,9 +926,8 @@ class QueueRepositoryTest {
),
)
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false)
repository.addUpNext(currentPosition = 0, entry = albumRange(1, 4), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "x1"),
playNext = false,
@@ -562,7 +978,7 @@ class QueueRepositoryTest {
val shuffled = repository.snapshot()
val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id }
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
@@ -580,7 +996,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
@@ -593,7 +1009,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
@@ -606,7 +1022,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
@@ -621,7 +1037,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
@@ -634,7 +1050,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.replace(listOf(repository.songEntry(1, "s5")))
@@ -783,98 +1199,22 @@ class QueueRepositoryTest {
}
@Test
fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() =
fun removingAContextTrackWhileShuffledIsRejected() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L)
val before = repository.snapshot()
val entriesBefore = before.entries.map { it.entry }
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 1)
val after = repository.snapshot()
assertTrue(after.shuffled)
assertEquals(2L, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter)
}
@Test
fun severalRemovalsWhileShuffledKeepTheRemainingOrder() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L)
val before = repository.snapshot()
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 0)
repository.removeAt(repository.snapshot(), 0)
val after = repository.snapshot()
assertTrue(after.shuffled)
assertEquals(1L, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(orderBefore.drop(2), orderAfter)
}
@Test
fun removingWhileShuffledKeepsTheOrderInverse() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.setShuffle(9L)
repository.removeAt(repository.snapshot(), 2)
val after = repository.snapshot()
assertEquals(3L, after.contextSize)
for (play in 0 until after.contextSize) {
assertEquals(play, after.playContext(after.flatContext(play)!!))
}
assertEquals((0L until after.contextSize).toList(), (0 until after.contextSize).map { after.flatContext(it)!! }.sorted())
}
@Test
fun removingAnyPositionWhileShuffledKeepsTheCoordinatesConsistent() =
runTest {
seedLibrary()
for (seed in listOf(1L, 42L, 999L)) {
for (position in 0L..4L) {
repository.replace(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(seed)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s4"),
playNext = false,
)
val before = repository.snapshot()
if (position >= before.size) continue
repository.removeAt(before, position)
val after = repository.snapshot()
assertTrue("seed=$seed position=$position stopped being shuffled", after.shuffled)
assertEquals(before.size - 1, after.size)
val flats = (0 until after.contextSize).map { after.flatContext(it)!! }
assertEquals((0L until after.contextSize).toList(), flats.sorted())
for (play in 0 until after.contextSize) {
assertEquals(play, after.playContext(after.flatContext(play)!!))
}
for (p in 0 until after.size) {
if (after.isUpNext(p)) continue
assertEquals(p, after.combined(after.contextPlay(p)))
}
val block = (0 until after.size).filter { after.isUpNext(it) }
assertEquals(
(after.anchorPlay + 1..after.anchorPlay + after.upNextSize).toList(),
block,
)
val ids = (0 until after.size).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(ids.distinct().size, ids.size)
}
}
assertEquals(before.contextSize, after.contextSize)
assertEquals(entriesBefore, after.entries.map { it.entry })
assertEquals(orderBefore, (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id })
}
@Test
@@ -932,7 +1272,7 @@ class QueueRepositoryTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(1L, updated.anchorPlay)
@@ -947,13 +1287,13 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val before = repository.snapshot()
assertEquals(0L, before.anchorPlay)
assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
repository.setUpNextAnchor(2)
repository.reanchorFor(before, 3)
val after = repository.snapshot()
assertEquals(2L, after.anchorPlay)
@@ -966,7 +1306,7 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
@@ -1105,6 +1445,16 @@ class QueueRepositoryTest {
created = 0,
)
private suspend fun setPlaylist(
id: String,
vararg songIds: String,
) {
db.libraryDao().deletePlaylistSongsFrom(1, id, 0)
db.libraryDao().upsertPlaylistSongs(
songIds.mapIndexed { position, songId -> PlaylistSong(1, id, songId, position.toLong()) },
)
}
private suspend fun seedLibrary() {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
@@ -101,7 +101,7 @@ class SyncManagerTest {
}
@Test
fun aFailedSyncStillInvalidatesTheLibraryCache() =
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
runBlocking {
val before = queueRepository.snapshot().version
@@ -109,7 +109,7 @@ class SyncManagerTest {
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertTrue(status is SyncStatus.Failed)
assertEquals(before + 1, queueRepository.snapshot().version)
assertEquals(before, queueRepository.snapshot().version)
}
@Test
@@ -36,6 +36,9 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.removeAt
import com.subtracks.data.repo.replace
import com.subtracks.data.repo.setShuffle
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -55,7 +58,6 @@ import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -2116,40 +2118,28 @@ class PlaybackControllerTest {
}
@Test
fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() {
fun removingATrackWhileShuffledIsRejected() {
seedAlbum(6, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.toggleShuffle()
await { controller.state.value.shuffle }
val currentId =
controller.state.value.item!!
.id
val removeAt = (controller.state.value.position!! + 1) % 6
val before = runBlocking { queues.snapshot() }
val itemsBefore = handle.items.map { it.id }
runBlocking { controller.removeAt(removeAt) }
await {
controller.state.value.item
?.id == currentId && controller.state.value.shuffle
}
runBlocking { controller.removeAt((controller.state.value.position!! + 1) % 6) }
assertTrue(handle.items.map { it.id }.contains(currentId))
val remaining =
runBlocking {
val snap = queues.snapshot()
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
}
assertEquals(5, remaining.size)
assertEquals(5, remaining.distinct().size)
val after = runBlocking { queues.snapshot() }
assertEquals(before.contextSize, after.contextSize)
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals(itemsBefore, handle.items.map { it.id })
}
@Test
fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() {
fun removingThePlayingTrackWhileShuffledLeavesTheBlockUnchanged() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
runBlocking {
@@ -2161,30 +2151,24 @@ class PlaybackControllerTest {
await { controller.state.value.item != null && controller.state.value.shuffle }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
val before = runBlocking { queues.snapshot() }
val currentId = handle.currentItem?.id
val current = controller.state.value.position!!
runBlocking { controller.removeAt(current) }
runBlocking { controller.removeAt(controller.state.value.position!!) }
// The block was queued behind the removed track, so it takes the slot and plays now.
await {
controller.state.value.item
?.id == "x1"
}
val after = runBlocking { queues.snapshot() }
assertTrue(after.shuffled)
assertEquals(
"x1",
runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id },
)
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals(before.upNextAnchor, after.upNextAnchor)
assertEquals(1L, after.upNextSize)
assertEquals(currentId, handle.currentItem?.id)
}
@Test
fun removingTheAnchorWhilePlayingABlockTrackKeepsTheBlockInPlace() {
fun removingTheAnchorWhilePlayingABlockTrackIsRejected() {
seedShuffledQueue(seed = 5L)
seedSong("x1", "al2")
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.playAt(1)
await {
controller.state.value.item
@@ -2195,8 +2179,9 @@ class PlaybackControllerTest {
runBlocking { controller.removeAt(before.anchorPlay) }
val after = runBlocking { queues.snapshot() }
assertEquals(-1L, after.anchorPlay)
assertEquals("x1", runBlocking { queues.itemAt(after, 0)?.song?.id })
assertEquals(before.upNextAnchor, after.upNextAnchor)
assertEquals(before.contextSize, after.contextSize)
assertEquals("x1", runBlocking { queues.itemAt(after, 1)?.song?.id })
assertEquals(
"x1",
controller.state.value.item
@@ -2205,20 +2190,18 @@ class PlaybackControllerTest {
}
@Test
fun removingThePlayingTrackWhileShuffledPlaysTheNextShuffledTrack() {
fun removingThePlayingTrackWhileShuffledDoesNotAdvance() {
seedShuffledQueue(seed = 12345L)
val orderBefore = resolvedIds()
val current = controller.state.value.position!!
val expected = orderBefore.toMutableList().apply { removeAt(current.toInt()) }[current.toInt()]
val currentId = handle.currentItem?.id
val before = runBlocking { queues.snapshot() }
runBlocking { controller.removeAt(current) }
runBlocking { controller.removeAt(controller.state.value.position!!) }
await {
controller.state.value.item
?.id == expected
}
val after = runBlocking { queues.snapshot() }
assertEquals(expected, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
assertEquals(currentId, handle.currentItem?.id)
assertEquals(orderBefore, resolvedIds())
}
@Test
@@ -2275,73 +2258,6 @@ class PlaybackControllerTest {
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id })
}
@Test
fun undoingARemovalRestoresTheShuffledOrder() {
seedShuffledQueue(seed = 42L)
val orderBefore = resolvedIds()
val currentPosition = controller.state.value.position!!
val before = runBlocking { queues.snapshot() }
val removePosition = (0 until before.size).first { it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
assertNotEquals(orderBefore, resolvedIds())
runBlocking { controller.undo() }
await { controller.state.value.shuffle && resolvedIds() == orderBefore }
assertEquals(orderBefore, resolvedIds())
assertEquals(currentPosition, controller.state.value.position)
}
@Test
fun removingANonCurrentTrackWhileShuffledKeepsTheCursorOnThePlayingTrack() {
seedShuffledQueue(seed = 12345L)
val currentId =
controller.state.value.item!!
.id
val before = runBlocking { queues.snapshot() }
val currentPosition = controller.state.value.position!!
val removePosition = (0 until before.size).first { it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
val after = runBlocking { queues.snapshot() }
assertEquals(
currentId,
controller.state.value.item
?.id,
)
assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
}
@Test
fun removingANonCurrentTrackWhileShuffledKeepsTheBlockNext() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
runBlocking {
queues.replace(listOf(queues.albumEntry(1, "al1")))
queues.setShuffle(999L)
queues.setCursor(0)
}
controller.connect()
await { controller.state.value.item != null && controller.state.value.shuffle }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
val currentId =
controller.state.value.item!!
.id
val before = runBlocking { queues.snapshot() }
val currentPosition = controller.state.value.position!!
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
val after = runBlocking { queues.snapshot() }
assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
}
@Test
fun movingContextTracksWhileShuffledIsIgnored() {
seedAlbum(6, sourceId = 1)
@@ -2799,39 +2715,6 @@ class PlaybackControllerTest {
)
}
@Test
fun removingAContextTrackWhileShuffledKeepsThePlayingTrackAndTheBlock() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.toggleShuffle()
await { controller.state.value.shuffle && runBlocking { controller.upcomingItem()?.id } == "x1" }
val currentId =
controller.state.value.item!!
.id
val before = runBlocking { queues.snapshot() }
val currentPosition = controller.state.value.position!!
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
await {
val snap = runBlocking { queues.snapshot() }
controller.state.value.item
?.id == currentId &&
runBlocking { queues.itemAt(snap, controller.state.value.position!!)?.song?.id } == currentId
}
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
}
@Test
fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
seedAlbum(5, sourceId = 1)
@@ -21,6 +21,8 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.replace
import com.subtracks.data.repo.setShuffle
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController