address review: test the source wiring, one default, clamp the stored value

- Add a `SourceRepositoryTest` that sets the preference to 1 and proves the
  active source fetches sequentially; removing the `maxConcurrentFetches`
  wiring now fails it.
- `playlistSongsRespectsASmallerFetchBound` now holds the first `bound`
  fetches and asserts no `(bound + 1)`th starts, instead of timing an
  overlap; it fails when the semaphore ignores the bound.
- One `DEFAULT_FETCH_CONCURRENCY` (in `data.source`) backs both the source
  default and the preference default, so they cannot drift.
- Clamp the stored preference in `syncConcurrency()` too, so a bad stored
  value can't make `SubsonicSource` throw on construction.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent a4149690e5
commit 5ade5073f8
7 changed files with 72 additions and 18 deletions
@@ -3,6 +3,7 @@ package com.subtracks.data.prefs
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@@ -65,7 +66,7 @@ class UserPreferencesTest {
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(DEFAULT_SYNC_CONCURRENCY, prefs.syncConcurrency().first())
assertEquals(DEFAULT_FETCH_CONCURRENCY, prefs.syncConcurrency().first())
prefs.setSyncConcurrency(8)
assertEquals(8, prefs.syncConcurrency().first())
@@ -9,15 +9,21 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -26,10 +32,14 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@RunWith(AndroidJUnit4::class)
class SourceRepositoryTest {
private lateinit var db: SubtracksDatabase
private lateinit var prefs: UserPreferences
private lateinit var repository: SourceRepository
private lateinit var artwork: ArtworkStore
private val messages = ArrayList<String>()
@@ -43,7 +53,8 @@ class SourceRepositoryTest {
.setDriver(BundledSQLiteDriver())
.build()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
repository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, showMessage = { messages += it })
prefs = fakeUserPreferences()
repository = SourceRepository(db, OkHttpClient(), prefs, artwork, showMessage = { messages += it })
}
@After
@@ -88,6 +99,46 @@ class SourceRepositoryTest {
}
}
@Test
fun theActiveSourceFetchConcurrencyComesFromThePreference() =
runBlocking {
withServer { server ->
val dispatches = AtomicInteger()
val secondStarted = CountDownLatch(1)
val release = CountDownLatch(1)
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
if (dispatches.incrementAndGet() == 2) secondStarted.countDown()
release.await(5, TimeUnit.SECONDS)
val id = url.queryParameter("id")!!
return MockResponse().setBody(
"<subsonic-response status=\"ok\"><playlist id=\"$id\">" +
(1..3).joinToString("") { "<entry id=\"$id-s$it\" title=\"t$it\"/>" } +
"</playlist></subsonic-response>",
)
}
}
prefs.setSyncConcurrency(1)
repository.addSource("nav", server.url("/").toString(), "u", "p", false)
val source = repository.activeMusicSource()!!
val fetching =
async(Dispatchers.IO) {
source.playlistSongs(listOf("p1", "p2", "p3")).toList().flatten()
}
// With concurrency 1 the second fetch must not start while the first is in flight;
// if the preference is not wired the source defaults to a higher bound and it does.
assertFalse(secondStarted.await(500, TimeUnit.MILLISECONDS))
release.countDown()
assertEquals(9, fetching.await().size)
}
}
@Test
fun pingReportsWhetherItFellBackToThePassword() =
runBlocking {
@@ -1,5 +1,6 @@
package com.subtracks.data.source.subsonic
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
@@ -314,7 +315,7 @@ class SubsonicSourceTest {
assertEquals(listOf(0L, 1L, 2L), batch.sortedBy { it.position }.map { it.position })
assertEquals((1..3).map { "$playlistId-s$it" }, batch.sortedBy { it.position }.map { it.songId })
}
assertTrue(maxInFlight.get() in 2..MAX_CONCURRENT_FETCHES)
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
}
@Test
@@ -349,20 +350,17 @@ class SubsonicSourceTest {
fun playlistSongsRespectsASmallerFetchBound() =
runBlocking {
val bound = 2
val inFlight = AtomicInteger()
val maxInFlight = AtomicInteger()
val started = CountDownLatch(bound)
val beyondBound = CountDownLatch(bound + 1)
val release = CountDownLatch(1)
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
val current = inFlight.incrementAndGet()
maxInFlight.updateAndGet { maxOf(it, current) }
beyondBound.countDown()
started.countDown()
release.await(5, TimeUnit.SECONDS)
inFlight.decrementAndGet()
return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!))
}
}
@@ -376,11 +374,15 @@ class SubsonicSourceTest {
}
assertTrue("$bound fetches should have started together", started.await(5, TimeUnit.SECONDS))
// While those are held, the semaphore must block any further fetch.
assertFalse(
"a ${bound + 1}th fetch started past the bound",
beyondBound.await(500, TimeUnit.MILLISECONDS),
)
release.countDown()
val entries = fetching.await()
assertEquals(12, entries.size)
assertEquals(bound, maxInFlight.get())
}
@Test
@@ -496,7 +498,7 @@ class SubsonicSourceTest {
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(4, songs.size)
assertTrue(maxInFlight.get() in 2..MAX_CONCURRENT_FETCHES)
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
}
@Test