address review: share the test helpers, guard AppModuleTest cleanup

Use cancelAndJoinBlocking in the repo tests and AppModuleTest instead of hand-rolled runBlocking joins, put AppModuleTest's cleanup in a finally so a failed join cannot leave Main overridden and Koin started, and collapse the five polling await copies into one awaitUntil. upcomingItem now reads the queue snapshot under the lock but does the Room read outside it.
This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 88b53821bd
commit 26f58b7502
11 changed files with 48 additions and 57 deletions
@@ -868,12 +868,17 @@ class PlaybackController(
thumbnail: Boolean = false,
): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail)
suspend fun upcomingItem(): QueueItem? =
suspend fun upcomingItem(): QueueItem? {
var current: QueueSnapshot? = null
var position: Long? = null
startLock.withLock {
val snap = snapshot ?: return@withLock null
val position = currentPosition() ?: return@withLock null
queueRepository.itemAt(snap, position + 1)?.toQueueItem()
current = snapshot
position = currentPosition()
}
val snapshot = current ?: return null
val at = position ?: return null
return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
}
private fun play(
entries: List<QueueEntry>,
@@ -4,12 +4,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
// Wall-clock budget for the tests that poll asynchronous playback/repository work on a real
// dispatcher. A loaded CI runner can starve those threads for seconds; this only needs to catch a
// real hang, not a slow machine.
const val TEST_TIMEOUT_MS = 30_000L
fun cancelAndJoinBlocking(vararg scopes: CoroutineScope) {
runBlocking { scopes.forEach { it.coroutineContext[Job]?.cancelAndJoin() } }
}
fun awaitUntil(
message: String,
predicate: () -> Boolean,
) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms $message", predicate())
}
@@ -6,7 +6,8 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
@@ -27,9 +28,7 @@ import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -99,7 +98,7 @@ class DownloadRepositoryTest {
@After
fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(scope)
db.close()
dir.deleteRecursively()
}
@@ -1159,11 +1158,7 @@ class DownloadRepositoryTest {
file.writeText(contents)
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected download state", predicate())
}
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected download state", predicate)
private companion object {
const val ALBUM_ART = "art-al1"
@@ -5,6 +5,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Song
@@ -12,9 +13,7 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -65,7 +64,7 @@ class LibraryRepositoryTest {
@After
fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(scope)
db.close()
}
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
@@ -15,10 +16,8 @@ import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
@@ -67,7 +66,7 @@ class SourceRepositoryTest {
@After
fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(scope)
db.close()
}
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.fakeUserPreferences
@@ -13,9 +14,7 @@ import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -66,7 +65,7 @@ class SyncManagerTest {
@After
fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(scope)
db.close()
server.shutdown()
}
@@ -3,14 +3,12 @@ package com.subtracks.di
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@@ -39,15 +37,18 @@ class AppModuleTest {
@After
fun tearDown() {
if (::koin.isInitialized) {
runBlocking {
koin.get<CoroutineScope>().coroutineContext[Job]?.cancelAndJoin()
koin.get<CoroutineScope>(named("io")).coroutineContext[Job]?.cancelAndJoin()
koin.get<CoroutineScope>(named("playback")).coroutineContext[Job]?.cancelAndJoin()
try {
if (::koin.isInitialized) {
cancelAndJoinBlocking(
koin.get<CoroutineScope>(),
koin.get<CoroutineScope>(named("io")),
koin.get<CoroutineScope>(named("playback")),
)
}
} finally {
stopKoin()
Dispatchers.resetMain()
}
stopKoin()
Dispatchers.resetMain()
}
@Test
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
@@ -3079,9 +3080,5 @@ class PlaybackControllerTest {
return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, scope = scope)
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected playback state", predicate())
}
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected playback state", predicate)
}
@@ -6,7 +6,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
@@ -177,9 +177,5 @@ class DownloadsViewModelTest {
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected state", predicate())
}
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
}
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
@@ -518,9 +519,5 @@ class QueueViewModelTest {
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected state", predicate())
}
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
}
@@ -6,7 +6,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
@@ -151,9 +151,5 @@ class AddSourceViewModelTest {
"<subsonic-response status=\"failed\"><error code=\"40\" message=\"Wrong username\"/></subsonic-response>",
)
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected state", predicate())
}
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
}