let tests own the worker coroutine scopes

The repositories and the playback controller each created a hidden SupervisorJob scope and exposed close() only so tests could join it before closing the DB; production never called close(). Inject the scope instead and have each test cancel and join the scope it passes, so no test-only teardown lives in the app. Koin now provides the app/io/playback scopes.

Also read upcomingItem under the controller lock, and give the wall-clock test waits one shared 30s budget instead of 5s per file: a loaded runner could starve the controller's single thread past the old deadline.
This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 18dc929218
commit 88b53821bd
17 changed files with 220 additions and 155 deletions
@@ -13,11 +13,8 @@ import com.subtracks.R
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
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.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/** /**
* Our own aggregated progress notification for the downloads the platform engine runs. The engine's * Our own aggregated progress notification for the downloads the platform engine runs. The engine's
@@ -27,8 +24,8 @@ import kotlinx.coroutines.runBlocking
class DownloadNotifier( class DownloadNotifier(
private val context: Context, private val context: Context,
private val repository: DownloadRepository, private val repository: DownloadRepository,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val manager = NotificationManagerCompat.from(context) private val manager = NotificationManagerCompat.from(context)
fun start() { fun start() {
@@ -45,12 +42,6 @@ class DownloadNotifier(
} }
} }
internal fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
private fun build(content: DownloadNotification): Notification = private fun build(content: DownloadNotification): Notification =
NotificationCompat NotificationCompat
.Builder(context, CHANNEL_ID) .Builder(context, CHANNEL_ID)
@@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -51,8 +49,8 @@ class DownloadRepository(
private val artworkFetcher: ArtworkFetcher, private val artworkFetcher: ArtworkFetcher,
private val showMessage: (String) -> Unit = {}, private val showMessage: (String) -> Unit = {},
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
) { ) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val mutex = Mutex() private val mutex = Mutex()
private val loaded = CompletableDeferred<Unit>() private val loaded = CompletableDeferred<Unit>()
private var pollJob: Job? = null private var pollJob: Job? = null
@@ -83,12 +81,6 @@ class DownloadRepository(
} }
} }
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -34,6 +34,7 @@ class LibraryRepository(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val serverActions: ServerActionSink, private val serverActions: ServerActionSink,
private val showMessage: (String) -> Unit = {}, private val showMessage: (String) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) { ) {
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId() val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
@@ -280,21 +281,20 @@ class LibraryRepository(
} }
private val starLock = Mutex() private val starLock = Mutex()
private val starScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun star( fun star(
type: StarType, type: StarType,
id: String, id: String,
starred: Boolean, starred: Boolean,
) { ) {
starScope.launch { setStar(type, id, starred) } scope.launch { setStar(type, id, starred) }
} }
fun toggleStar( fun toggleStar(
type: StarType, type: StarType,
id: String, id: String,
) { ) {
starScope.launch { scope.launch {
starLock.withLock { starLock.withLock {
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@withLock val sourceId = sourceRepository.activeSourceIdOnce() ?: return@withLock
setStarLocked(type, id, starredValue(sourceId, type, id) == null) setStarLocked(type, id, starredValue(sourceId, type, id) == null)
@@ -16,9 +16,7 @@ import com.subtracks.data.source.streamLengthSuffix
import com.subtracks.data.source.subsonic.SubsonicClient import com.subtracks.data.source.subsonic.SubsonicClient
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.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -26,7 +24,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -39,9 +36,8 @@ class SourceRepository(
private val artworkStore: ArtworkStore, private val artworkStore: ArtworkStore,
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi), networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi),
private val showMessage: (String) -> Unit = {}, private val showMessage: (String) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private data class ActiveSource( private data class ActiveSource(
val id: Long, val id: Long,
val source: SubsonicMusicSource, val source: SubsonicMusicSource,
@@ -101,12 +97,6 @@ class SourceRepository(
} }
} }
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
fun sources(): Flow<List<Source>> = db.sourcesDao().sources() fun sources(): Flow<List<Source>> = db.sourcesDao().sources()
fun activeSourceId(): Flow<Long?> = db.sourcesDao().activeSourceId() fun activeSourceId(): Flow<Long?> = db.sourcesDao().activeSourceId()
@@ -7,14 +7,11 @@ import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
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.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
sealed interface SyncStatus { sealed interface SyncStatus {
data object Idle : SyncStatus data object Idle : SyncStatus
@@ -33,8 +30,8 @@ class SyncManager(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val showMessage: (String) -> Unit = {}, private val showMessage: (String) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val requests = Channel<Unit>(Channel.CONFLATED) private val requests = Channel<Unit>(Channel.CONFLATED)
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle) private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
val status: StateFlow<SyncStatus> = _status val status: StateFlow<SyncStatus> = _status
@@ -49,12 +46,6 @@ class SyncManager(
requests.trySend(Unit) requests.trySend(Unit)
} }
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
private suspend fun runSync() { private suspend fun runSync() {
if (sourceRepository.offline.value) { if (sourceRepository.offline.value) {
showMessage("Offline mode is on") showMessage("Offline mode is on")
@@ -37,8 +37,12 @@ import com.subtracks.ui.library.PlaylistDetailViewModel
import com.subtracks.ui.playback.QueueViewModel import com.subtracks.ui.playback.QueueViewModel
import com.subtracks.ui.settings.AddSourceViewModel import com.subtracks.ui.settings.AddSourceViewModel
import com.subtracks.ui.settings.SettingsViewModel import com.subtracks.ui.settings.SettingsViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import java.io.File import java.io.File
@@ -53,11 +57,24 @@ fun appModule(
single { createAndroidDatabase(context) } single { createAndroidDatabase(context) }
single { http } single { http }
single { createUserPreferences(context) } single { createUserPreferences(context) }
single { SourceRepository(get(), get(), get(), get(), networkMode = networkMode(context.applicationContext), showMessage = toast) } single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
single {
SourceRepository(
get(),
get(),
get(),
get(),
networkMode = networkMode(context.applicationContext),
showMessage = toast,
scope = get(),
)
}
single<ServerActionSink> { NetworkServerActionSink(get()) } single<ServerActionSink> { NetworkServerActionSink(get()) }
single { LibraryRepository(get(), get(), get(), toast) } single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) } single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get(), showMessage = toast) } single { SyncManager(get(), get(), get(), showMessage = toast, scope = get()) }
single { QueueRepository(get()) } single { QueueRepository(get()) }
single { ArtworkStore(downloadsRoot(context)) } single { ArtworkStore(downloadsRoot(context)) }
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) } single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
@@ -71,16 +88,19 @@ fun appModule(
artworkStore = get(), artworkStore = get(),
artworkFetcher = get(), artworkFetcher = get(),
showMessage = toast, showMessage = toast,
dispatcher = Dispatchers.IO,
scope = get(named("io")),
).also { it.start() } ).also { it.start() }
} }
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get()).also { it.start() } } single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
single(createdAtStart = true) { single(createdAtStart = true) {
val playback = get<PlaybackController>() val playback = get<PlaybackController>()
Scrobbler( Scrobbler(
sink = get(), sink = get(),
enabled = get<UserPreferences>().scrobbling(), enabled = get<UserPreferences>().scrobbling(),
scope = get(),
recordPlay = get<LibraryRepository>()::recordPlay, recordPlay = get<LibraryRepository>()::recordPlay,
).also { ).also {
it.attach(playback.state, playback.positionMs) it.attach(playback.state, playback.positionMs)
@@ -11,12 +11,10 @@ import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.data.repo.Shuffle import com.subtracks.data.repo.Shuffle
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -111,9 +109,8 @@ class PlaybackController(
private val connection: PlayerConnection, private val connection: PlayerConnection,
private val downloads: DownloadRepository, private val downloads: DownloadRepository,
private val showMessage: (String) -> Unit = {}, private val showMessage: (String) -> Unit = {},
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
) { ) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow(PlaybackState()) private val _state = MutableStateFlow(PlaybackState())
val state: StateFlow<PlaybackState> = _state val state: StateFlow<PlaybackState> = _state
@@ -169,14 +166,6 @@ class PlaybackController(
} }
} }
fun close() {
scope.cancel()
}
internal suspend fun awaitStopped() {
scope.coroutineContext[Job]?.join()
}
private val _ready = MutableStateFlow(false) private val _ready = MutableStateFlow(false)
val ready: StateFlow<Boolean> = _ready val ready: StateFlow<Boolean> = _ready
@@ -879,10 +868,11 @@ 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? =
val snap = snapshot ?: return null startLock.withLock {
val position = currentPosition() ?: return null val snap = snapshot ?: return@withLock null
return queueRepository.itemAt(snap, position + 1)?.toQueueItem() val position = currentPosition() ?: return@withLock null
queueRepository.itemAt(snap, position + 1)?.toQueueItem()
} }
private fun play( private fun play(
@@ -0,0 +1,15 @@
package com.subtracks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
// 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() } }
}
@@ -6,6 +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.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
@@ -24,7 +25,11 @@ import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.coverArtKey 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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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
@@ -52,6 +57,7 @@ class DownloadRepositoryTest {
private lateinit var repository: DownloadRepository private lateinit var repository: DownloadRepository
private lateinit var artwork: ArtworkStore private lateinit var artwork: ArtworkStore
private lateinit var dir: File private lateinit var dir: File
private lateinit var scope: CoroutineScope
private val prefs = fakeUserPreferences() private val prefs = fakeUserPreferences()
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private val requestedArt = CopyOnWriteArrayList<String>() private val requestedArt = CopyOnWriteArrayList<String>()
@@ -74,7 +80,8 @@ class DownloadRepositoryTest {
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sources = SourceRepository(db, OkHttpClient(), prefs, artwork) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
engine = FakeDownloadEngine() engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}") dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
repository = repository =
@@ -86,13 +93,13 @@ class DownloadRepositoryTest {
artworkStore = artwork, artworkStore = artwork,
artworkFetcher = fetcher, artworkFetcher = fetcher,
showMessage = { messages += it }, showMessage = { messages += it },
scope = scope,
) )
} }
@After @After
fun tearDown() { fun tearDown() {
repository.close() runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
sources.close()
db.close() db.close()
dir.deleteRecursively() dir.deleteRecursively()
} }
@@ -1153,9 +1160,9 @@ class DownloadRepositoryTest {
} }
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected download state", predicate()) assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected download state", predicate())
} }
private companion object { private companion object {
@@ -10,6 +10,11 @@ import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.prefs.fakeUserPreferences 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.Dispatchers
import kotlinx.coroutines.Job
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
@@ -34,6 +39,7 @@ class LibraryRepositoryTest {
private lateinit var db: SubtracksDatabase private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository private lateinit var sourceRepository: SourceRepository
private lateinit var repository: LibraryRepository private lateinit var repository: LibraryRepository
private lateinit var scope: CoroutineScope
private val messages = ArrayList<String>() private val messages = ArrayList<String>()
@Before @Before
@@ -44,13 +50,22 @@ class LibraryRepositoryTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
repository = LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), showMessage = { messages += it }) sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
repository =
LibraryRepository(
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
showMessage = { messages += it },
scope = scope,
)
} }
@After @After
fun tearDown() { fun tearDown() {
sourceRepository.close() runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
db.close() db.close()
} }
@@ -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.TEST_TIMEOUT_MS
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
@@ -12,8 +13,12 @@ import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences 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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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
@@ -43,6 +48,7 @@ class SourceRepositoryTest {
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
private lateinit var repository: SourceRepository private lateinit var repository: SourceRepository
private lateinit var artwork: ArtworkStore private lateinit var artwork: ArtworkStore
private lateinit var scope: CoroutineScope
private val messages = ArrayList<String>() private val messages = ArrayList<String>()
@Before @Before
@@ -55,12 +61,13 @@ class SourceRepositoryTest {
.build() .build()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
prefs = fakeUserPreferences() prefs = fakeUserPreferences()
repository = SourceRepository(db, OkHttpClient(), prefs, artwork, showMessage = { messages += it }) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
repository = SourceRepository(db, OkHttpClient(), prefs, artwork, showMessage = { messages += it }, scope = scope)
} }
@After @After
fun tearDown() { fun tearDown() {
repository.close() runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
db.close() db.close()
} }
@@ -69,14 +76,14 @@ class SourceRepositoryTest {
runBlocking { runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false) repository.addSource("nav", "http://a.example/", "u", "p", false)
val sourceId = repository.activeSourceId().first()!! val sourceId = repository.activeSourceId().first()!!
withTimeout(5_000) { while (repository.downloadUri(sourceId, "s1") == null) delay(10) } withTimeout(TEST_TIMEOUT_MS) { while (repository.downloadUri(sourceId, "s1") == null) delay(10) }
assertTrue(repository.downloadUri(sourceId, "s1")!!.contains("/rest/download")) assertTrue(repository.downloadUri(sourceId, "s1")!!.contains("/rest/download"))
prefs.setDownloadQuality(StreamQuality(maxBitrate = 128, format = "opus")) prefs.setDownloadQuality(StreamQuality(maxBitrate = 128, format = "opus"))
var transcoded: String? = null var transcoded: String? = null
withTimeout(5_000) { withTimeout(TEST_TIMEOUT_MS) {
while (transcoded == null) { while (transcoded == null) {
delay(10) delay(10)
transcoded = repository.downloadUri(sourceId, "s1")?.takeIf { it.contains("/rest/stream") } transcoded = repository.downloadUri(sourceId, "s1")?.takeIf { it.contains("/rest/stream") }
@@ -155,9 +162,9 @@ class SourceRepositoryTest {
repository.addSource("nav", server.url("/").toString(), "u", "s3cret", true) repository.addSource("nav", server.url("/").toString(), "u", "s3cret", true)
val config = withTimeout(5_000) { repository.activeConfig().first { it != null && !it.useTokenAuth } } val config = withTimeout(TEST_TIMEOUT_MS) { repository.activeConfig().first { it != null && !it.useTokenAuth } }
assertFalse(config!!.useTokenAuth) assertFalse(config!!.useTokenAuth)
withTimeout(5_000) { while (messages.isEmpty()) delay(10) } withTimeout(TEST_TIMEOUT_MS) { while (messages.isEmpty()) delay(10) }
assertEquals(1, messages.size) assertEquals(1, messages.size)
} }
} }
@@ -250,7 +257,7 @@ class SourceRepositoryTest {
repository.addSource("server", "http://a.example/", "u", "p", true) repository.addSource("server", "http://a.example/", "u", "p", true)
val sourceId = awaitActiveSourceId() val sourceId = awaitActiveSourceId()
repository.setOfflineMode(true) repository.setOfflineMode(true)
withTimeout(5_000) { repository.offline.first { it } } withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
assertEquals(null, repository.streamUri("s1", 1_000L, StreamQuality())) assertEquals(null, repository.streamUri("s1", 1_000L, StreamQuality()))
assertEquals(null, repository.networkCoverArt(sourceId, "art-1", false)) assertEquals(null, repository.networkCoverArt(sourceId, "art-1", false))
@@ -266,17 +273,17 @@ class SourceRepositoryTest {
val sourceId = awaitActiveSourceId() val sourceId = awaitActiveSourceId()
artwork.write(sourceId, coverArtKey(sourceId, "art-1", false), byteArrayOf(1, 2, 3)) artwork.write(sourceId, coverArtKey(sourceId, "art-1", false), byteArrayOf(1, 2, 3))
repository.setOfflineMode(true) repository.setOfflineMode(true)
withTimeout(5_000) { repository.offline.first { it } } withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
val ref = awaitCoverArt("art-1") val ref = awaitCoverArt("art-1")
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:")) assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
} }
private suspend fun awaitActiveSourceId(): Long = withTimeout(5_000) { repository.activeSourceId().first { it != null }!! } private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef = private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
withTimeout(5_000) { withTimeout(TEST_TIMEOUT_MS) {
var ref = repository.coverArt(coverArt) var ref = repository.coverArt(coverArt)
while (ref == null) { while (ref == null) {
delay(10) delay(10)
@@ -5,11 +5,17 @@ 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.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
import com.subtracks.data.repo.QueueRepository 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.Dispatchers
import kotlinx.coroutines.Job
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
@@ -37,6 +43,7 @@ class SyncManagerTest {
private lateinit var sourceRepository: SourceRepository private lateinit var sourceRepository: SourceRepository
private lateinit var queueRepository: QueueRepository private lateinit var queueRepository: QueueRepository
private lateinit var manager: SyncManager private lateinit var manager: SyncManager
private lateinit var scope: CoroutineScope
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private lateinit var server: MockWebServer private lateinit var server: MockWebServer
@@ -48,17 +55,18 @@ class SyncManagerTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
queueRepository = QueueRepository(db) queueRepository = QueueRepository(db)
manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it }) manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it }, scope = scope)
server = MockWebServer() server = MockWebServer()
server.start() server.start()
} }
@After @After
fun tearDown() { fun tearDown() {
manager.close() runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() }
sourceRepository.close()
db.close() db.close()
server.shutdown() server.shutdown()
} }
@@ -67,7 +75,7 @@ class SyncManagerTest {
fun aFailedSyncTellsTheUser() = fun aFailedSyncTellsTheUser() =
runBlocking { runBlocking {
manager.requestSync() manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(listOf("Sync failed: No server configured"), messages) assertEquals(listOf("Sync failed: No server configured"), messages)
} }
@@ -78,7 +86,7 @@ class SyncManagerTest {
val before = queueRepository.snapshot().version val before = queueRepository.snapshot().version
manager.requestSync() manager.requestSync()
val status = withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } } val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertTrue(status is SyncStatus.Failed) assertTrue(status is SyncStatus.Failed)
assertEquals(before + 1, queueRepository.snapshot().version) assertEquals(before + 1, queueRepository.snapshot().version)
@@ -91,7 +99,7 @@ class SyncManagerTest {
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync() manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
@@ -128,13 +136,13 @@ class SyncManagerTest {
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync() manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
server.dispatcher = healthyDispatcher() server.dispatcher = healthyDispatcher()
manager.requestSync() manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
@@ -173,7 +181,7 @@ class SyncManagerTest {
manager.requestSync() manager.requestSync()
releaseFirst.countDown() releaseFirst.countDown()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
assertTrue("the coalesced sync should start", secondArtistStarted.await(10, TimeUnit.SECONDS)) assertTrue("the coalesced sync should start", secondArtistStarted.await(10, TimeUnit.SECONDS))
assertEquals(2, artistRequests.get()) assertEquals(2, artistRequests.get())
} }
@@ -230,10 +238,10 @@ class SyncManagerTest {
fun aSyncRequestWhileOfflineIsRefusedWithoutTouchingTheNetwork() = fun aSyncRequestWhileOfflineIsRefusedWithoutTouchingTheNetwork() =
runBlocking { runBlocking {
sourceRepository.setOfflineMode(true) sourceRepository.setOfflineMode(true)
withTimeout(5_000) { sourceRepository.offline.first { it } } withTimeout(TEST_TIMEOUT_MS) { sourceRepository.offline.first { it } }
manager.requestSync() manager.requestSync()
val status = withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } } val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals("Offline mode is on", (status as SyncStatus.Failed).message) assertEquals("Offline mode is on", (status as SyncStatus.Failed).message)
} }
@@ -3,10 +3,17 @@ 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.data.download.DownloadNotifier
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController 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
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.After import org.junit.After
import org.junit.Assert.assertSame import org.junit.Assert.assertSame
@@ -16,25 +23,31 @@ import org.junit.runner.RunWith
import org.koin.core.Koin import org.koin.core.Koin
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.koin.core.qualifier.named
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AppModuleTest { class AppModuleTest {
private lateinit var koin: Koin private lateinit var koin: Koin
private val mainDispatcher = UnconfinedTestDispatcher()
@Before @Before
fun setUp() = stopKoin() fun setUp() {
stopKoin()
Dispatchers.setMain(mainDispatcher)
}
@After @After
fun tearDown() { fun tearDown() {
if (::koin.isInitialized) { if (::koin.isInitialized) {
runCatching { runBlocking {
koin.get<DownloadNotifier>().close() koin.get<CoroutineScope>().coroutineContext[Job]?.cancelAndJoin()
koin.get<PlaybackController>().close() koin.get<CoroutineScope>(named("io")).coroutineContext[Job]?.cancelAndJoin()
koin.get<DownloadRepository>().close() koin.get<CoroutineScope>(named("playback")).coroutineContext[Job]?.cancelAndJoin()
koin.get<SourceRepository>().close()
} }
} }
stopKoin() stopKoin()
Dispatchers.resetMain()
} }
@Test @Test
@@ -6,6 +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.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.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
@@ -26,7 +28,10 @@ import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -52,6 +57,9 @@ import java.util.concurrent.Executors
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PlaybackControllerTest { class PlaybackControllerTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
private val extraScopes = mutableListOf<CoroutineScope>()
private lateinit var db: SubtracksDatabase private lateinit var db: SubtracksDatabase
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
@@ -74,7 +82,15 @@ class PlaybackControllerTest {
.build() .build()
prefs = fakeUserPreferences() prefs = fakeUserPreferences()
networkMode = MutableStateFlow(NetworkMode.Wifi) networkMode = MutableStateFlow(NetworkMode.Wifi)
sources = SourceRepository(db, OkHttpClient(), prefs, ArtworkStore(File(context.cacheDir, "art")), networkMode = networkMode) sources =
SourceRepository(
db,
OkHttpClient(),
prefs,
ArtworkStore(File(context.cacheDir, "art")),
networkMode = networkMode,
scope = repoScope,
)
queues = QueueRepository(db) queues = QueueRepository(db)
downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}") downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
downloads = downloads =
@@ -85,6 +101,7 @@ class PlaybackControllerTest {
downloadsDir, downloadsDir,
artworkStore = ArtworkStore(File(context.cacheDir, "art")), artworkStore = ArtworkStore(File(context.cacheDir, "art")),
artworkFetcher = { ByteArray(0) }, artworkFetcher = { ByteArray(0) },
scope = repoScope,
).also { it.start() } ).also { it.start() }
handle = FakePlayerHandle() handle = FakePlayerHandle()
controller = controller =
@@ -94,16 +111,13 @@ class PlaybackControllerTest {
FakePlayerConnection(handle), FakePlayerConnection(handle),
downloads, downloads,
showMessage = { messages += it }, showMessage = { messages += it },
dispatcher = dispatcher, scope = controllerScope,
) )
} }
@After @After
fun tearDown() { fun tearDown() {
controller.close() cancelAndJoinBlocking(controllerScope, repoScope, *extraScopes.toTypedArray())
runBlocking { controller.awaitStopped() }
sources.close()
downloads.close()
db.close() db.close()
dispatcher.close() dispatcher.close()
} }
@@ -649,7 +663,7 @@ class PlaybackControllerTest {
runBlocking { queues.setPosition(12_000L) } runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle() val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher) val restored = controllerOn(dispatcher, restoredHandle)
restored.connect() restored.connect()
await { await {
restored.state.value.item restored.state.value.item
@@ -657,7 +671,6 @@ class PlaybackControllerTest {
} }
assertEquals(12_000L, restored.positionMs.value) assertEquals(12_000L, restored.positionMs.value)
restored.close()
} }
@Test @Test
@@ -757,7 +770,7 @@ class PlaybackControllerTest {
block: Runnable, block: Runnable,
) = block.run() ) = block.run()
} }
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) val eager = controllerOn(inline, eagerHandle)
eager.playAlbum(1, "al1", 0) eager.playAlbum(1, "al1", 0)
await { await {
@@ -787,7 +800,6 @@ class PlaybackControllerTest {
?.id, ?.id,
) )
assertEquals("q2", runBlocking { eager.upcomingItem()?.id }) assertEquals("q2", runBlocking { eager.upcomingItem()?.id })
eager.close()
} }
@Test @Test
@@ -804,7 +816,7 @@ class PlaybackControllerTest {
block: Runnable, block: Runnable,
) = block.run() ) = block.run()
} }
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) val eager = controllerOn(inline, eagerHandle)
eager.playAlbum(1, "al1", 0) eager.playAlbum(1, "al1", 0)
await { await {
@@ -830,7 +842,6 @@ class PlaybackControllerTest {
?.id, ?.id,
) )
assertEquals(3, runBlocking { queues.snapshot().upNext.size }) assertEquals(3, runBlocking { queues.snapshot().upNext.size })
eager.close()
} }
@Test @Test
@@ -847,7 +858,7 @@ class PlaybackControllerTest {
block: Runnable, block: Runnable,
) = block.run() ) = block.run()
} }
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) val eager = controllerOn(inline, eagerHandle)
eager.playAlbum(1, "al1", 0) eager.playAlbum(1, "al1", 0)
await { await {
@@ -873,7 +884,6 @@ class PlaybackControllerTest {
?.id, ?.id,
) )
assertEquals(3L, runBlocking { queues.snapshot().upNextSize }) assertEquals(3L, runBlocking { queues.snapshot().upNextSize })
eager.close()
} }
@Test @Test
@@ -890,7 +900,7 @@ class PlaybackControllerTest {
block: Runnable, block: Runnable,
) = block.run() ) = block.run()
} }
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) val eager = controllerOn(inline, eagerHandle)
eager.playAlbum(1, "al1", 0) eager.playAlbum(1, "al1", 0)
await { await {
@@ -920,7 +930,6 @@ class PlaybackControllerTest {
val queued = runBlocking { queues.snapshot().upNext.map { it.entry.refId } } val queued = runBlocking { queues.snapshot().upNext.map { it.entry.refId } }
assertTrue("c1" in queued) assertTrue("c1" in queued)
eager.close()
} }
@Test @Test
@@ -2308,7 +2317,7 @@ class PlaybackControllerTest {
runBlocking { queues.setPosition(12_000L) } runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle() val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher) val restored = controllerOn(dispatcher, restoredHandle)
restored.connect() restored.connect()
await { await {
restored.state.value.item restored.state.value.item
@@ -2321,7 +2330,6 @@ class PlaybackControllerTest {
"setWindow(size=3, start=0, position=12000)", "setWindow(size=3, start=0, position=12000)",
restoredHandle.operations.first { it.startsWith("setWindow") }, restoredHandle.operations.first { it.startsWith("setWindow") },
) )
restored.close()
} }
@Test @Test
@@ -2890,7 +2898,7 @@ class PlaybackControllerTest {
songId: String, songId: String,
) { ) {
val file = File(downloadsDir, "$sourceId/$songId") val file = File(downloadsDir, "$sourceId/$songId")
val deadline = System.nanoTime() + 15_000_000_000L val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (System.nanoTime() < deadline) { while (System.nanoTime() < deadline) {
runBlocking { runBlocking {
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed))
@@ -2906,12 +2914,12 @@ class PlaybackControllerTest {
private fun setOffline() { private fun setOffline() {
sources.setOfflineMode(true) sources.setOfflineMode(true)
runBlocking { withTimeout(5_000) { sources.offline.first { it } } } runBlocking { withTimeout(TEST_TIMEOUT_MS) { sources.offline.first { it } } }
} }
private fun setOnline() { private fun setOnline() {
sources.setOfflineMode(false) sources.setOfflineMode(false)
runBlocking { withTimeout(5_000) { sources.offline.first { !it } } } runBlocking { withTimeout(TEST_TIMEOUT_MS) { sources.offline.first { !it } } }
} }
private fun seedAlbum( private fun seedAlbum(
@@ -3063,9 +3071,17 @@ class PlaybackControllerTest {
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id } (0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
} }
private fun controllerOn(
dispatcher: CoroutineDispatcher,
handle: FakePlayerHandle,
): PlaybackController {
val scope = CoroutineScope(SupervisorJob() + dispatcher).also { extraScopes += it }
return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, scope = scope)
}
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected playback state", predicate()) assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected playback state", predicate())
} }
} }
@@ -6,6 +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.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.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
@@ -22,9 +24,11 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
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.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -51,6 +55,8 @@ class DownloadsViewModelTest {
private lateinit var downloadRepository: DownloadRepository private lateinit var downloadRepository: DownloadRepository
private lateinit var controller: PlaybackController private lateinit var controller: PlaybackController
private lateinit var viewModel: DownloadsViewModel private lateinit var viewModel: DownloadsViewModel
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
private var sourceId: Long = 0 private var sourceId: Long = 0
@Before @Before
@@ -63,7 +69,7 @@ class DownloadsViewModelTest {
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork) sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
downloadRepository = downloadRepository =
DownloadRepository( DownloadRepository(
db, db,
@@ -72,6 +78,7 @@ class DownloadsViewModelTest {
File(context.cacheDir, "downloads-${System.nanoTime()}"), File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork, artworkStore = artwork,
artworkFetcher = { ByteArray(0) }, artworkFetcher = { ByteArray(0) },
scope = repoScope,
) )
controller = controller =
PlaybackController( PlaybackController(
@@ -79,11 +86,11 @@ class DownloadsViewModelTest {
QueueRepository(db), QueueRepository(db),
FakePlayerConnection(FakePlayerHandle()), FakePlayerConnection(FakePlayerHandle()),
downloadRepository, downloadRepository,
dispatcher = dispatcher, scope = controllerScope,
) )
viewModel = viewModel =
DownloadsViewModel( DownloadsViewModel(
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository)), LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
downloadRepository, downloadRepository,
controller, controller,
) )
@@ -92,10 +99,7 @@ class DownloadsViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
controller.close() cancelAndJoinBlocking(controllerScope, repoScope)
runBlocking { controller.awaitStopped() }
downloadRepository.close()
sourceRepository.close()
db.close() db.close()
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.close() dispatcher.close()
@@ -174,8 +178,8 @@ 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) {
val deadline = System.nanoTime() + 5_000_000_000L val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected state", predicate()) assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected state", predicate())
} }
} }
@@ -6,6 +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.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.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
@@ -22,9 +24,11 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
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.Job
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
@@ -56,6 +60,8 @@ class QueueViewModelTest {
private lateinit var downloadsDir: File private lateinit var downloadsDir: File
private lateinit var controller: PlaybackController private lateinit var controller: PlaybackController
private lateinit var viewModel: QueueViewModel private lateinit var viewModel: QueueViewModel
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
@Before @Before
fun setUp() { fun setUp() {
@@ -66,7 +72,8 @@ class QueueViewModelTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) sources =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = repoScope)
queues = QueueRepository(db) queues = QueueRepository(db)
val artwork = ArtworkStore(File(context.cacheDir, "art")) val artwork = ArtworkStore(File(context.cacheDir, "art"))
downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}") downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
@@ -78,18 +85,16 @@ class QueueViewModelTest {
downloadsDir, downloadsDir,
artworkStore = artwork, artworkStore = artwork,
artworkFetcher = { ByteArray(0) }, artworkFetcher = { ByteArray(0) },
scope = repoScope,
).also { it.start() } ).also { it.start() }
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher) controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, scope = controllerScope)
viewModel = QueueViewModel(queues, controller, sources, downloads) viewModel = QueueViewModel(queues, controller, sources, downloads)
} }
@After @After
fun tearDown() { fun tearDown() {
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
controller.close() cancelAndJoinBlocking(controllerScope, repoScope)
runBlocking { controller.awaitStopped() }
downloads.close()
sources.close()
db.close() db.close()
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.close() dispatcher.close()
@@ -455,7 +460,7 @@ class QueueViewModelTest {
private fun setOffline() { private fun setOffline() {
runBlocking { runBlocking {
sources.setOfflineMode(true) sources.setOfflineMode(true)
withTimeout(5_000) { sources.offline.first { it } } withTimeout(TEST_TIMEOUT_MS) { sources.offline.first { it } }
} }
} }
@@ -514,10 +519,8 @@ 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) {
repeat(500) { val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
if (predicate()) return while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
Thread.sleep(10) assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected state", predicate())
}
assertTrue("Timed out waiting for the expected state", predicate())
} }
} }
@@ -6,6 +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.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.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
@@ -14,9 +16,11 @@ import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
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.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -45,8 +49,8 @@ class AddSourceViewModelTest {
private lateinit var sourceRepository: SourceRepository private lateinit var sourceRepository: SourceRepository
private lateinit var downloadRepository: DownloadRepository private lateinit var downloadRepository: DownloadRepository
private lateinit var server: MockWebServer private lateinit var server: MockWebServer
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val createdViewModels = mutableListOf<AddSourceViewModel>() private val createdViewModels = mutableListOf<AddSourceViewModel>()
private val syncManagers = mutableListOf<SyncManager>()
@Before @Before
fun setUp() { fun setUp() {
@@ -58,7 +62,7 @@ class AddSourceViewModelTest {
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork) sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
downloadRepository = downloadRepository =
DownloadRepository( DownloadRepository(
db, db,
@@ -67,6 +71,7 @@ class AddSourceViewModelTest {
File(context.cacheDir, "downloads-${System.nanoTime()}"), File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork, artworkStore = artwork,
artworkFetcher = { ByteArray(0) }, artworkFetcher = { ByteArray(0) },
scope = repoScope,
) )
server = MockWebServer() server = MockWebServer()
server.start() server.start()
@@ -75,10 +80,8 @@ class AddSourceViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
createdViewModels.forEach { runBlocking { it.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } } createdViewModels.forEach { runBlocking { it.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } }
syncManagers.forEach { it.close() } cancelAndJoinBlocking(repoScope)
server.shutdown() server.shutdown()
downloadRepository.close()
sourceRepository.close()
db.close() db.close()
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.close() dispatcher.close()
@@ -137,7 +140,7 @@ class AddSourceViewModelTest {
} }
private fun viewModel(sourceId: Long? = null): AddSourceViewModel { private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
val syncManager = SyncManager(db, sourceRepository, QueueRepository(db)).also { syncManagers += it } val syncManager = SyncManager(db, sourceRepository, QueueRepository(db), scope = repoScope)
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId).also { createdViewModels += it } return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId).also { createdViewModels += it }
} }
@@ -149,8 +152,8 @@ class AddSourceViewModelTest {
) )
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected state", predicate()) assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms waiting for the expected state", predicate())
} }
} }