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, thumbnail: Boolean = false,
): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail)
suspend fun upcomingItem(): QueueItem? = suspend fun upcomingItem(): QueueItem? {
var current: QueueSnapshot? = null
var position: Long? = null
startLock.withLock { startLock.withLock {
val snap = snapshot ?: return@withLock null current = snapshot
val position = currentPosition() ?: return@withLock null position = currentPosition()
queueRepository.itemAt(snap, position + 1)?.toQueueItem()
} }
val snapshot = current ?: return null
val at = position ?: return null
return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
}
private fun play( private fun play(
entries: List<QueueEntry>, entries: List<QueueEntry>,
@@ -4,12 +4,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking 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 const val TEST_TIMEOUT_MS = 30_000L
fun cancelAndJoinBlocking(vararg scopes: CoroutineScope) { fun cancelAndJoinBlocking(vararg scopes: CoroutineScope) {
runBlocking { scopes.forEach { it.coroutineContext[Job]?.cancelAndJoin() } } 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.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkFetcher import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -27,9 +28,7 @@ import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -99,7 +98,7 @@ class DownloadRepositoryTest {
@After @After
fun tearDown() { fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() } cancelAndJoinBlocking(scope)
db.close() db.close()
dir.deleteRecursively() dir.deleteRecursively()
} }
@@ -1159,11 +1158,7 @@ class DownloadRepositoryTest {
file.writeText(contents) file.writeText(contents)
} }
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected download state", predicate)
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 companion object { private companion object {
const val ALBUM_ART = "art-al1" const val ALBUM_ART = "art-al1"
@@ -5,6 +5,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -12,9 +13,7 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -65,7 +64,7 @@ class LibraryRepositoryTest {
@After @After
fun tearDown() { fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() } cancelAndJoinBlocking(scope)
db.close() db.close()
} }
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
@@ -15,10 +16,8 @@ import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
@@ -67,7 +66,7 @@ class SourceRepositoryTest {
@After @After
fun tearDown() { fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() } cancelAndJoinBlocking(scope)
db.close() db.close()
} }
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
@@ -13,9 +14,7 @@ import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -66,7 +65,7 @@ class SyncManagerTest {
@After @After
fun tearDown() { fun tearDown() {
runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() } cancelAndJoinBlocking(scope)
db.close() db.close()
server.shutdown() server.shutdown()
} }
@@ -3,14 +3,12 @@ package com.subtracks.di
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi 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.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
@@ -39,15 +37,18 @@ class AppModuleTest {
@After @After
fun tearDown() { fun tearDown() {
if (::koin.isInitialized) { try {
runBlocking { if (::koin.isInitialized) {
koin.get<CoroutineScope>().coroutineContext[Job]?.cancelAndJoin() cancelAndJoinBlocking(
koin.get<CoroutineScope>(named("io")).coroutineContext[Job]?.cancelAndJoin() koin.get<CoroutineScope>(),
koin.get<CoroutineScope>(named("playback")).coroutineContext[Job]?.cancelAndJoin() koin.get<CoroutineScope>(named("io")),
koin.get<CoroutineScope>(named("playback")),
)
} }
} finally {
stopKoin()
Dispatchers.resetMain()
} }
stopKoin()
Dispatchers.resetMain()
} }
@Test @Test
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -3079,9 +3080,5 @@ class PlaybackControllerTest {
return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, scope = scope) return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, scope = scope)
} }
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected playback state", predicate)
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())
}
} }
@@ -6,7 +6,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -177,9 +177,5 @@ class DownloadsViewModelTest {
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() } private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
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())
}
} }
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -518,9 +519,5 @@ class QueueViewModelTest {
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } } private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
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())
}
} }
@@ -6,7 +6,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -151,9 +151,5 @@ class AddSourceViewModelTest {
"<subsonic-response status=\"failed\"><error code=\"40\" message=\"Wrong username\"/></subsonic-response>", "<subsonic-response status=\"failed\"><error code=\"40\" message=\"Wrong username\"/></subsonic-response>",
) )
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
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())
}
} }