summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--app/build.gradle.kts3
-rw-r--r--app/src/main/java/org/terst/listen/data/Preferences.kt13
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt24
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt10
-rw-r--r--app/src/main/java/org/terst/listen/data/TierSeeder.kt76
-rw-r--r--app/src/main/java/org/terst/listen/data/TierSeeding.kt52
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt14
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt39
-rw-r--r--app/src/test/java/org/terst/listen/data/TierSeederTest.kt97
-rw-r--r--app/src/test/java/org/terst/listen/data/TierSeedingTest.kt95
-rw-r--r--app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt23
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"))