From ba6a03b2bd22ba29506e16caff708383caec07ac Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 3 Nov 2026 17:40:49 +0000 Subject: Look again for a waiting episode, and place a library nobody has placed The last two pieces, both of a kind: a feature that was built, wired and correct, and did nothing because the thing that triggers it was missing. **"Waiting for server" lasted until the next refresh.** The server starts fetching the moment it is asked and its on-demand mirrors finish in seconds -- 183 of them over thirty days, several completing two or three seconds apart. The phone looked 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. That gap, not anything on the server, is what made "not checked yet" a state a listener sees. A wait now schedules one look back a minute later, through WaitingDownloads, which already bounds the work two ways. KEEP rather than the REPLACE 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 until a queue of them starved it. The first wait sets the time. The hook lives on ServerSkips rather than on a wrapper around it. The wrapper version was written first and is the sort of thing that rots silently: changing the graph's declared type back to ServerSkips drops the override, nothing fails, and the retry simply stops. WaitForServerWiringTest now pins that a wait is reported through the real class, and was watched failing without it. **Tiers were shipped empty.** The control, the push on every refresh, the server ordering its work by it -- all built, all tested, and not one of 43 shows ever placed, because placing them was a tapping job. So the server worked the library in feed order and the news waited behind whatever else turned up, which is the exact thing the feature was asked for. TierSeeder reads it off the feeds instead, checked against the real library before it was written. It puts Tangle, Up First, Science Friday and Wards Way in Topical; both Dan Carlin shows, Radiolab, Reply All and the bulk-published back catalogues in Whenever; and leaves the weekly middle -- 23 of the 42 -- alone. Peter named Up First and Tangle as the ones to hurry and Dan Carlin and Radiolab as the ones to let wait, so the rule agrees with the person it is for. Publication rate alone does not work, which is why there is a floor. Serial, LOW and The Outer Reach each measure a median gap near zero because a whole season went up at once: on rate they look more topical than the news and mean the exact opposite. A gap under half a day is a back catalogue, not a daily show. It is a seed, not a policy. It runs once, touches only shows still sitting at the default, and never a show placed by hand -- and it declines to spend its one run on a library with no episodes to judge yet, or a fresh install would be marked done having placed nothing. Afterwards the control in the show's settings is the only thing that decides. Verified on the emulator rather than argued: Darknet Diaries, fortnightly, placed itself as "Whenever" on a refresh with nothing tapped. RetryWaitingWorker joins the three WorkManager entry points already excluded from coverage, under the list's own rule -- the decisions are WaitingDownloads', and those are measured. 1071 tests, detekt 0, lint clean, coverage 95.29% -> 95.35%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../main/java/org/terst/listen/data/Preferences.kt | 13 +++ .../java/org/terst/listen/data/Repositories.kt | 24 +++++- .../terst/listen/data/SubscriptionRepository.kt | 10 +++ .../main/java/org/terst/listen/data/TierSeeder.kt | 76 +++++++++++++++++ .../main/java/org/terst/listen/data/TierSeeding.kt | 52 ++++++++++++ .../java/org/terst/listen/server/ServerSkips.kt | 14 ++++ app/src/main/java/org/terst/listen/work/Workers.kt | 39 +++++++++ .../java/org/terst/listen/data/TierSeederTest.kt | 97 ++++++++++++++++++++++ .../java/org/terst/listen/data/TierSeedingTest.kt | 95 +++++++++++++++++++++ .../listen/download/WaitForServerWiringTest.kt | 23 ++++- 10 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/terst/listen/data/TierSeeder.kt create mode 100644 app/src/main/java/org/terst/listen/data/TierSeeding.kt create mode 100644 app/src/test/java/org/terst/listen/data/TierSeederTest.kt create mode 100644 app/src/test/java/org/terst/listen/data/TierSeedingTest.kt (limited to 'app/src') 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 = 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 = 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 = 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 { + 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, 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): Map = + 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.. ShowTier.ARCHIVE + in BULK_UPLOAD_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 { + 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 @@ -102,6 +117,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() + .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 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 { + val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() + return (0 until count).map { it * step } + } + + private fun place(dates: List, 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")) -- cgit v1.2.3