mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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")
|
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 {
|
tasks.withType<Test>().configureEach {
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
excludeTestsMatching(integrationTestClassName)
|
integrationTestClasses.forEach { excludeTestsMatching(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +97,7 @@ tasks.register<Test>("integrationTest") {
|
|||||||
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
|
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
|
||||||
classpath = files(provider { unitTest.get().classpath })
|
classpath = files(provider { unitTest.get().classpath })
|
||||||
filter {
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +43,10 @@ class DownloadNotifier(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
private fun build(content: DownloadNotification): Notification =
|
private fun build(content: DownloadNotification): Notification =
|
||||||
NotificationCompat
|
NotificationCompat
|
||||||
.Builder(context, CHANNEL_ID)
|
.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -78,7 +79,9 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
|
val job = scope.coroutineContext[Job]
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
runBlocking { job?.join() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.sync.Semaphore
|
|||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xml.sax.Attributes
|
import org.xml.sax.Attributes
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class SubsonicSource(
|
class SubsonicSource(
|
||||||
@@ -272,6 +273,8 @@ class SubsonicSource(
|
|||||||
) { input -> SubsonicXml.containsEntity(input, "song") }
|
) { input -> SubsonicXml.containsEntity(input, "song") }
|
||||||
} catch (_: SubsonicException) {
|
} catch (_: SubsonicException) {
|
||||||
false
|
false
|
||||||
|
} catch (_: IOException) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
emptyQuerySearchSupported = supported
|
emptyQuerySearchSupported = supported
|
||||||
return supported
|
return supported
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ class LibraryViewModel(
|
|||||||
fun sync() = syncManager.requestSync()
|
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
|
@Test
|
||||||
fun albumOrdersAreIndexBacked() =
|
fun albumOrdersAreIndexBacked() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -442,7 +444,11 @@ class LibraryDaoTest {
|
|||||||
@Test
|
@Test
|
||||||
fun searchRunsThroughTheTrigramIndex() =
|
fun searchRunsThroughTheTrigramIndex() =
|
||||||
runTest {
|
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"))
|
assertTrue(plan, plan.contains("album_search"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class MigrationsTest {
|
|||||||
migrated.execSQL(
|
migrated.execSQL(
|
||||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " +
|
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " +
|
||||||
"starred, songCount, frequentRank, recentRank) " +
|
"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(
|
migrated.execSQL(
|
||||||
"INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " +
|
"INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " +
|
||||||
@@ -97,9 +97,24 @@ class MigrationsTest {
|
|||||||
MIGRATIONS.sortedBy { it.startVersion }.forEach { migration ->
|
MIGRATIONS.sortedBy { it.startVersion }.forEach { migration ->
|
||||||
migration.migrate(migrated)
|
migration.migrate(migrated)
|
||||||
when (migration.endVersion) {
|
when (migration.endVersion) {
|
||||||
5 -> migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'")
|
5 -> {
|
||||||
8 -> migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'")
|
migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'")
|
||||||
11 -> migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')")
|
}
|
||||||
|
|
||||||
|
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(123456L, long(migrated, "SELECT changed FROM playlists WHERE id = 'pl1'"))
|
||||||
assertEquals(987654L, long(migrated, "SELECT duration 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("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 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("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
|
package com.subtracks.data.download
|
||||||
|
|
||||||
import android.app.DownloadManager
|
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.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.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SystemDownloadEngineTest {
|
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
|
@Test
|
||||||
fun successfulRunningAndFailedStatusesAreMapped() {
|
fun successfulRunningAndFailedStatusesAreMapped() {
|
||||||
assertEquals(EngineStatus.Completed, engineStatus(DownloadManager.STATUS_SUCCESSFUL))
|
assertEquals(EngineStatus.Completed, engineStatus(DownloadManager.STATUS_SUCCESSFUL))
|
||||||
@@ -25,4 +45,66 @@ class SystemDownloadEngineTest {
|
|||||||
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
|
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
|
||||||
assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN))
|
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()
|
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
|
@Test
|
||||||
fun syncConcurrencyDefaultsAndClampsToAtLeastOne() =
|
fun syncConcurrencyDefaultsAndClampsToAtLeastOne() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -89,11 +89,15 @@ class LibraryRepositoryTest {
|
|||||||
fun starringUpdatesTheRowBeforeTheServerResponds() =
|
fun starringUpdatesTheRowBeforeTheServerResponds() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
withSource { server ->
|
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) }
|
val call = launch { repository.setStar(StarType.Song, "s1", true) }
|
||||||
withTimeout(3_000) { while (starred() == null) delay(10) }
|
withTimeout(3_000) { while (starred() == null) delay(10) }
|
||||||
call.cancel()
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKind
|
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() {
|
private suspend fun seedLibrary() {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ class SourceRepositoryTest {
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
withServer { server ->
|
withServer { server ->
|
||||||
val dispatches = AtomicInteger()
|
val dispatches = AtomicInteger()
|
||||||
|
val inFlight = AtomicInteger()
|
||||||
|
val maxInFlight = AtomicInteger()
|
||||||
val secondStarted = CountDownLatch(1)
|
val secondStarted = CountDownLatch(1)
|
||||||
val release = CountDownLatch(1)
|
val release = CountDownLatch(1)
|
||||||
server.dispatcher =
|
server.dispatcher =
|
||||||
@@ -175,7 +177,10 @@ class SourceRepositoryTest {
|
|||||||
val url = request.requestUrl!!
|
val url = request.requestUrl!!
|
||||||
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
||||||
if (dispatches.incrementAndGet() == 2) secondStarted.countDown()
|
if (dispatches.incrementAndGet() == 2) secondStarted.countDown()
|
||||||
|
val current = inFlight.incrementAndGet()
|
||||||
|
maxInFlight.updateAndGet { maxOf(it, current) }
|
||||||
release.await(5, TimeUnit.SECONDS)
|
release.await(5, TimeUnit.SECONDS)
|
||||||
|
inFlight.decrementAndGet()
|
||||||
val id = url.queryParameter("id")!!
|
val id = url.queryParameter("id")!!
|
||||||
return MockResponse().setBody(
|
return MockResponse().setBody(
|
||||||
"<subsonic-response status=\"ok\"><playlist id=\"$id\">" +
|
"<subsonic-response status=\"ok\"><playlist id=\"$id\">" +
|
||||||
@@ -199,6 +204,7 @@ class SourceRepositoryTest {
|
|||||||
assertFalse(secondStarted.await(500, TimeUnit.MILLISECONDS))
|
assertFalse(secondStarted.await(500, TimeUnit.MILLISECONDS))
|
||||||
release.countDown()
|
release.countDown()
|
||||||
assertEquals(9, fetching.await().size)
|
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 okhttp3.mockwebserver.MockWebServer
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
class SubsonicClientTest {
|
class SubsonicClientTest {
|
||||||
private lateinit var server: MockWebServer
|
private lateinit var server: MockWebServer
|
||||||
@@ -57,6 +59,36 @@ class SubsonicClientTest {
|
|||||||
assertNull(url.queryParameter("p"))
|
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
|
@Test
|
||||||
fun plaintextAuthSendsPassword() {
|
fun plaintextAuthSendsPassword() {
|
||||||
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
|
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.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import okhttp3.mockwebserver.SocketPolicy
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
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
|
@Test
|
||||||
fun artistsAreEmittedInBoundedBatches() =
|
fun artistsAreEmittedInBoundedBatches() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -353,6 +390,8 @@ class SubsonicSourceTest {
|
|||||||
val bound = 2
|
val bound = 2
|
||||||
val started = CountDownLatch(bound)
|
val started = CountDownLatch(bound)
|
||||||
val beyondBound = CountDownLatch(bound + 1)
|
val beyondBound = CountDownLatch(bound + 1)
|
||||||
|
val inFlight = AtomicInteger()
|
||||||
|
val maxInFlight = AtomicInteger()
|
||||||
val release = CountDownLatch(1)
|
val release = CountDownLatch(1)
|
||||||
server.dispatcher =
|
server.dispatcher =
|
||||||
object : Dispatcher() {
|
object : Dispatcher() {
|
||||||
@@ -361,7 +400,10 @@ class SubsonicSourceTest {
|
|||||||
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
||||||
beyondBound.countDown()
|
beyondBound.countDown()
|
||||||
started.countDown()
|
started.countDown()
|
||||||
|
val current = inFlight.incrementAndGet()
|
||||||
|
maxInFlight.updateAndGet { maxOf(it, current) }
|
||||||
release.await(5, TimeUnit.SECONDS)
|
release.await(5, TimeUnit.SECONDS)
|
||||||
|
inFlight.decrementAndGet()
|
||||||
return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!))
|
return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +426,7 @@ class SubsonicSourceTest {
|
|||||||
val entries = fetching.await()
|
val entries = fetching.await()
|
||||||
|
|
||||||
assertEquals(12, entries.size)
|
assertEquals(12, entries.size)
|
||||||
|
assertEquals("the bound must cap concurrent fetches", bound, maxInFlight.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import okhttp3.OkHttpClient
|
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.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -22,6 +27,9 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SyncManagerTest {
|
class SyncManagerTest {
|
||||||
@@ -30,6 +38,7 @@ class SyncManagerTest {
|
|||||||
private lateinit var queueRepository: QueueRepository
|
private lateinit var queueRepository: QueueRepository
|
||||||
private lateinit var manager: SyncManager
|
private lateinit var manager: SyncManager
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -42,12 +51,15 @@ class SyncManagerTest {
|
|||||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
|
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
|
||||||
queueRepository = QueueRepository(db)
|
queueRepository = QueueRepository(db)
|
||||||
manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it })
|
manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it })
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
sourceRepository.close()
|
sourceRepository.close()
|
||||||
db.close()
|
db.close()
|
||||||
|
server.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,4 +82,146 @@ class SyncManagerTest {
|
|||||||
assertTrue(status is SyncStatus.Failed)
|
assertTrue(status is SyncStatus.Failed)
|
||||||
assertEquals(before + 1, queueRepository.snapshot().version)
|
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 android.content.Context
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.download.DownloadNotifier
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertSame
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.Koin
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class AppModuleTest {
|
class AppModuleTest {
|
||||||
|
private lateinit var koin: Koin
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() = stopKoin()
|
fun setUp() = stopKoin()
|
||||||
|
|
||||||
@After
|
@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
|
@Test
|
||||||
fun theModuleResolvesThePlaybackGraph() {
|
fun theModuleResolvesThePlaybackGraph() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
val koin = startKoin { modules(appModule(context, OkHttpClient())) }.koin
|
koin = startKoin { modules(appModule(context, OkHttpClient())) }.koin
|
||||||
|
|
||||||
assertNotNull(koin.get<DownloadRepository>())
|
val downloads = koin.get<DownloadRepository>()
|
||||||
assertNotNull(koin.get<PlaybackController>())
|
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.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -1236,11 +1237,10 @@ class PlaybackControllerTest {
|
|||||||
await { controller.state.value.isPlaying }
|
await { controller.state.value.isPlaying }
|
||||||
|
|
||||||
handle.startBuffering()
|
handle.startBuffering()
|
||||||
Thread.sleep(300)
|
runBlocking { withContext(dispatcher) { } }
|
||||||
assertFalse(controller.state.value.isBuffering)
|
assertFalse("buffering must be debounced, not reported immediately", controller.state.value.isBuffering)
|
||||||
|
|
||||||
Thread.sleep(1_200)
|
await { controller.state.value.isBuffering }
|
||||||
assertTrue(controller.state.value.isBuffering)
|
|
||||||
|
|
||||||
handle.becomeReady()
|
handle.becomeReady()
|
||||||
await { !controller.state.value.isBuffering }
|
await { !controller.state.value.isBuffering }
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ import org.robolectric.annotation.Config
|
|||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
import kotlin.random.Random
|
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)
|
@OptIn(DelicateCoilApi::class)
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import org.junit.runner.RunWith
|
|||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
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)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.junit.runner.RunWith
|
|||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
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)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
@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
|
package com.subtracks.ui.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
@@ -22,6 +23,7 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
@@ -43,6 +45,8 @@ class QueueViewModelTest {
|
|||||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
|
||||||
private lateinit var db: SubtracksDatabase
|
private lateinit var db: SubtracksDatabase
|
||||||
|
private lateinit var sources: SourceRepository
|
||||||
|
private lateinit var downloads: DownloadRepository
|
||||||
private lateinit var queues: QueueRepository
|
private lateinit var queues: QueueRepository
|
||||||
private lateinit var controller: PlaybackController
|
private lateinit var controller: PlaybackController
|
||||||
private lateinit var viewModel: QueueViewModel
|
private lateinit var viewModel: QueueViewModel
|
||||||
@@ -56,10 +60,10 @@ class QueueViewModelTest {
|
|||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(BundledSQLiteDriver())
|
||||||
.build()
|
.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)
|
queues = QueueRepository(db)
|
||||||
val artwork = ArtworkStore(File(context.cacheDir, "art"))
|
val artwork = ArtworkStore(File(context.cacheDir, "art"))
|
||||||
val downloads =
|
downloads =
|
||||||
DownloadRepository(
|
DownloadRepository(
|
||||||
db,
|
db,
|
||||||
sources,
|
sources,
|
||||||
@@ -74,7 +78,10 @@ class QueueViewModelTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
viewModel.viewModelScope.cancel()
|
||||||
controller.close()
|
controller.close()
|
||||||
|
downloads.close()
|
||||||
|
sources.close()
|
||||||
db.close()
|
db.close()
|
||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
dispatcher.close()
|
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
|
@Test
|
||||||
fun resolvesColorsFromCoverArt() {
|
fun resolvesColorsFromCoverArt() {
|
||||||
val ref = CoverArtRef(url = "art", cacheKey = "art-${System.nanoTime()}")
|
val ref = CoverArtRef(url = "art", cacheKey = "art")
|
||||||
var colors: ArtworkColors? = null
|
var colors: ArtworkColors? = null
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
colors = rememberArtworkColors(ref)
|
colors = rememberArtworkColors(ref)
|
||||||
@@ -113,7 +113,7 @@ class ArtworkExtractionTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() {
|
fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() {
|
||||||
val key = "missing-${System.nanoTime()}"
|
val key = "missing"
|
||||||
val stored = ArtworkSeed(key, 0xFF3A7BD5.toInt(), 0xFFD53A3A.toInt(), null)
|
val stored = ArtworkSeed(key, 0xFF3A7BD5.toInt(), 0xFFD53A3A.toInt(), null)
|
||||||
ArtworkSeedCache.install(
|
ArtworkSeedCache.install(
|
||||||
object : ArtworkSeedStore {
|
object : ArtworkSeedStore {
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
sdk=35
|
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