From 033ac56cf1cd35d0580ab4ada01bdbeb1c091f8f Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:21:31 +0000 Subject: [PATCH] read queue view rows on the main dispatcher in the view model tests `QueueViewModelTest` read `viewModel.rows` (a `mutableStateListOf` mutated on the main dispatcher) from the test thread, racing the view model: CI saw `ConcurrentModificationException` in `removingWhileShuffledReconcilesTheRows` and a torn read in `removingATrackUnderTheBlockReloadsRows`. Copy the rows on the dispatcher via a `rowsSnapshot()` helper before asserting. --- .../ui/playback/QueueViewModelTest.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index c705858d..abc2b358 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals @@ -146,8 +147,9 @@ class QueueViewModelTest { await { val snapshot = runBlocking { queues.snapshot() } - viewModel.rows.isNotEmpty() && - viewModel.rows.all { runBlocking { queues.itemAt(snapshot, it.position)?.song?.id } == it.song.song.id } + val rows = rowsSnapshot() + rows.isNotEmpty() && + runBlocking { rows.all { queues.itemAt(snapshot, it.position)?.song?.id == it.song.song.id } } } } @@ -310,17 +312,17 @@ class QueueViewModelTest { viewModel.remove(2) await { - val positions = viewModel.rows.map { it.position } + val positions = rowsSnapshot().map { it.position } positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList() } val snapshot = runBlocking { queues.snapshot() } + val rows = rowsSnapshot() val expected = runBlocking { - (viewModel.rows.first().position..viewModel.rows.last().position) - .mapNotNull { queues.itemAt(snapshot, it)?.song?.id } + (rows.first().position..rows.last().position).mapNotNull { queues.itemAt(snapshot, it)?.song?.id } } - assertEquals(expected, viewModel.rows.map { it.song.song.id }) + assertEquals(expected, rows.map { it.song.song.id }) } @Test @@ -440,6 +442,10 @@ class QueueViewModelTest { ) } + // `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). + private fun rowsSnapshot(): List = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } } + private fun await(predicate: () -> Boolean) { repeat(500) { if (predicate()) return