mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
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:
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user