mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
ui: roll back rejected multi-step queue edits
This commit is contained in:
@@ -120,7 +120,7 @@ class QueueViewModel(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private var reorderPending = false
|
private var reorderPending = false
|
||||||
private var lastReorder: ReorderRollback? = null
|
private var dragRollback: ReorderRollback? = null
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -251,14 +251,13 @@ class QueueViewModel(
|
|||||||
fromIndex: Int,
|
fromIndex: Int,
|
||||||
toIndex: Int,
|
toIndex: Int,
|
||||||
) {
|
) {
|
||||||
lastReorder = null
|
|
||||||
if (fromIndex == toIndex) return
|
if (fromIndex == toIndex) return
|
||||||
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
|
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
|
||||||
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
|
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
|
||||||
if (rows[fromIndex].resetVersion != rows[toIndex].resetVersion) return
|
if (rows[fromIndex].resetVersion != rows[toIndex].resetVersion) return
|
||||||
val before = rows.map { it.id }
|
val before = dragRollback?.before ?: rows.map { it.id }
|
||||||
rows.add(toIndex, rows.removeAt(fromIndex))
|
rows.add(toIndex, rows.removeAt(fromIndex))
|
||||||
lastReorder = ReorderRollback(before, rows.map { it.id })
|
dragRollback = ReorderRollback(before, rows.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(position: Long) {
|
fun play(position: Long) {
|
||||||
@@ -275,7 +274,7 @@ class QueueViewModel(
|
|||||||
|
|
||||||
private fun rejectMove(
|
private fun rejectMove(
|
||||||
edit: ReorderRollback?,
|
edit: ReorderRollback?,
|
||||||
onComplete: (Boolean) -> Unit,
|
onComplete: (Boolean) -> Unit = {},
|
||||||
): Boolean {
|
): Boolean {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
@@ -287,27 +286,28 @@ class QueueViewModel(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelMove() {
|
||||||
|
val edit = dragRollback ?: return
|
||||||
|
dragRollback = null
|
||||||
|
rejectMove(edit)
|
||||||
|
}
|
||||||
|
|
||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
targetResetVersion: Long? = null,
|
targetResetVersion: Long? = null,
|
||||||
onComplete: (Boolean) -> Unit = {},
|
onComplete: (Boolean) -> Unit = {},
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val edit = lastReorder
|
val edit = dragRollback
|
||||||
lastReorder = null
|
dragRollback = null
|
||||||
if (from == to || reorderPending) {
|
if (from == to || reorderPending) return rejectMove(edit, onComplete)
|
||||||
onComplete(false)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val resetVersion = rows.firstOrNull { it.position == from }?.resetVersion ?: return rejectMove(edit, onComplete)
|
val resetVersion = rows.firstOrNull { it.position == from }?.resetVersion ?: return rejectMove(edit, onComplete)
|
||||||
val targetResetVersion = targetResetVersion ?: rows.firstOrNull { it.position == to }?.resetVersion ?: resetVersion
|
val targetResetVersion = targetResetVersion ?: rows.firstOrNull { it.position == to }?.resetVersion ?: resetVersion
|
||||||
if (resetVersion != targetResetVersion) return rejectMove(edit, onComplete)
|
if (resetVersion != targetResetVersion) return rejectMove(edit, onComplete)
|
||||||
reorderPending = true
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val contentVersion = queueRepository.contentVersion()
|
val applied = playbackController.move(from, to, resetVersion)
|
||||||
playbackController.move(from, to, resetVersion)
|
|
||||||
val applied = queueRepository.contentVersion() != contentVersion
|
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (!applied) rollbackReorder(edit)
|
if (!applied) rollbackReorder(edit)
|
||||||
reconcileLocked()
|
reconcileLocked()
|
||||||
@@ -340,10 +340,14 @@ class QueueViewModel(
|
|||||||
reconcileLocked()
|
reconcileLocked()
|
||||||
return@withLock false
|
return@withLock false
|
||||||
}
|
}
|
||||||
val contentVersion = queueRepository.contentVersion()
|
val applied = playbackController.removeAt(expected.position, expected.resetVersion)
|
||||||
playbackController.removeAt(expected.position, expected.resetVersion)
|
if (applied) {
|
||||||
val applied = queueRepository.contentVersion() != contentVersion
|
val currentIndex =
|
||||||
if (applied) rows.removeAt(index)
|
rows.indexOfFirst {
|
||||||
|
it.id == expected.id && it.position == expected.position && it.resetVersion == expected.resetVersion
|
||||||
|
}
|
||||||
|
if (currentIndex >= 0) rows.removeAt(currentIndex)
|
||||||
|
}
|
||||||
reconcileLocked()
|
reconcileLocked()
|
||||||
applied
|
applied
|
||||||
}
|
}
|
||||||
@@ -395,6 +399,7 @@ fun QueueRoute(
|
|||||||
onRemove = { position, onComplete -> viewModel.remove(position, onComplete) },
|
onRemove = { position, onComplete -> viewModel.remove(position, onComplete) },
|
||||||
onReorder = viewModel::reorder,
|
onReorder = viewModel::reorder,
|
||||||
onMove = { move, onComplete -> viewModel.move(move.from, move.to, move.resetVersion, onComplete) },
|
onMove = { move, onComplete -> viewModel.move(move.from, move.to, move.resetVersion, onComplete) },
|
||||||
|
onCancelMove = viewModel::cancelMove,
|
||||||
onLoadOlder = viewModel::loadOlder,
|
onLoadOlder = viewModel::loadOlder,
|
||||||
onLoadNewer = viewModel::loadNewer,
|
onLoadNewer = viewModel::loadNewer,
|
||||||
onUndo = viewModel::undo,
|
onUndo = viewModel::undo,
|
||||||
@@ -414,7 +419,8 @@ fun QueueScreen(
|
|||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long, (Boolean) -> Unit) -> Unit,
|
onRemove: (Long, (Boolean) -> Unit) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (QueueMove, (Boolean) -> Unit) -> Boolean,
|
onMove: (QueueMove, (Boolean) -> Unit) -> Unit,
|
||||||
|
onCancelMove: () -> Unit,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -554,10 +560,11 @@ fun QueueScreen(
|
|||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = { dragId = row.id },
|
onDragStarted = { dragId = row.id },
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
dragId?.let { id ->
|
val move = dragId?.let { id -> rows.dropMoveFor(id) }
|
||||||
rows.dropMoveFor(id)?.let { move ->
|
if (move == null) {
|
||||||
onMove(move) { applied -> if (applied) showUndo(queueReordered) }
|
onCancelMove()
|
||||||
}
|
} else {
|
||||||
|
onMove(move) { applied -> if (applied) showUndo(queueReordered) }
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -573,7 +573,8 @@ class AppScreenshotTest {
|
|||||||
onPlay = {},
|
onPlay = {},
|
||||||
onRemove = { _, _ -> },
|
onRemove = { _, _ -> },
|
||||||
onReorder = { _, _ -> },
|
onReorder = { _, _ -> },
|
||||||
onMove = { _, _ -> true },
|
onMove = { _, _ -> },
|
||||||
|
onCancelMove = {},
|
||||||
onLoadOlder = {},
|
onLoadOlder = {},
|
||||||
onLoadNewer = {},
|
onLoadNewer = {},
|
||||||
onUndo = {},
|
onUndo = {},
|
||||||
|
|||||||
@@ -78,10 +78,7 @@ class QueueScreenTest {
|
|||||||
visible: List<QueueRow> = rows,
|
visible: List<QueueRow> = rows,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false,
|
||||||
onRemove: (Long, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
|
onRemove: (Long, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
|
||||||
onMove: (QueueMove, (Boolean) -> Unit) -> Boolean = { _, onComplete ->
|
onMove: (QueueMove, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
|
||||||
onComplete(false)
|
|
||||||
true
|
|
||||||
},
|
|
||||||
onUndo: () -> Unit = {},
|
onUndo: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val visibleRows = mutableStateListOf<QueueRow>().also { it.addAll(visible) }
|
val visibleRows = mutableStateListOf<QueueRow>().also { it.addAll(visible) }
|
||||||
@@ -99,6 +96,7 @@ class QueueScreenTest {
|
|||||||
onRemove = onRemove,
|
onRemove = onRemove,
|
||||||
onReorder = { from, to -> visibleRows.add(to, visibleRows.removeAt(from)) },
|
onReorder = { from, to -> visibleRows.add(to, visibleRows.removeAt(from)) },
|
||||||
onMove = onMove,
|
onMove = onMove,
|
||||||
|
onCancelMove = {},
|
||||||
onLoadOlder = {},
|
onLoadOlder = {},
|
||||||
onLoadNewer = {},
|
onLoadNewer = {},
|
||||||
onUndo = onUndo,
|
onUndo = onUndo,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import com.subtracks.playback.FakePlayerHandle
|
|||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -36,11 +37,13 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -50,7 +53,9 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@@ -536,6 +541,164 @@ class QueueViewModelTest {
|
|||||||
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveWaitingForQueuedResetDoesNotReportTheResetAsApplied() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val point = prepareAlbumReset("s0", 0)
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val reset =
|
||||||
|
repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
|
||||||
|
}
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
assertTrue(viewModel.move(1, 2, onComplete = completion::complete))
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removalWaitingForQueuedResetDoesNotReportTheResetAsApplied() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val point = prepareAlbumReset("s0", 0)
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val reset =
|
||||||
|
repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
|
||||||
|
}
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.remove(2, completion::complete)
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedTwoCallbackSparseTailDragRestoresTheDragBaseline() {
|
||||||
|
openReconciledSparseOfflineQueue(includeFarTail = true)
|
||||||
|
val canonical = resolvedQueueIds()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val dragged = before[1]
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
viewModel.reorder(2, 3)
|
||||||
|
val move = viewModel.rows.dropMoveFor(dragged.id)!!
|
||||||
|
assertTrue(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(canonical, resolvedQueueIds())
|
||||||
|
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
|
||||||
|
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedMixedGenerationCrossingRestoresTheWholeDrag() {
|
||||||
|
runOnMain {
|
||||||
|
viewModel.rows += row(0)
|
||||||
|
viewModel.rows += row(1)
|
||||||
|
viewModel.rows += row(2)
|
||||||
|
}
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val dragged = before[0]
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reorder(0, 1)
|
||||||
|
viewModel.rows[2] = viewModel.rows[2].copy(resetVersion = 1)
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
val move = viewModel.rows.dropMoveFor(dragged.id)!!
|
||||||
|
assertFalse(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
|
||||||
|
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun secondOptimisticDragRejectedWhileMovePendingRollsBack() {
|
||||||
|
openSparseOfflineQueue()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val visibleIds = before.map { it.song.song.id }.toSet()
|
||||||
|
val expectedFirst = before.toMutableList().also { it.add(1, it.removeAt(0)) }.map { it.song.song.id }
|
||||||
|
val expectedSecond =
|
||||||
|
before
|
||||||
|
.toMutableList()
|
||||||
|
.also { it.add(1, it.removeAt(0)) }
|
||||||
|
.also { it.add(1, it.removeAt(2)) }
|
||||||
|
.map { it.song.song.id }
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val firstCompletion = CompletableDeferred<Boolean>()
|
||||||
|
val secondCompletion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.reorder(0, 1)
|
||||||
|
val firstMove = viewModel.rows.dropMoveFor(before[0].id)!!
|
||||||
|
assertTrue(viewModel.move(firstMove.from, firstMove.to, firstMove.resetVersion, firstCompletion::complete))
|
||||||
|
yield()
|
||||||
|
viewModel.reorder(2, 1)
|
||||||
|
assertEquals(expectedSecond, viewModel.rows.map { it.song.song.id })
|
||||||
|
val secondMove = viewModel.rows.dropMoveFor(before[2].id)!!
|
||||||
|
assertFalse(viewModel.move(secondMove.from, secondMove.to, secondMove.resetVersion, secondCompletion::complete))
|
||||||
|
}
|
||||||
|
await { rowsSnapshot().map { it.song.song.id } == expectedFirst }
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(awaitCompletion(firstCompletion))
|
||||||
|
assertFalse(awaitCompletion(secondCompletion))
|
||||||
|
assertEquals(expectedFirst, resolvedQueueIds().filter { it in visibleIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removalAfterInterleavedReorderRemovesTheCapturedRow() {
|
||||||
|
openSparseOfflineQueue()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.remove(before[2].position, completion::complete)
|
||||||
|
yield()
|
||||||
|
viewModel.reorder(0, 2)
|
||||||
|
}
|
||||||
|
assertEquals(listOf("s801", "s901", "s1"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(awaitCompletion(completion))
|
||||||
|
assertEquals(listOf("s1", "s801"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
assertEquals(listOf("s1", "s801"), resolvedQueueIds().filter { it in setOf("s1", "s801", "s901") })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun moveRejectsRowsFromDifferentResetGenerations() {
|
fun moveRejectsRowsFromDifferentResetGenerations() {
|
||||||
runOnMain {
|
runOnMain {
|
||||||
@@ -594,11 +757,12 @@ class QueueViewModelTest {
|
|||||||
assertEquals(3L, controller.state.value.position)
|
assertEquals(3L, controller.state.value.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openReconciledSparseOfflineQueue() {
|
private fun openSparseOfflineQueue(includeFarTail: Boolean = false) {
|
||||||
runBlocking { seedSongs(901) }
|
runBlocking { seedSongs(if (includeFarTail) 1001 else 901) }
|
||||||
markDownloaded(1, "s1")
|
markDownloaded(1, "s1")
|
||||||
markDownloaded(1, "s801")
|
markDownloaded(1, "s801")
|
||||||
markDownloaded(1, "s901")
|
markDownloaded(1, "s901")
|
||||||
|
if (includeFarTail) markDownloaded(1, "s1001")
|
||||||
setOffline()
|
setOffline()
|
||||||
controller.playAlbum(1, "al1", 0)
|
controller.playAlbum(1, "al1", 0)
|
||||||
await {
|
await {
|
||||||
@@ -608,12 +772,17 @@ class QueueViewModelTest {
|
|||||||
viewModel.open()
|
viewModel.open()
|
||||||
await { viewModel.ready }
|
await { viewModel.ready }
|
||||||
viewModel.loadNewer()
|
viewModel.loadNewer()
|
||||||
await { rowsSnapshot().map { it.position } == listOf(0L, 800L, 900L) }
|
val expected = if (includeFarTail) listOf(0L, 800L, 900L, 1000L) else listOf(0L, 800L, 900L)
|
||||||
|
await { rowsSnapshot().map { it.position } == expected }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReconciledSparseOfflineQueue(includeFarTail: Boolean = false) {
|
||||||
|
openSparseOfflineQueue(includeFarTail)
|
||||||
resetAlbumWith("s0", 0)
|
resetAlbumWith("s0", 0)
|
||||||
viewModel.reconcile()
|
viewModel.reconcile()
|
||||||
await { rowsSnapshot().firstOrNull()?.resetVersion == 1L }
|
await { rowsSnapshot().firstOrNull()?.resetVersion == 1L }
|
||||||
assertEquals(listOf(1L, 800L, 900L), rowsSnapshot().map { it.position })
|
val expected = if (includeFarTail) listOf(1L, 800L, 900L, 1000L) else listOf(1L, 800L, 900L)
|
||||||
|
assertEquals(expected, rowsSnapshot().map { it.position })
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetAlbumWith(
|
private fun resetAlbumWith(
|
||||||
@@ -621,31 +790,37 @@ class QueueViewModelTest {
|
|||||||
track: Long,
|
track: Long,
|
||||||
) {
|
) {
|
||||||
val layout = controller.state.value.layout
|
val layout = controller.state.value.layout
|
||||||
runBlocking {
|
val point = prepareAlbumReset(songId, track)
|
||||||
val point = queues.captureResetPoint()
|
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point) }
|
||||||
db.libraryDao().upsertSongs(
|
|
||||||
listOf(
|
|
||||||
Song(
|
|
||||||
sourceId = 1,
|
|
||||||
id = songId,
|
|
||||||
albumId = "al1",
|
|
||||||
artistId = "ar1",
|
|
||||||
title = "Song $songId",
|
|
||||||
album = "Album",
|
|
||||||
artist = "Artist",
|
|
||||||
duration = 100,
|
|
||||||
track = track,
|
|
||||||
disc = 1,
|
|
||||||
starred = null,
|
|
||||||
genre = null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
|
|
||||||
}
|
|
||||||
await { controller.state.value.layout > layout }
|
await { controller.state.value.layout > layout }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun prepareAlbumReset(
|
||||||
|
songId: String,
|
||||||
|
track: Long,
|
||||||
|
) = runBlocking {
|
||||||
|
val point = queues.captureResetPoint()
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = songId,
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song $songId",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = track,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
point
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolvedQueueIds(): List<String> =
|
private fun resolvedQueueIds(): List<String> =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val snapshot = queues.snapshot()
|
val snapshot = queues.snapshot()
|
||||||
@@ -770,12 +945,33 @@ class QueueViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pauseQueueMutation(): Pair<CountDownLatch, Job> {
|
||||||
|
val locked = CountDownLatch(1)
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
val locker =
|
||||||
|
repoScope.launch {
|
||||||
|
queues.withMutationLock {
|
||||||
|
locked.countDown()
|
||||||
|
release.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(locked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
|
||||||
|
return release to locker
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resumeQueueMutation(paused: Pair<CountDownLatch, Job>) {
|
||||||
|
paused.first.countDown()
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { paused.second.join() } }
|
||||||
|
}
|
||||||
|
|
||||||
// `rows` is a snapshot list mutated on the main dispatcher, so read it there rather than from
|
// `rows` is a snapshot list mutated on the main dispatcher, so read it there rather than from
|
||||||
// the test thread (otherwise iterating it races the view model and throws or reads torn state).
|
// the test thread (otherwise iterating it races the view model and throws or reads torn state).
|
||||||
private fun rowsSnapshot(): List<QueueRow> = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } }
|
private fun rowsSnapshot(): List<QueueRow> = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } }
|
||||||
|
|
||||||
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
|
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
|
||||||
|
|
||||||
|
private fun runOnMainSuspending(block: suspend () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
|
||||||
|
|
||||||
private fun awaitCompletion(completion: CompletableDeferred<Boolean>): Boolean =
|
private fun awaitCompletion(completion: CompletableDeferred<Boolean>): Boolean =
|
||||||
runBlocking { withTimeout(TEST_TIMEOUT_MS) { completion.await() } }
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { completion.await() } }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user