diff options
| -rw-r--r-- | .coverage-baseline | 2 | ||||
| -rw-r--r-- | app/build.gradle.kts | 3 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/Preferences.kt | 13 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/Repositories.kt | 24 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt | 10 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/TierSeeder.kt | 76 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/TierSeeding.kt | 52 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/server/ServerSkips.kt | 14 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/work/Workers.kt | 39 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/listen/data/TierSeederTest.kt | 97 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/listen/data/TierSeedingTest.kt | 95 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt | 23 |
12 files changed, 443 insertions, 5 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 621badc..da45132 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -95.29 +95.35 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7b74cb..80c4dc7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -377,6 +377,9 @@ val coverageExclusions = listOf( "**/work/RefreshWorker*", "**/work/DownloadWorker*", "**/work/RetentionWorker*", + // Same rule: the decisions are WaitingDownloads', which bounds the retry two ways and is + // measured. This is the WorkManager entry point that calls it. + "**/work/RetryWaitingWorker*", "**/work/Scheduling*", // DataStore: a Context, a file and a generated Flow. Nothing here decides anything. diff --git a/app/src/main/java/org/terst/listen/data/Preferences.kt b/app/src/main/java/org/terst/listen/data/Preferences.kt index 65df0f3..7ccecf1 100644 --- a/app/src/main/java/org/terst/listen/data/Preferences.kt +++ b/app/src/main/java/org/terst/listen/data/Preferences.kt @@ -62,6 +62,18 @@ class Preferences(private val context: Context) : ViewPreferences { */ override val skipShowFurniture: Flow<Boolean> = context.store.data.map { it[SKIP_SHOW_FURNITURE] ?: true } + /** + * Whether the one-time tier seed has run; see [TierSeeder]. + * + * A flag rather than "place anything still at the default", because moving a show back to + * Keeping up by hand is a decision, and without this the next refresh would undo it. + */ + val tiersSeeded: Flow<Boolean> = context.store.data.map { it[TIERS_SEEDED] ?: false } + + suspend fun setTiersSeeded(value: Boolean) { + context.store.edit { it[TIERS_SEEDED] = value } + } + suspend fun setSkipShowFurniture(value: Boolean) { context.store.edit { it[SKIP_SHOW_FURNITURE] = value } } @@ -138,6 +150,7 @@ class Preferences(private val context: Context) : ViewPreferences { val SERVER_URL = stringPreferencesKey("server_url") val SERVER_TOKEN = stringPreferencesKey("server_token") val SKIP_SHOW_FURNITURE = booleanPreferencesKey("skip_show_furniture") + val TIERS_SEEDED = booleanPreferencesKey("tiers_seeded") val FAST_PLAY_BREAKS = booleanPreferencesKey("fast_play_breaks") val REJECTED_URL = stringPreferencesKey("rejected_server_url") val REJECTED_TOKEN = stringPreferencesKey("rejected_server_token") diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index a2756b8..f873079 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -210,6 +210,7 @@ class AppGraph(private val context: Context) : UiGraph { }, refreshSkips = { skipRefresher.refreshAll() }, retryWaiting = { waitingDownloads.retryAll() }, + seedTiers = { tierSeeding.seedOnce() }, ), ) } @@ -243,15 +244,32 @@ class AppGraph(private val context: Context) : UiGraph { * Only the exceptions: sending "ordinary" for sixty shows would be the same information as * sending nothing, in sixty times the space. */ + /** Places a library nobody has placed; see [TierSeeder] for the rule and why it exists. */ + val tierSeeding: TierSeeding by lazy { + TierSeeding( + subscriptions, episodes, + hasSeeded = { preferences.tiersSeeded.first() }, + markSeeded = { preferences.setTiersSeeded(true) }, + ) + } + private suspend fun placedTiers(): Map<String, String> = subscriptions.getAll() .filter { ShowTier.of(it.tier) != ShowTier.ORDINARY } .associate { it.feedUrl to it.tier } - /** Shared between the download path and [skipRefresher], which write the server's answer - * into exactly the same columns. */ + /** + * Shared between the download path and [skipRefresher], which write the server's answer into + * exactly the same columns. + * + * What a wait *triggers* is decided here rather than inside [ServerSkips], which is about the + * server's answers and already carries as many collaborators as it should. Scheduling is the + * wiring's business, and this is the wiring. + */ val serverSkips: ServerSkips by lazy { - ServerSkips(skipServer, subscriptions, fingerprints, playbackState, midrolls, transcripts) + ServerSkips(skipServer, subscriptions, fingerprints, playbackState, midrolls, transcripts).also { + it.onWaiting = { Scheduling.retryWaitingSoon(context) } + } } /** diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt index 933c838..6ca6177 100644 --- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt +++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt @@ -79,6 +79,15 @@ data class SubscriptionCollaborators( * refresh route passes through, the library's refresh button included. */ val retryWaiting: suspend () -> Unit = {}, + /** + * Places shows that have never been placed; see [TierSeeder]. + * + * Before the server is told, so a seeded tier goes up on the same refresh that worked it out + * rather than a cycle later. A no-op once it has run, and on a fresh install -- where there + * are no episodes yet to judge a cadence from -- it simply finds nothing and tries again on + * the next refresh. + */ + val seedTiers: suspend () -> Unit = {}, ) class SubscriptionRepository( @@ -145,6 +154,7 @@ class SubscriptionRepository( } suspend fun refreshAll(): Map<String, RefreshOutcome> { + collaborators.seedTiers() val shows = subscriptions.getAll() // The one place every refresh passes through, which is why the server is told here // rather than in a worker: the refresh button in the library calls this directly and diff --git a/app/src/main/java/org/terst/listen/data/TierSeeder.kt b/app/src/main/java/org/terst/listen/data/TierSeeder.kt new file mode 100644 index 0000000..b7f4847 --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/TierSeeder.kt @@ -0,0 +1,76 @@ +package org.terst.listen.data + +import java.util.concurrent.TimeUnit + +/** + * Places shows into [ShowTier] from how often they actually publish. + * + * The tier feature shipped complete -- a control per show, pushed to the server on every refresh, + * the server ordering its work by it -- and empty, because every show has to be placed by hand + * and none ever was. So the server worked a 43-show library in feed order and the news waited + * behind whatever else arrived. + * + * This is a seed, not a policy. It runs once, places only shows still sitting at the default, and + * never touches one that has been placed; afterwards the control in the show's settings is the + * only thing that decides. A guess that is right most of the time beats a default nobody sets, + * and a wrong one costs a tap. + */ +object TierSeeder { + + /** What a show's feed says about it: when it published, and whether it is being dripped. */ + data class Cadence(val subscriptionId: String, val publishedAtMs: List<Long>, val dripEnabled: Boolean) + + /** + * Enough recent episodes to see a rhythm, and few enough that an old one does not count. + * + * A show that was daily three years ago and is monthly now is not news, and taking the whole + * feed would say it was. + */ + private const val RECENT = 12 + + /** Fewer than this and the gaps between them are not evidence of anything. */ + private const val ENOUGH = 4 + + /** + * Below this, the episodes did not arrive on a cadence at all -- they were published together. + * + * The case that makes rate alone useless. Serial, LOW and The Outer Reach each measure a + * median gap of about zero because a whole season went up at once, which on publication rate + * makes them look more topical than the news and means the exact opposite. + */ + private const val BULK_UPLOAD_DAYS = 0.5 + + /** Daily, give or take. Tangle, Up First and Science Friday sit between 0.9 and 1.0. */ + private const val TOPICAL_DAYS = 2.0 + + /** Fortnightly or rarer. Radiolab is 14 days; Hardcore History is 223. */ + private const val ARCHIVE_DAYS = 14.0 + + /** The placements to apply: only the shows this would move off the default. */ + fun seed(shows: List<Cadence>): Map<String, ShowTier> = + shows.associate { it.subscriptionId to place(it) } + .filterValues { it != ShowTier.ORDINARY } + + /** Where one show belongs, or [ShowTier.ORDINARY] when its feed says nothing useful. */ + fun place(show: Cadence): ShowTier { + // Already declared to be heard whenever, so there is nothing to work out. + if (show.dripEnabled) return ShowTier.ARCHIVE + + val recent = show.publishedAtMs.sorted().takeLast(RECENT) + if (recent.size < ENOUGH) return ShowTier.ORDINARY + + val gaps = recent.zipWithNext { a, b -> (b - a).toDouble() / TimeUnit.DAYS.toMillis(1) } + return when (val median = median(gaps)) { + in 0.0..<BULK_UPLOAD_DAYS -> ShowTier.ARCHIVE + in BULK_UPLOAD_DAYS..<TOPICAL_DAYS -> ShowTier.TOPICAL + else -> if (median >= ARCHIVE_DAYS) ShowTier.ARCHIVE else ShowTier.ORDINARY + } + } + + /** The median, not the mean: one re-run or one long hiatus must not move the answer. */ + private fun median(values: List<Double>): Double { + val sorted = values.sorted() + val middle = sorted.size / 2 + return if (sorted.size % 2 == 1) sorted[middle] else (sorted[middle - 1] + sorted[middle]) / 2 + } +} diff --git a/app/src/main/java/org/terst/listen/data/TierSeeding.kt b/app/src/main/java/org/terst/listen/data/TierSeeding.kt new file mode 100644 index 0000000..d86b8d5 --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/TierSeeding.kt @@ -0,0 +1,52 @@ +package org.terst.listen.data + +/** + * Runs [TierSeeder] once over a library that has never been placed. + * + * Kept apart from the seeder itself so the rule stays a pure function with its own tests, and + * this holds only the reading and writing -- the same split as `RetentionSweeper` and its worker. + */ +class TierSeeding( + private val subscriptions: SubscriptionDao, + private val episodes: EpisodeDao, + /** Whether the seed has already run, and how to record that it has. */ + private val hasSeeded: suspend () -> Boolean, + private val markSeeded: suspend () -> Unit, +) { + + /** + * Returns how many shows were placed. + * + * The flag is set only when something was actually placed. A fresh install has no episodes + * to judge a cadence from, and marking it done there would mean the library was never + * placed at all. + */ + suspend fun seedOnce(): Int { + if (hasSeeded()) return 0 + + val shows = subscriptions.getAll() + if (shows.isEmpty()) return 0 + + val placements = TierSeeder.seed( + shows.map { show -> + TierSeeder.Cadence( + subscriptionId = show.id, + publishedAtMs = episodes.getForSubscription(show.id).mapNotNull { it.publishedAtMs }, + dripEnabled = show.dripEnabled, + ) + }, + ) + if (placements.isEmpty()) return 0 + + // Only shows still at the default. One already placed by hand is a decision. + val byId = shows.associateBy { it.id } + var placed = 0 + for ((id, tier) in placements) { + if (ShowTier.of(byId[id]?.tier) != ShowTier.ORDINARY) continue + subscriptions.setTier(id, tier.wire) + placed++ + } + markSeeded() + return placed + } +} diff --git a/app/src/main/java/org/terst/listen/server/ServerSkips.kt b/app/src/main/java/org/terst/listen/server/ServerSkips.kt index 87aa104..847a99f 100644 --- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt +++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt @@ -27,6 +27,20 @@ class ServerSkips( private val transcripts: TranscriptDao, ) : ServerAudio { + /** + * What to do when the server could not supply an episode yet. Set by the graph. + * + * A property rather than a constructor parameter: this class already carries as many + * collaborators as it should, and a seventh is over the limit. It lives here rather than on + * a wrapper around this class because a wrapper's override disappears the moment somebody + * changes the graph's declared type back to ServerSkips, and nothing would fail -- the + * retry would simply stop happening, silently, which is the shape of bug this whole + * waiting-for-server path has already produced once. + */ + var onWaiting: () -> Unit = {} + + override suspend fun waiting(episode: EpisodeEntity) = onWaiting() + override suspend fun canAsk(episode: EpisodeEntity): Boolean = server.isConfigured() && subscriptions.getById(episode.subscriptionId) != null diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt index 9809e9d..5e1bcad 100644 --- a/app/src/main/java/org/terst/listen/work/Workers.kt +++ b/app/src/main/java/org/terst/listen/work/Workers.kt @@ -77,12 +77,27 @@ class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWor } } +/** + * Retries the downloads waiting on the skip server's copy, shortly after one started waiting. + * + * The decisions are WaitingDownloads', which bounds the work two ways; this is only the trigger. + */ +class RetryWaitingWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val retried = Graph.get(applicationContext).waitingDownloads.retryAll() + return Result.success(androidx.work.Data.Builder().putInt("retried", retried).build()) + } +} + object Scheduling { private const val REFRESH_WORK = "listen-refresh" private const val DOWNLOAD_WORK = "listen-download" private const val DOWNLOAD_NOW_WORK = "listen-download-now" private const val RETENTION_WORK = "listen-retention" + private const val RETRY_WAITING_WORK = "listen-retry-waiting" + private const val RETRY_WAITING_SECONDS = 60L private const val REFRESH_HOURS = 6L private const val DOWNLOAD_HOURS = 2L private const val RETENTION_HOURS = 12L @@ -103,6 +118,30 @@ object Scheduling { } /** + * Looks again for a server copy that was not ready, a minute after it was not. + * + * The server starts fetching the moment it is asked, and its on-demand mirrors complete in + * seconds -- 183 of them over thirty days, several finishing two to three seconds apart. The + * phone was looking again only on a feed refresh, which is the 5am alarm, a launch or the + * button, so an episode the server would have within the minute read "waiting for server" for + * hours. A minute covers a queued burst and a slow publisher; if it is still not ready, the + * refresh path is unchanged and picks it up as before. + * + * KEEP, not REPLACE as [downloadNow] uses. Several episodes landing in the waiting state one + * after another is the normal case, and REPLACE would push the retry a minute further out + * each time -- a queue of them would starve it indefinitely. The first wait sets the time. + */ + fun retryWaitingSoon(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + RETRY_WAITING_WORK, + ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder<RetryWaitingWorker>() + .setInitialDelay(RETRY_WAITING_SECONDS, TimeUnit.SECONDS) + .build(), + ) + } + + /** * The periodic jobs are KEEP rather than UPDATE, so the schedule survives an app restart * instead of resetting its interval every launch -- which on a frequently-opened app means * the periodic work never actually fires. diff --git a/app/src/test/java/org/terst/listen/data/TierSeederTest.kt b/app/src/test/java/org/terst/listen/data/TierSeederTest.kt new file mode 100644 index 0000000..3935851 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/TierSeederTest.kt @@ -0,0 +1,97 @@ +package org.terst.listen.data + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * Placing a library's shows from how they actually publish. + * + * Peter's framing, in his words: "prioritize server processing for things like up first and + * tangle, and let things like Dan Carlin or radiolab wait for idle periods". The tier feature + * shipped built, tested and wired end to end -- and with nothing in it, because every show has to + * be placed by hand and none had been. A seed derived from the feeds does the obvious ones. + * + * Checked against the real library before it was written. The rule here puts Tangle, Up First, + * Science Friday and Wards Way in [ShowTier.TOPICAL]; both Dan Carlin shows, Radiolab, Reply All + * and the bulk-published back catalogues in [ShowTier.ARCHIVE]; and the weekly middle -- 23 of + * the 42 shows -- untouched. + */ +class TierSeederTest { + + private fun daily(count: Int) = cadenceOf(count, days = 1.0) + + private fun cadenceOf(count: Int, days: Double): List<Long> { + val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() + return (0 until count).map { it * step } + } + + private fun place(dates: List<Long>, dripEnabled: Boolean = false) = + TierSeeder.place(TierSeeder.Cadence("s1", dates, dripEnabled)) + + @Test + fun `a daily show is topical`() { + assertEquals(ShowTier.TOPICAL, place(daily(12))) + } + + @Test + fun `a weekly show is left where it is`() { + assertEquals(ShowTier.ORDINARY, place(cadenceOf(12, days = 7.0))) + } + + /** Radiolab is fortnightly; Dan Carlin is twice a year. Both wait. */ + @Test + fun `an occasional show waits`() { + assertEquals(ShowTier.ARCHIVE, place(cadenceOf(12, days = 14.0))) + assertEquals(ShowTier.ARCHIVE, place(cadenceOf(6, days = 200.0))) + } + + /** + * The case that broke the first rule, and the reason the floor exists. + * + * Serial, LOW and The Outer Reach all measure a median gap of about zero -- a whole season + * published in one go. On publication rate alone they look more topical than the news, and + * they are the exact opposite: a back catalogue, which is what archive means. + */ + @Test + fun `a back catalogue published all at once is an archive, not the most topical thing there is`() { + assertEquals(ShowTier.ARCHIVE, place(cadenceOf(12, days = 0.0))) + } + + /** Already said to be heard whenever, so there is nothing to work out. */ + @Test + fun `a slow-drip show waits whatever its feed does`() { + assertEquals(ShowTier.ARCHIVE, place(daily(12), dripEnabled = true)) + } + + /** + * Too little to go on is not evidence of anything, and the default starves nothing -- the + * same reasoning ShowTier.of uses for an unrecognised stored value. + */ + @Test + fun `a show with too few episodes to judge is left alone`() { + assertEquals(ShowTier.ORDINARY, place(daily(3))) + assertEquals(ShowTier.ORDINARY, place(emptyList())) + } + + /** Only the newest stretch counts: a show that used to be daily and is now monthly is not news. */ + @Test + fun `an old burst does not make a quiet show topical`() { + val longAgo = cadenceOf(12, days = 1.0) + val recent = (1..12).map { longAgo.last() + it * TimeUnit.DAYS.toMillis(30) } + + assertEquals(ShowTier.ARCHIVE, place(longAgo + recent)) + } + + @Test + fun `seeding places only the shows still sitting at the default`() { + val placed = TierSeeder.seed( + listOf( + TierSeeder.Cadence("daily", daily(12), dripEnabled = false), + TierSeeder.Cadence("weekly", cadenceOf(12, days = 7.0), dripEnabled = false), + ), + ) + + assertEquals(mapOf("daily" to ShowTier.TOPICAL), placed) + } +} diff --git a/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt b/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt new file mode 100644 index 0000000..7f64ee3 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt @@ -0,0 +1,95 @@ +package org.terst.listen.data + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * Applying [TierSeeder]'s answer to a library, once. + * + * The rule is tested next door; this is about what it is allowed to touch. A placement nobody + * made is worth guessing at, and one somebody made is not. + */ +class TierSeedingTest { + + private val subscriptions = FakeSubscriptionDao() + private val episodes = FakeEpisodeDao() + private var seeded = false + + private fun seeding() = TierSeeding( + subscriptions, episodes, + hasSeeded = { seeded }, + markSeeded = { seeded = true }, + ) + + private fun show(id: String, tier: String = "ordinary", dripEnabled: Boolean = false) { + subscriptions.rows += SubscriptionEntity( + id = id, feedUrl = "https://$id", title = id, addedAtMs = 0, + tier = tier, dripEnabled = dripEnabled, + ) + } + + private fun publishesEvery(id: String, days: Double, count: Int = 12) { + val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() + repeat(count) { i -> + episodes.rows += EpisodeEntity( + id = "$id-$i", subscriptionId = id, guid = "$id-$i", title = "e$i", + mediaUrl = "https://cdn/$i.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, + publishedAtMs = i * step, + ) + } + } + + @Test + fun `a daily show is placed`() = runTest { + show("daily") + publishesEvery("daily", days = 1.0) + + assertEquals(1, seeding().seedOnce()) + assertEquals(ShowTier.TOPICAL.wire, subscriptions.rows.single().tier) + } + + /** A placement made by hand is a decision, and a guess must not overwrite one. */ + @Test + fun `a show already placed by hand is left alone`() = runTest { + show("daily", tier = ShowTier.ARCHIVE.wire) + publishesEvery("daily", days = 1.0) + + assertEquals(0, seeding().seedOnce()) + assertEquals(ShowTier.ARCHIVE.wire, subscriptions.rows.single().tier) + } + + @Test + fun `it does not run twice`() = runTest { + show("daily") + publishesEvery("daily", days = 1.0) + seeding().seedOnce() + + subscriptions.setTier("daily", ShowTier.ORDINARY.wire) + + assertEquals("moving a show back by hand must survive the next refresh", 0, seeding().seedOnce()) + assertEquals(ShowTier.ORDINARY.wire, subscriptions.rows.single().tier) + } + + /** + * A fresh install has subscriptions before it has episodes, and placing nothing there must + * not count as having placed the library -- or it would never be placed at all. + */ + @Test + fun `a library with nothing to judge is left for next time`() = runTest { + show("new") + + assertEquals(0, seeding().seedOnce()) + assertEquals(false, seeded) + } + + @Test + fun `a weekly show is left at the default and does not spend the one run`() = runTest { + show("weekly") + publishesEvery("weekly", days = 7.0) + + assertEquals(0, seeding().seedOnce()) + assertEquals(false, seeded) + } +} diff --git a/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt b/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt index 67832e3..9b7a225 100644 --- a/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt +++ b/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt @@ -80,10 +80,31 @@ class WaitForServerWiringTest { val fingerprints = FakeShowFingerprintDao().apply { episodeRows = dao.rows } val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao(), FakeTranscriptDao(), - ) + ).also { it.onWaiting = { waitsReported++ } } return EpisodeDownloader(OkHttpClient(), dao, temp.newFolder(), null, serverSkips) { 1L } } + /** What the graph hangs the "look again shortly" scheduling off; see ServerSkips.onWaiting. */ + private var waitsReported = 0 + + /* + A wait reaches the hook through the real ServerSkips. + + The scheduling itself is WorkManager's and cannot run here, but whether a wait is *reported* + at all is this class's business and is exactly what a refactor would quietly drop -- an + earlier draft put the override on a wrapper around ServerSkips, where changing the graph's + declared type back would have stopped the retry with nothing failing. + */ + @Test + fun `an episode left waiting reports the wait`() = runTest { + val transport = Transport(SkipReply.Body("""{"status":"pending"}""")) + + downloader(transport, configured = true).download(episode(), SubscriptionKind.PODCAST) + + assertEquals(DownloadState.WAITING_FOR_SERVER, dao.rows.single().downloadState) + assertEquals("a wait must be reported, or nothing schedules a look back", 1, waitsReported) + } + @Test fun `no server configured downloads from the publisher immediately`() = runTest { publisher.enqueue(MockResponse().setBody("publisher-bytes")) |
