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