close the test gaps a QA pass found, and de-flake the flaky ones

Verify the Subsonic token hash and salt freshness, and fall back to per-album
songs when the empty-search probe fails with an I/O error instead of
aborting the whole sync.

Add a real-server sync test that mirrors navidrome and gonic into Room and
reads the rows back, plus SyncManager coverage for the success, coalescing
and mid-sync-failure paths.

Cover the sort mapping and delete/ping ViewModels, the download engine's
request flags, the queue helpers, download prefs and migration data
preservation.

Drop the leaked collectors in the ViewModel/queue tests, document the
screenshot tests as render smoke tests, bind a real search term in the
trigram plan test, and assert a positive in-flight invariant alongside the
concurrency timing checks.
This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent 5cbe4c6839
commit 684cc5a287
27 changed files with 989 additions and 25 deletions
+7 -3
View File
@@ -75,12 +75,16 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
val integrationTestClassName = "com.subtracks.data.source.subsonic.SubsonicSourceIntegrationTest"
val integrationTestClasses =
listOf(
"com.subtracks.data.source.subsonic.SubsonicSourceIntegrationTest",
"com.subtracks.data.sync.SyncServiceIntegrationTest",
)
tasks.withType<Test>().configureEach {
if (name.endsWith("UnitTest")) {
filter {
excludeTestsMatching(integrationTestClassName)
integrationTestClasses.forEach { excludeTestsMatching(it) }
}
}
}
@@ -93,7 +97,7 @@ tasks.register<Test>("integrationTest") {
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
classpath = files(provider { unitTest.get().classpath })
filter {
includeTestsMatching(integrationTestClassName)
integrationTestClasses.forEach { includeTestsMatching(it) }
}
}
@@ -0,0 +1,75 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.source.subsonic.SubsonicSource
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SyncServiceIntegrationTest {
@Test
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
runBlocking {
servers.forEach { server ->
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
try {
db.sourcesDao().upsertSource(
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
)
val source =
SubsonicSource(
id = 1,
client =
SubsonicClient(
baseUrl = server.baseUrl.toHttpUrl(),
username = server.username,
password = server.password,
useTokenAuth = false,
http = OkHttpClient(),
),
)
SyncService(db, source).sync()
val library = db.libraryDao()
assertEquals(server.name, 2, library.artistIds(1).size)
assertEquals(server.name, 3, library.albumIds(1).size)
assertEquals(server.name, 20, library.songIdsAfter(1, "", 1000).size)
assertEquals(server.name, 1, library.playlistIds(1).size)
} finally {
db.close()
}
}
}
private data class Server(
val name: String,
val baseUrl: String,
val username: String,
val password: String,
)
private companion object {
val servers =
listOf(
Server("navidrome", "http://localhost:4533/", "admin", "password"),
Server("gonic", "http://localhost:4747/", "admin", "admin"),
)
}
}
@@ -14,6 +14,7 @@ import com.subtracks.data.repo.DownloadRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
@@ -42,6 +43,10 @@ class DownloadNotifier(
}
}
internal fun close() {
scope.cancel()
}
private fun build(content: DownloadNotification): Notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -78,7 +79,9 @@ class DownloadRepository(
}
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
@@ -27,6 +27,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.xml.sax.Attributes
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class SubsonicSource(
@@ -272,6 +273,8 @@ class SubsonicSource(
) { input -> SubsonicXml.containsEntity(input, "song") }
} catch (_: SubsonicException) {
false
} catch (_: IOException) {
false
}
emptyQuerySearchSupported = supported
return supported
@@ -159,8 +159,8 @@ class LibraryViewModel(
fun sync() = syncManager.requestSync()
}
private fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
private fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name
internal fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name
private fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name
internal fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name
@@ -224,6 +224,8 @@ class LibraryDaoTest {
}
}
// These plan assertions depend on the query planner in the pinned androidx.sqlite:sqlite-bundled
// (see gradle/libs.versions.toml); re-check the expected index names when that engine is bumped.
@Test
fun albumOrdersAreIndexBacked() =
runTest {
@@ -442,7 +444,11 @@ class LibraryDaoTest {
@Test
fun searchRunsThroughTheTrigramIndex() =
runTest {
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0")
val plan =
plan(
"SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " +
"ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0",
)
assertTrue(plan, plan.contains("album_search"))
}
@@ -87,7 +87,7 @@ class MigrationsTest {
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " +
"starred, songCount, frequentRank, recentRank) " +
"VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, NULL, 1, NULL, NULL)",
"VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, 555, 1, NULL, NULL)",
)
migrated.execSQL(
"INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " +
@@ -97,9 +97,24 @@ class MigrationsTest {
MIGRATIONS.sortedBy { it.startVersion }.forEach { migration ->
migration.migrate(migrated)
when (migration.endVersion) {
5 -> migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'")
8 -> migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'")
11 -> migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')")
5 -> {
migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'")
}
8 -> {
migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'")
}
11 -> {
migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')")
}
18 -> {
migrated.execSQL(
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) " +
"VALUES (1, 's1', 'Completed', 10, 20)",
)
}
}
}
@@ -107,8 +122,11 @@ class MigrationsTest {
assertEquals(123456L, long(migrated, "SELECT changed FROM playlists WHERE id = 'pl1'"))
assertEquals(987654L, long(migrated, "SELECT duration FROM playlists WHERE id = 'pl1'"))
assertEquals("The Album", text(migrated, "SELECT name FROM albums WHERE id = 'al1'"))
assertEquals(555L, long(migrated, "SELECT starred FROM albums WHERE id = 'al1'"))
assertEquals("The Artist", text(migrated, "SELECT name FROM artists WHERE id = 'ar1'"))
assertEquals("The Disc", text(migrated, "SELECT title FROM discs WHERE albumId = 'al1' AND disc = 2"))
assertEquals("Completed", text(migrated, "SELECT status FROM song_downloads WHERE songId = 's1'"))
assertEquals(10L, long(migrated, "SELECT bytes FROM song_downloads WHERE songId = 's1'"))
}
}
@@ -1,10 +1,30 @@
package com.subtracks.data.download
import android.app.DownloadManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
@RunWith(AndroidJUnit4::class)
class SystemDownloadEngineTest {
private lateinit var engine: SystemDownloadEngine
private lateinit var downloadManager: DownloadManager
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
engine = SystemDownloadEngine(context)
downloadManager = context.getSystemService(DownloadManager::class.java)
}
@Test
fun successfulRunningAndFailedStatusesAreMapped() {
assertEquals(EngineStatus.Completed, engineStatus(DownloadManager.STATUS_SUCCESSFUL))
@@ -25,4 +45,66 @@ class SystemDownloadEngineTest {
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN))
}
@Test
fun enqueuedRequestIsStoredAndTargetsTheSourceAndSongDirectory() {
val id =
engine.enqueue(
EngineRequest(
uri = "https://server.example/rest/stream?id=song-9",
path = "downloads/7/song-9",
title = "A Song",
),
)
val downloads = shadowOf(downloadManager)
assertEquals(1, downloads.requestCount)
val request = shadowOf(downloads.getRequest(id))
assertEquals(id, request.id)
assertEquals("https://server.example/rest/stream?id=song-9", request.uri.toString())
assertEquals("A Song", request.title.toString())
val destination = request.destination
assertNotNull(destination)
val path = destination!!.path!!
assertTrue("destination $path is not under external files", path.contains("/external-files/"))
assertTrue(
"destination $path is not under the source/song downloads directory",
path.endsWith("/Music/downloads/7/song-9"),
)
}
@Test
fun meteredPreferenceIsAppliedForBothValues() {
val allowed =
engine.enqueue(
EngineRequest(
uri = "https://server.example/a",
path = "downloads/1/a",
title = "a",
allowMetered = true,
),
)
val blocked =
engine.enqueue(
EngineRequest(
uri = "https://server.example/b",
path = "downloads/1/b",
title = "b",
allowMetered = false,
),
)
val downloads = shadowOf(downloadManager)
assertEquals(2, downloads.requestCount)
val allowedRequest = shadowOf(downloads.getRequest(allowed))
assertTrue(allowedRequest.allowedOverMetered)
assertTrue(allowedRequest.allowedOverRoaming)
val blockedRequest = shadowOf(downloads.getRequest(blocked))
assertFalse(blockedRequest.allowedOverMetered)
assertFalse(blockedRequest.allowedOverRoaming)
}
}
@@ -81,6 +81,25 @@ class UserPreferencesTest {
file.delete()
}
@Test
fun downloadQualityAndMeteredRoundTrip() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(StreamQuality(), prefs.downloadQuality().first())
assertEquals(false, prefs.downloadOverMetered().first())
prefs.setDownloadQuality(StreamQuality(192, "opus"))
prefs.setDownloadOverMetered(true)
assertEquals(StreamQuality(192, "opus"), prefs.downloadQuality().first())
assertEquals(true, prefs.downloadOverMetered().first())
file.delete()
}
@Test
fun syncConcurrencyDefaultsAndClampsToAtLeastOne() =
runTest {
@@ -89,11 +89,15 @@ class LibraryRepositoryTest {
fun starringUpdatesTheRowBeforeTheServerResponds() =
runBlocking {
withSource { server ->
server.enqueue(ok().setBodyDelay(10, TimeUnit.SECONDS))
server.enqueue(ok().setBodyDelay(250, TimeUnit.MILLISECONDS))
val call = launch { repository.setStar(StarType.Song, "s1", true) }
withTimeout(3_000) { while (starred() == null) delay(10) }
call.cancel()
call.join()
assertNotNull(starred())
assertEquals(emptyList<String>(), messages)
}
}
@@ -8,6 +8,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
@@ -734,6 +735,121 @@ class QueueRepositoryTest {
}
}
@Test
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(1L, updated.anchorPlay)
assertEquals(1L, updated.upNextSize)
assertEquals(2L, repository.combinedIndexOf(updated, "s4"))
assertEquals(1L, repository.combinedContextIndexOf(updated, "s2"))
assertEquals(3L, repository.combinedContextIndexOf(updated, "s3"))
}
@Test
fun combinedContextIndexOfTracksTheAnchorAsItMoves() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val before = repository.snapshot()
assertEquals(0L, before.anchorPlay)
assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
repository.setUpNextAnchor(2)
val after = repository.snapshot()
assertEquals(2L, after.anchorPlay)
assertEquals(2L, repository.combinedContextIndexOf(after, "s3"))
assertEquals(3L, repository.combinedIndexOf(after, "s4"))
}
@Test
fun combinedIndexOfPrefersTheUpNextCopyOverTheContextCopy() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
assertEquals(1L, repository.combinedIndexOf(updated, "s4"))
}
@Test
fun sourceNameReadsTheAlbumPlaylistOrSongAlbum() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("p1", "Road Trip")))
assertEquals("First Album", repository.sourceName(QueueKind.Album, 1, "al1"))
assertEquals("Road Trip", repository.sourceName(QueueKind.Playlist, 1, "p1"))
assertEquals("Second Album", repository.sourceName(QueueKind.Song, 1, "s4"))
assertEquals(null, repository.sourceName(QueueKind.Song, 1, "ghost"))
}
@Test
fun aReorderedAlbumTrackKeepsItsAlbumAsSourceName() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1")))
repository.move(repository.snapshot(), from = 0, to = 2)
val (entry, _) = repository.snapshot().locate(2)!!
assertEquals(QueueKind.Song, entry.kind)
assertEquals("s1", entry.refId)
assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId))
}
@Test
fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(snapshot, 1)
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Album, entry.kind)
assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId))
}
@Test
fun queueModesRoundTrip() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1")))
assertEquals(QueueModes(shuffle = false, repeat = 0), repository.modes())
repository.setShuffle(4321L)
repository.setRepeat(2)
assertEquals(QueueModes(shuffle = true, repeat = 2), repository.modes())
repository.setShuffle(null)
repository.setRepeat(0)
assertEquals(QueueModes(shuffle = false, repeat = 0), repository.modes())
}
private fun playlist(
id: String,
name: String,
) = Playlist(
sourceId = 1,
id = id,
name = name,
comment = null,
coverArt = null,
songCount = 0,
created = 0,
)
private suspend fun seedLibrary() {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
@@ -167,6 +167,8 @@ class SourceRepositoryTest {
runBlocking {
withServer { server ->
val dispatches = AtomicInteger()
val inFlight = AtomicInteger()
val maxInFlight = AtomicInteger()
val secondStarted = CountDownLatch(1)
val release = CountDownLatch(1)
server.dispatcher =
@@ -175,7 +177,10 @@ class SourceRepositoryTest {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
if (dispatches.incrementAndGet() == 2) secondStarted.countDown()
val current = inFlight.incrementAndGet()
maxInFlight.updateAndGet { maxOf(it, current) }
release.await(5, TimeUnit.SECONDS)
inFlight.decrementAndGet()
val id = url.queryParameter("id")!!
return MockResponse().setBody(
"<subsonic-response status=\"ok\"><playlist id=\"$id\">" +
@@ -199,6 +204,7 @@ class SourceRepositoryTest {
assertFalse(secondStarted.await(500, TimeUnit.MILLISECONDS))
release.countDown()
assertEquals(9, fetching.await().size)
assertEquals("only one fetch may be in flight at a time", 1, maxInFlight.get())
}
}
@@ -5,11 +5,13 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import java.security.MessageDigest
class SubsonicClientTest {
private lateinit var server: MockWebServer
@@ -57,6 +59,36 @@ class SubsonicClientTest {
assertNull(url.queryParameter("p"))
}
@Test
fun tokenHashIsMd5OfPasswordAndSalt() {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
client(tokenAuth = true).check("ping")
val url = server.takeRequest().requestUrl!!
val salt = url.queryParameter("s")!!
assertEquals(md5Hex("secret$salt"), url.queryParameter("t"))
}
@Test
fun tokenSaltIsFreshPerRequest() {
repeat(2) { server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")) }
val client = client(tokenAuth = true)
client.check("ping")
client.check("ping")
val first = server.takeRequest().requestUrl!!.queryParameter("s")
val second = server.takeRequest().requestUrl!!.queryParameter("s")
assertNotEquals(first, second)
}
private fun md5Hex(input: String): String =
MessageDigest
.getInstance("MD5")
.digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
@Test
fun plaintextAuthSendsPassword() {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
@@ -12,6 +12,7 @@ import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -74,6 +75,42 @@ class SubsonicSourceTest {
)
}
@Test
fun getSongsFallsBackToAlbumsWhenEmptySearchProbeFails() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)
}
request.path?.startsWith("/rest/getAlbumList2.view") == true -> {
MockResponse().setBody(ALBUM_LIST)
}
request.path?.startsWith("/rest/getAlbum.view") == true -> {
MockResponse().setBody(ALBUM)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
val source = SubsonicSource(1, client())
assertEquals(
listOf("s1", "s2"),
source
.songs()
.toList()
.flatten()
.map { it.id },
)
}
@Test
fun artistsAreEmittedInBoundedBatches() =
runBlocking {
@@ -353,6 +390,8 @@ class SubsonicSourceTest {
val bound = 2
val started = CountDownLatch(bound)
val beyondBound = CountDownLatch(bound + 1)
val inFlight = AtomicInteger()
val maxInFlight = AtomicInteger()
val release = CountDownLatch(1)
server.dispatcher =
object : Dispatcher() {
@@ -361,7 +400,10 @@ class SubsonicSourceTest {
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
beyondBound.countDown()
started.countDown()
val current = inFlight.incrementAndGet()
maxInFlight.updateAndGet { maxOf(it, current) }
release.await(5, TimeUnit.SECONDS)
inFlight.decrementAndGet()
return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!))
}
}
@@ -384,6 +426,7 @@ class SubsonicSourceTest {
val entries = fetching.await()
assertEquals(12, entries.size)
assertEquals("the bound must cap concurrent fetches", bound, maxInFlight.get())
}
@Test
@@ -14,6 +14,11 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -22,6 +27,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@RunWith(AndroidJUnit4::class)
class SyncManagerTest {
@@ -30,6 +38,7 @@ class SyncManagerTest {
private lateinit var queueRepository: QueueRepository
private lateinit var manager: SyncManager
private val messages = CopyOnWriteArrayList<String>()
private lateinit var server: MockWebServer
@Before
fun setUp() {
@@ -42,12 +51,15 @@ class SyncManagerTest {
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
queueRepository = QueueRepository(db)
manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it })
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
sourceRepository.close()
db.close()
server.shutdown()
}
@Test
@@ -70,4 +82,146 @@ class SyncManagerTest {
assertTrue(status is SyncStatus.Failed)
assertEquals(before + 1, queueRepository.snapshot().version)
}
@Test
fun aSuccessfulSyncMirrorsTheLibraryIntoRoom() =
runBlocking {
server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } }
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
}
@Test
fun aMidSyncFailureKeepsCommittedRowsAndRecovers() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/getArtists.view") == true -> {
MockResponse().setBody(ARTISTS)
}
request.path?.startsWith("/rest/getAlbumList2.view") == true -> {
MockResponse().setBody(ALBUM_LIST)
}
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
request.path?.startsWith("/rest/getAlbum.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
server.dispatcher = healthyDispatcher()
manager.requestSync()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } }
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
}
@Test
fun requestsWhileRunningCoalesceIntoOneSync() =
runBlocking {
val artistRequests = AtomicInteger()
val firstArtistStarted = CountDownLatch(1)
val secondArtistStarted = CountDownLatch(1)
val releaseFirst = CountDownLatch(1)
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path?.startsWith("/rest/getArtists.view") == true) {
when (artistRequests.incrementAndGet()) {
1 -> {
firstArtistStarted.countDown()
releaseFirst.await(10, TimeUnit.SECONDS)
}
2 -> {
secondArtistStarted.countDown()
}
}
}
return healthyDispatcher().dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
assertTrue(firstArtistStarted.await(10, TimeUnit.SECONDS))
manager.requestSync()
manager.requestSync()
releaseFirst.countDown()
withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } }
assertTrue("the coalesced sync should start", secondArtistStarted.await(10, TimeUnit.SECONDS))
assertEquals(2, artistRequests.get())
}
private fun healthyDispatcher() =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/getArtists.view") == true -> {
MockResponse().setBody(ARTISTS)
}
request.path?.startsWith("/rest/getAlbumList2.view") == true -> {
val type = request.requestUrl?.queryParameter("type")
MockResponse().setBody(if (type == "newest") ALBUM_LIST else emptyAlbumList())
}
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setBody(SONG)
}
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
MockResponse().setBody(emptyPlaylists())
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
private fun emptyAlbumList() = "<subsonic-response status=\"ok\"><albumList2></albumList2></subsonic-response>"
private fun emptyPlaylists() = "<subsonic-response status=\"ok\"><playlists></playlists></subsonic-response>"
private companion object {
const val ARTISTS =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><artists>" +
"<artist id=\"ar1\" name=\"Artist One\" albumCount=\"1\"/>" +
"</artists></subsonic-response>"
const val ALBUM_LIST =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><albumList2>" +
"<album id=\"al1\" artistId=\"ar1\" name=\"Album One\" artist=\"Artist One\" songCount=\"1\"/>" +
"</albumList2></subsonic-response>"
const val SONG =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"</searchResult3></subsonic-response>"
}
}
@@ -3,31 +3,49 @@ package com.subtracks.di
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.download.DownloadNotifier
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.Koin
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
@RunWith(AndroidJUnit4::class)
class AppModuleTest {
private lateinit var koin: Koin
@Before
fun setUp() = stopKoin()
@After
fun tearDown() = stopKoin()
fun tearDown() {
if (::koin.isInitialized) {
runCatching {
koin.get<DownloadNotifier>().close()
koin.get<PlaybackController>().close()
koin.get<DownloadRepository>().close()
koin.get<SourceRepository>().close()
}
}
stopKoin()
}
@Test
fun theModuleResolvesThePlaybackGraph() {
val context = ApplicationProvider.getApplicationContext<Context>()
val koin = startKoin { modules(appModule(context, OkHttpClient())) }.koin
koin = startKoin { modules(appModule(context, OkHttpClient())) }.koin
assertNotNull(koin.get<DownloadRepository>())
assertNotNull(koin.get<PlaybackController>())
val downloads = koin.get<DownloadRepository>()
val controller = koin.get<PlaybackController>()
assertSame(downloads, koin.get<DownloadRepository>())
assertSame(controller, koin.get<PlaybackController>())
}
}
@@ -27,6 +27,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
@@ -1236,11 +1237,10 @@ class PlaybackControllerTest {
await { controller.state.value.isPlaying }
handle.startBuffering()
Thread.sleep(300)
assertFalse(controller.state.value.isBuffering)
runBlocking { withContext(dispatcher) { } }
assertFalse("buffering must be debounced, not reported immediately", controller.state.value.isBuffering)
Thread.sleep(1_200)
assertTrue(controller.state.value.isBuffering)
await { controller.state.value.isBuffering }
handle.becomeReady()
await { !controller.state.value.isBuffering }
@@ -71,6 +71,11 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import kotlin.random.Random
/**
* Render smoke tests: these render each screen and assert the awaited node exists, then write a PNG
* for local review. No golden images are committed and `verifyRoborazziDebug` is not run in CI, so
* pixel/layout regressions are NOT caught here. Use `recordRoborazziDebug` to inspect output.
*/
@OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -27,6 +27,7 @@ import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
@@ -28,6 +28,7 @@ import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
@@ -0,0 +1,179 @@
package com.subtracks.ui.downloads
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class DownloadsViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository
private lateinit var downloadRepository: DownloadRepository
private lateinit var controller: PlaybackController
private lateinit var viewModel: DownloadsViewModel
private var sourceId: Long = 0
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork)
downloadRepository =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
)
controller =
PlaybackController(
sourceRepository,
QueueRepository(db),
FakePlayerConnection(FakePlayerHandle()),
downloadRepository,
dispatcher = dispatcher,
)
viewModel =
DownloadsViewModel(
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository)),
downloadRepository,
controller,
)
}
@After
fun tearDown() {
viewModel.viewModelScope.cancel()
controller.close()
downloadRepository.close()
sourceRepository.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@Test
fun deleteAllRemovesEveryDownloadOfTheSource() {
seedDownloads()
viewModel.deleteAll()
await { allRows().isEmpty() }
assertTrue("every download row should be gone", allRows().isEmpty())
}
@Test
fun deleteRemovesOnlyTheSongsOfThatAlbum() {
seedDownloads()
viewModel.delete(DownloadList.Album, "al1")
await { row("s1") == null && row("s2") == null }
assertNull(row("s1"))
assertNull(row("s2"))
assertNotNull("a song from another album should survive", row("s3"))
}
@Test
fun deleteSongsRemovesOnlyTheGivenSongs() {
seedDownloads()
viewModel.deleteSongs(listOf("s1"))
await { row("s1") == null }
assertNull(row("s1"))
assertNotNull("an unnamed song should survive", row("s2"))
}
private fun seedDownloads() {
sourceId =
runBlocking {
val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertSongs(
listOf(
song(id, "s1", "al1"),
song(id, "s2", "al1"),
song(id, "s3", "al2"),
),
)
listOf("s1", "s2", "s3").forEach { db.downloadDao().upsert(SongDownload(id, it, DownloadStatus.Completed)) }
id
}
}
private fun song(
sourceId: Long,
songId: String,
albumId: String,
) = Song(
sourceId = sourceId,
id = songId,
albumId = albumId,
artistId = "ar1",
title = songId,
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
)
private fun row(songId: String): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected state", predicate())
}
}
@@ -0,0 +1,27 @@
package com.subtracks.ui.library
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LibraryViewModelTest {
@Test
fun aKnownSortStringDecodesToItsEnum() {
assertEquals(AlbumSort.Year, ListQuery("Year").albumSort())
assertEquals(ArtistSort.AlbumCount, ListQuery("AlbumCount").artistSort())
assertEquals(PlaylistSort.Updated, ListQuery("Updated").playlistSort())
}
@Test
fun anUnknownSortStringFallsBackToName() {
assertEquals(AlbumSort.Name, ListQuery("LegacyAlbumOrder").albumSort())
assertEquals(ArtistSort.Name, ListQuery("LegacyArtistOrder").artistSort())
assertEquals(PlaylistSort.Name, ListQuery("LegacyPlaylistOrder").playlistSort())
}
}
@@ -1,6 +1,7 @@
package com.subtracks.ui.playback
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
@@ -22,6 +23,7 @@ import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@@ -43,6 +45,8 @@ class QueueViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var sources: SourceRepository
private lateinit var downloads: DownloadRepository
private lateinit var queues: QueueRepository
private lateinit var controller: PlaybackController
private lateinit var viewModel: QueueViewModel
@@ -56,10 +60,10 @@ class QueueViewModelTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
queues = QueueRepository(db)
val artwork = ArtworkStore(File(context.cacheDir, "art"))
val downloads =
downloads =
DownloadRepository(
db,
sources,
@@ -74,7 +78,10 @@ class QueueViewModelTest {
@After
fun tearDown() {
viewModel.viewModelScope.cancel()
controller.close()
downloads.close()
sources.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
@@ -0,0 +1,152 @@
package com.subtracks.ui.settings
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class AddSourceViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository
private lateinit var downloadRepository: DownloadRepository
private lateinit var server: MockWebServer
private val createdViewModels = mutableListOf<AddSourceViewModel>()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork)
downloadRepository =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
)
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
createdViewModels.forEach { it.viewModelScope.cancel() }
server.shutdown()
downloadRepository.close()
sourceRepository.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@Test
fun aRejectedPingSetsTheErrorAndClearsBusy() {
val viewModel = viewModel()
viewModel.setAddress(server.url("/").toString())
viewModel.setUsername("u")
viewModel.setPassword("p")
server.enqueue(failed())
viewModel.testConnection()
await { !viewModel.state.value.busy }
val state = viewModel.state.value
assertFalse(state.busy)
assertTrue("a rejected ping should be an error", state.isError)
assertEquals("Failed: Wrong username", state.message)
}
@Test
fun aSuccessfulPingClearsAnEarlierError() {
val viewModel = viewModel()
viewModel.setAddress(server.url("/").toString())
viewModel.setUsername("u")
viewModel.setPassword("p")
server.enqueue(failed())
viewModel.testConnection()
await { !viewModel.state.value.busy && viewModel.state.value.isError }
server.enqueue(ok())
viewModel.testConnection()
await { !viewModel.state.value.busy && !viewModel.state.value.isError }
val state = viewModel.state.value
assertEquals("Connection OK", state.message)
assertFalse(state.isError)
}
@Test
fun theActiveSourceCannotBeDeleted() =
runBlocking {
val sourceId = sourceRepository.addSource("nav", server.url("/").toString(), "u", "p", false)
val viewModel = viewModel(sourceId)
await { !viewModel.state.value.canDelete }
viewModel.delete {}
await { !viewModel.state.value.busy && viewModel.state.value.message != null }
val state = viewModel.state.value
assertTrue(state.isError)
assertTrue(state.message!!.contains("active server can't be deleted"))
assertNotNull("the active source must survive the refused delete", db.sourcesDao().sourceOnce(sourceId))
}
private fun viewModel(sourceId: Long? = null): AddSourceViewModel =
AddSourceViewModel(sourceRepository, SyncManager(db, sourceRepository, QueueRepository(db)), downloadRepository, sourceId)
.also { createdViewModels += it }
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
private fun failed() =
MockResponse().setBody(
"<subsonic-response status=\"failed\"><error code=\"40\" message=\"Wrong username\"/></subsonic-response>",
)
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected state", predicate())
}
}
@@ -65,7 +65,7 @@ class ArtworkExtractionTest {
@Test
fun resolvesColorsFromCoverArt() {
val ref = CoverArtRef(url = "art", cacheKey = "art-${System.nanoTime()}")
val ref = CoverArtRef(url = "art", cacheKey = "art")
var colors: ArtworkColors? = null
composeRule.setContent {
colors = rememberArtworkColors(ref)
@@ -113,7 +113,7 @@ class ArtworkExtractionTest {
@Test
fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() {
val key = "missing-${System.nanoTime()}"
val key = "missing"
val stored = ArtworkSeed(key, 0xFF3A7BD5.toInt(), 0xFFD53A3A.toInt(), null)
ArtworkSeedCache.install(
object : ArtworkSeedStore {
@@ -1 +1,5 @@
sdk=35
# The real Application starts app-scoped Koin singletons (download repo/notifier) whose collectors
# are never cancelled between tests, so they query a torn-down database at teardown. Tests build
# their own graphs; AppModuleTest starts Koin explicitly.
application=android.app.Application