summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/data/Preferences.kt28
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt77
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt71
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/BreakJudging.kt4
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeActions.kt45
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt7
-rw-r--r--app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt38
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerBar.kt1
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt19
-rw-r--r--app/src/main/java/org/terst/listen/ui/RulesViewModel.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt1
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt1
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt13
-rw-r--r--app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt7
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt2
-rw-r--r--app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt98
-rw-r--r--app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt196
-rw-r--r--app/src/test/java/org/terst/listen/ui/LibraryReadsTest.kt191
-rw-r--r--app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt494
-rw-r--r--app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt190
-rw-r--r--app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt365
-rw-r--r--app/src/test/java/org/terst/listen/ui/RulesViewModelTest.kt139
-rw-r--r--app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt321
-rw-r--r--app/src/test/java/org/terst/listen/ui/ViewModelConstructionTest.kt93
27 files changed, 2286 insertions, 139 deletions
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 58de63e..65df0f3 100644
--- a/app/src/main/java/org/terst/listen/data/Preferences.kt
+++ b/app/src/main/java/org/terst/listen/data/Preferences.kt
@@ -12,13 +12,33 @@ import org.terst.listen.server.SkipServerConfig
private val Context.store by preferencesDataStore(name = "listen-preferences")
/**
+ * The preferences a view model reads, behind an interface so the view models are testable.
+ *
+ * [Preferences] itself cannot be constructed off a device -- DataStore wants a real Context, a
+ * files directory and a file -- which is the only reason the view models could not be unit
+ * tested at all. This is the same narrowing as [org.terst.listen.playback.Playhead]: the two
+ * settings the library screens read, and nothing else. The settings screens hold the whole
+ * [Preferences] (they write the server URL and the break-speed choice as well), and they get it
+ * from [AppGraph], whose own declaration keeps the full type.
+ */
+interface ViewPreferences {
+ /** Whether episodes already heard stay in a show's list. */
+ val showPlayed: Flow<Boolean>
+
+ suspend fun setShowPlayed(value: Boolean)
+
+ /** Whether a show's own intro, break cue and credits are skipped as well as the advertising. */
+ val skipShowFurniture: Flow<Boolean>
+}
+
+/**
* The handful of settings that are a view preference rather than data.
*
* Not in Room: these describe how the library is being looked at, not what is in it, and a
* schema migration for a boolean nobody else reads would be the wrong shape. DataStore has been
* a declared dependency since phase 1 with nothing using it; this is what it is for.
*/
-class Preferences(private val context: Context) {
+class Preferences(private val context: Context) : ViewPreferences {
/**
* Whether episodes already heard stay in a show's list.
@@ -27,9 +47,9 @@ class Preferences(private val context: Context) {
* buries the ones that are not. The toggle exists so a mis-tapped "played" can be found
* again, and it is global rather than per-show because it is about how you read a list.
*/
- val showPlayed: Flow<Boolean> = context.store.data.map { it[SHOW_PLAYED] ?: false }
+ override val showPlayed: Flow<Boolean> = context.store.data.map { it[SHOW_PLAYED] ?: false }
- suspend fun setShowPlayed(value: Boolean) {
+ override suspend fun setShowPlayed(value: Boolean) {
context.store.edit { it[SHOW_PLAYED] = value }
}
@@ -40,7 +60,7 @@ class Preferences(private val context: Context) {
* Advertising is not covered by this and is always skipped -- it is the thing the detection
* exists for, and there is no case for keeping it.
*/
- val skipShowFurniture: Flow<Boolean> = context.store.data.map { it[SKIP_SHOW_FURNITURE] ?: true }
+ override val skipShowFurniture: Flow<Boolean> = context.store.data.map { it[SKIP_SHOW_FURNITURE] ?: true }
suspend fun setSkipShowFurniture(value: Boolean) {
context.store.edit { it[SKIP_SHOW_FURNITURE] = value }
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 2655546..a2756b8 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -48,7 +48,46 @@ object Graph {
}
}
-class AppGraph(private val context: Context) {
+/**
+ * The part of the graph the view models read, behind an interface so they can be tested.
+ *
+ * [AppGraph] cannot be built off a device: its database is Room's, its preferences are
+ * DataStore's and its extractor is Chaquopy's. That is a property of the wiring, not of the
+ * view models, which are ordinary classes deciding ordinary things -- which banner a refresh
+ * produces, whether a busy flag gets cleared, where a new rule goes in the order. They were
+ * untestable only because the one thing they held was this class, and until 2026-11-03 the
+ * coverage gate excluded the whole `ui` package, so nothing said so.
+ *
+ * Narrow in the sense that matters: everything named here is either a DAO interface Room
+ * generates -- which the test source set's fake DAOs already stand in for -- or a plain class
+ * that constructs happily on the JVM, so a view model test exercises the real
+ * SubscriptionRepository, the real PlaybackAdvancer and the real QueueRepository rather than
+ * stubs agreeing with themselves. The one member that could not be real was [Preferences],
+ * which is why [ViewPreferences] exists; `AppGraph` keeps the wider type on its own
+ * declaration, so the settings composables still see the whole of it.
+ */
+interface UiGraph {
+ val subscriptions: SubscriptionDao
+ val episodes: EpisodeDao
+ val playbackState: PlaybackStateDao
+ val queue: QueueRepository
+ val ruleDao: RuleDao
+ val fingerprints: ShowFingerprintDao
+ val midrolls: MidrollDao
+ val transcripts: TranscriptDao
+ val breakVerdicts: BreakVerdictDao
+ val preferences: ViewPreferences
+ val resolver: FeedResolver
+ val repository: SubscriptionRepository
+ val unsubscriber: Unsubscriber
+ val downloader: EpisodeDownloader
+ val verdictRecorder: VerdictRecorder
+ val advancer: PlaybackAdvancer
+ val chapters: ChapterFetcher
+ val drip: DripReleaser
+}
+
+class AppGraph(private val context: Context) : UiGraph {
private val db by lazy { AppDatabase.get(context) }
@@ -63,15 +102,15 @@ class AppGraph(private val context: Context) {
.build()
}
- val subscriptions: SubscriptionDao get() = db.subscriptions()
- val episodes: EpisodeDao get() = db.episodes()
- val playbackState: PlaybackStateDao get() = db.playbackState()
+ override val subscriptions: SubscriptionDao get() = db.subscriptions()
+ override val episodes: EpisodeDao get() = db.episodes()
+ override val playbackState: PlaybackStateDao get() = db.playbackState()
val queueDao: QueueDao get() = db.queue()
- val ruleDao: RuleDao get() = db.rules()
- val fingerprints: ShowFingerprintDao get() = db.fingerprints()
- val midrolls: MidrollDao get() = db.midrolls()
- val transcripts: TranscriptDao get() = db.transcripts()
- val breakVerdicts: BreakVerdictDao get() = db.breakVerdicts()
+ override val ruleDao: RuleDao get() = db.rules()
+ override val fingerprints: ShowFingerprintDao get() = db.fingerprints()
+ override val midrolls: MidrollDao get() = db.midrolls()
+ override val transcripts: TranscriptDao get() = db.transcripts()
+ override val breakVerdicts: BreakVerdictDao get() = db.breakVerdicts()
val adReports: AdReportDao get() = db.adReports()
val libraryDir: File by lazy {
@@ -86,21 +125,21 @@ class AppGraph(private val context: Context) {
FileMidrollClipStore(File(context.filesDir, "midrolls").also { it.mkdirs() })
}
- val unsubscriber: Unsubscriber by lazy { Unsubscriber(subscriptions, episodes, midrollClips) }
+ override val unsubscriber: Unsubscriber by lazy { Unsubscriber(subscriptions, episodes, midrollClips) }
- val resolver: FeedResolver by lazy { FeedResolver(client) }
+ override val resolver: FeedResolver by lazy { FeedResolver(client) }
- val preferences: Preferences by lazy { Preferences(context) }
+ override val preferences: Preferences by lazy { Preferences(context) }
val ruleApplier: RuleApplier by lazy { RuleApplier(ruleDao, episodes, queue) }
- val drip: DripReleaser by lazy { DripReleaser(subscriptions, episodes, queue) }
+ override val drip: DripReleaser by lazy { DripReleaser(subscriptions, episodes, queue) }
- val chapters: ChapterFetcher by lazy { ChapterFetcher(client, episodes) }
+ override val chapters: ChapterFetcher by lazy { ChapterFetcher(client, episodes) }
// Shared with PlaybackService, so a user-started episode and one the queue moved on to are
// worked out the same way -- same resume position, same intro offset, same speed.
- val advancer: PlaybackAdvancer by lazy {
+ override val advancer: PlaybackAdvancer by lazy {
PlaybackAdvancer(episodes, subscriptions, queue, sources = object : PlaybackSources {
override suspend fun chapters(episode: EpisodeEntity) = chapters.chapters(episode)
override suspend fun midrolls(episode: EpisodeEntity) = midrolls.getRanges(episode.id)
@@ -152,11 +191,11 @@ class AppGraph(private val context: Context) {
})
}
- val verdictRecorder: VerdictRecorder by lazy { VerdictRecorder(breakVerdicts) }
+ override val verdictRecorder: VerdictRecorder by lazy { VerdictRecorder(breakVerdicts) }
val adReportStore: AdReportStore by lazy { AdReportStore(adReports) }
- val repository: SubscriptionRepository by lazy {
+ override val repository: SubscriptionRepository by lazy {
SubscriptionRepository(
subscriptions,
episodes,
@@ -226,7 +265,7 @@ class AppGraph(private val context: Context) {
val skipRefresher: SkipRefresher by lazy { SkipRefresher(episodes, serverSkips, labelPusher) }
- val downloader: EpisodeDownloader by lazy {
+ override val downloader: EpisodeDownloader by lazy {
EpisodeDownloader(
client,
episodes,
@@ -247,7 +286,7 @@ class AppGraph(private val context: Context) {
private val queuedDownloads: QueuedDownloads by lazy { QueuedDownloads(episodes) }
- val queue: QueueRepository by lazy {
+ override val queue: QueueRepository by lazy {
QueueRepository(queueDao) { joined ->
// Anything queued is meant to be listened to, so it is fetched for offline
// straight away rather than at the next periodic sweep.
diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
index 14e7f4f..d994092 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -25,6 +25,49 @@ data class NowPlaying(
)
/**
+ * What the player view model does to playback, behind an interface.
+ *
+ * [PlayerConnection] is a Media3 MediaController and cannot exist off a device -- building one
+ * needs a `SessionToken`, which reads the package manager -- so [org.terst.listen.ui.PlayerViewModel]
+ * could not be unit tested at all while it constructed one itself. Same narrowing as
+ * [Playhead]: the twelve things the view model asks of playback, no more, so the sleep timer's
+ * fade, the ad report and the speed change can each be asserted on.
+ *
+ * It is not a second place where playback decisions live. Everything here is a verb the view
+ * model already called; completion, the queue advance and the outro offset stay in
+ * [PlaybackService], per that decision.
+ */
+interface PlaybackControls {
+
+ val nowPlaying: StateFlow<NowPlaying>
+
+ /** Reported so playback failures are never silent; see [PlayerConnection.onError]. */
+ var onError: ((String) -> Unit)?
+
+ fun connect()
+
+ fun release()
+
+ fun publish()
+
+ fun play(item: MediaItem, startPositionMs: Long, speed: Float)
+
+ fun togglePlayPause()
+
+ fun seekBy(deltaMs: Long)
+
+ fun seekTo(positionMs: Long)
+
+ fun pause()
+
+ fun setVolume(volume: Float)
+
+ fun setSpeed(speed: Float)
+
+ fun reportAd()
+}
+
+/**
* Binds the UI to the playback service.
*
* Media3's MediaController is the supported way for an app process to drive a
@@ -32,7 +75,7 @@ data class NowPlaying(
* may be restarted independently of the activity, and a direct reference would go stale without
* any obvious symptom.
*/
-class PlayerConnection(private val context: Context) {
+class PlayerConnection(private val context: Context) : PlaybackControls {
private var controller: MediaController? = null
@@ -43,14 +86,14 @@ class PlayerConnection(private val context: Context) {
* UnrecognizedInputFormatException, with nothing in the app listening -- the screen simply
* sat there. Whatever the next failure of this class is, it should say so.
*/
- var onError: ((String) -> Unit)? = null
+ override var onError: ((String) -> Unit)? = null
private val _nowPlaying = MutableStateFlow(
NowPlaying(null, null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f)
)
- val nowPlaying: StateFlow<NowPlaying> = _nowPlaying.asStateFlow()
+ override val nowPlaying: StateFlow<NowPlaying> = _nowPlaying.asStateFlow()
- fun connect() {
+ override fun connect() {
if (controller != null) return
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val future = MediaController.Builder(context, token).buildAsync()
@@ -64,7 +107,7 @@ class PlayerConnection(private val context: Context) {
)
}
- fun release() {
+ override fun release() {
controller?.removeListener(listener)
controller?.release()
controller = null
@@ -92,7 +135,7 @@ class PlayerConnection(private val context: Context) {
* device: an episode set to start sixty seconds in began at zero, and so, all along, did
* every resume. Nothing failed and nothing logged; playback simply started at the top.
*/
- fun play(item: MediaItem, startPositionMs: Long, speed: Float) {
+ override fun play(item: MediaItem, startPositionMs: Long, speed: Float) {
val player = controller ?: return
player.setMediaItem(item, startPositionMs)
player.prepare()
@@ -101,36 +144,36 @@ class PlayerConnection(private val context: Context) {
publish()
}
- fun togglePlayPause() {
+ override fun togglePlayPause() {
val player = controller ?: return
if (player.isPlaying) player.pause() else player.play()
publish()
}
- fun seekBy(deltaMs: Long) {
+ override fun seekBy(deltaMs: Long) {
val player = controller ?: return
player.seekTo((player.currentPosition + deltaMs).coerceAtLeast(0))
publish()
}
/** Used by the player screen's scrubber, committed once the drag ends. */
- fun seekTo(positionMs: Long) {
+ override fun seekTo(positionMs: Long) {
val player = controller ?: return
player.seekTo(positionMs.coerceAtLeast(0))
publish()
}
- fun pause() {
+ override fun pause() {
controller?.pause()
publish()
}
/** Used by the sleep timer's fade. */
- fun setVolume(volume: Float) {
+ override fun setVolume(volume: Float) {
controller?.volume = volume.coerceIn(0f, 1f)
}
- fun setSpeed(speed: Float) {
+ override fun setSpeed(speed: Float) {
controller?.applySpeed(speed, speed)
publish()
}
@@ -143,12 +186,12 @@ class PlayerConnection(private val context: Context) {
* come back past it -- and two copies of that would drift. The service owns it; see
* [AdReporter].
*/
- fun reportAd() {
+ override fun reportAd() {
controller?.sendCustomCommand(NotificationControls.reportAdCommand, android.os.Bundle.EMPTY)
}
/** Called on a UI tick, since position changes without any callback firing. */
- fun publish() {
+ override fun publish() {
val player = controller
_nowPlaying.value = NowPlaying(
episodeId = player?.currentMediaItem?.mediaId,
diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt
index 8f2141c..cac7c05 100644
--- a/app/src/main/java/org/terst/listen/server/SkipServer.kt
+++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt
@@ -1,8 +1,6 @@
package org.terst.listen.server
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
diff --git a/app/src/main/java/org/terst/listen/ui/BreakJudging.kt b/app/src/main/java/org/terst/listen/ui/BreakJudging.kt
index bc9c802..565febc 100644
--- a/app/src/main/java/org/terst/listen/ui/BreakJudging.kt
+++ b/app/src/main/java/org/terst/listen/ui/BreakJudging.kt
@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.terst.listen.data.BreakVerdictEntity
import org.terst.listen.data.EpisodeMidrollEntity
-import org.terst.listen.data.AppGraph
+import org.terst.listen.data.UiGraph
import org.terst.listen.fingerprint.BreakPopup
import org.terst.listen.fingerprint.SkipSummary
import org.terst.listen.playback.BreakVerdicts
@@ -43,7 +43,7 @@ class BreakJudging(
* What the break popup reads and writes, apart from [ListenViewModel] so that class does not grow
* a function per feature.
*/
-class BreakJudgingSource(private val graph: AppGraph) {
+class BreakJudgingSource(private val graph: UiGraph) {
/** What the listener has said about this episode's detected breaks. */
suspend fun verdicts(episodeId: String) = graph.breakVerdicts.getForEpisode(episodeId)
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
index c3d1d72..3672a4a 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
@@ -1,49 +1,6 @@
package org.terst.listen.ui
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Snackbar
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import org.terst.listen.data.DownloadState
-import org.terst.listen.data.EpisodeEntity
-import org.terst.listen.data.SubscriptionEntity
-import java.util.concurrent.TimeUnit
-
-/** The row's five actions, bundled so the signature stays readable. */
+/** The row's actions, bundled so the signature stays readable. */
data class EpisodeActions(
val play: () -> Unit,
val enqueue: () -> Unit,
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
index bc0e6fc..912d697 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -9,25 +9,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.LightMode
-import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
-import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -51,12 +44,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
-import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
-import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionListing
import org.terst.listen.playback.NowPlaying
-import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index a9f95da..0fe8132 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -22,16 +22,17 @@ import org.terst.listen.data.RuleEntity
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionKind
import org.terst.listen.data.SubscriptionListing
+import org.terst.listen.data.UiGraph
import org.terst.listen.feed.FeedResolver
-import org.terst.listen.feed.SubscriptionResolver
import org.terst.listen.feed.YouTubeUrls
/** Transient message for the UI. Errors are surfaced, never only logged. */
data class Banner(val text: String, val isError: Boolean = false)
-class ListenViewModel(app: Application) : AndroidViewModel(app) {
+class ListenViewModel(app: Application, private val graph: UiGraph) : AndroidViewModel(app) {
- private val graph = Graph.get(app)
+ /** The real one. Kept as a constructor of its own so `by viewModels()` still finds it. */
+ constructor(app: Application) : this(app, Graph.get(app))
/**
* Whether the library is showing bedtime shows instead of the ordinary ones.
diff --git a/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt b/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
index 54e95ed..8443561 100644
--- a/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
@@ -9,8 +9,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.terst.listen.data.Graph
+import org.terst.listen.data.UiGraph
import org.terst.listen.opml.Opml
import org.terst.listen.opml.OpmlFeed
+import java.io.InputStream
+import java.io.OutputStream
/**
* Importing and exporting subscriptions as OPML.
@@ -21,9 +24,10 @@ import org.terst.listen.opml.OpmlFeed
* used by one screen, and separating it lets the settings screen stop depending on the library
* view model altogether.
*/
-class OpmlViewModel(app: Application) : AndroidViewModel(app) {
+class OpmlViewModel(app: Application, private val graph: UiGraph) : AndroidViewModel(app) {
- private val graph = Graph.get(app)
+ /** The real one. Kept as a constructor of its own so `by viewModels()` still finds it. */
+ constructor(app: Application) : this(app, Graph.get(app))
private val _banner = MutableStateFlow<Banner?>(null)
val banner: StateFlow<Banner?> = _banner.asStateFlow()
@@ -31,15 +35,24 @@ class OpmlViewModel(app: Application) : AndroidViewModel(app) {
/**
* Imports subscriptions from an OPML file.
*
- * Reports how many were added and how many failed. Silently importing 40 of 60 feeds is the
- * kind of partial success that only surfaces weeks later when a show never appears.
+ * The content resolver is reached through a lambda rather than used here, and [importFrom]
+ * holds everything that decides anything. A `Uri` cannot be made off a device -- its
+ * constructor is private and `Uri.parse` is one of the framework stubs -- so with the
+ * resolver call inline, nothing in this class could be tested at all. That is the whole of
+ * the seam: these two lines are the part no unit test reaches.
*/
- fun importOpml(uri: Uri) {
+ fun importOpml(uri: Uri) =
+ importFrom { getApplication<Application>().contentResolver.openInputStream(uri) }
+
+ /**
+ * Reports how many subscriptions were added and how many failed. Silently importing 40 of 60
+ * feeds is the kind of partial success that only surfaces weeks later when a show never
+ * appears.
+ */
+ internal fun importFrom(open: () -> InputStream?) {
viewModelScope.launch {
val feeds = runCatching {
- getApplication<Application>().contentResolver.openInputStream(uri)
- ?.use { Opml.parse(it) }
- .orEmpty()
+ open()?.use { Opml.parse(it) }.orEmpty()
}.getOrElse {
_banner.value = Banner("Could not read that file: ${it.message}", isError = true)
return@launch
@@ -66,14 +79,17 @@ class OpmlViewModel(app: Application) : AndroidViewModel(app) {
}
}
- fun exportOpml(uri: Uri) {
+ /** The write half of the same seam; see [importOpml]. */
+ fun exportOpml(uri: Uri) =
+ exportTo { getApplication<Application>().contentResolver.openOutputStream(uri) }
+
+ internal fun exportTo(open: () -> OutputStream?) {
viewModelScope.launch {
val feeds = graph.subscriptions.getAll().map {
OpmlFeed(title = it.title, xmlUrl = it.feedUrl, htmlUrl = it.link)
}
runCatching {
- getApplication<Application>().contentResolver.openOutputStream(uri)
- ?.use { Opml.write(feeds, it) }
+ open()?.use { Opml.write(feeds, it) }
}.fold(
onSuccess = { _banner.value = Banner("Exported ${feeds.size} subscription(s)") },
onFailure = { _banner.value = Banner("Export failed: ${it.message}", isError = true) },
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
index 76679dc..64401c7 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
@@ -1,6 +1,5 @@
package org.terst.listen.ui
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
index b785763..4552211 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -13,6 +13,8 @@ import org.terst.listen.data.Graph
import org.terst.listen.feed.Chapter
import org.terst.listen.playback.NowPlaying
import org.terst.listen.playback.PlaybackSpeed
+import org.terst.listen.data.UiGraph
+import org.terst.listen.playback.PlaybackControls
import org.terst.listen.playback.PlayerConnection
import org.terst.listen.playback.StartOutcome
import org.terst.listen.playback.BedtimeAction
@@ -27,9 +29,15 @@ import org.terst.listen.playback.toMediaItem
* currently playing, regardless of which screen is open -- and because one view model covering
* both grew past the point where its function list said anything useful.
*/
-class PlayerViewModel(app: Application) : AndroidViewModel(app) {
+class PlayerViewModel(
+ app: Application,
+ private val graph: UiGraph,
+ private val connection: PlaybackControls,
+) : AndroidViewModel(app) {
+
+ /** The real one. Kept as a constructor of its own so `by viewModels()` still finds it. */
+ constructor(app: Application) : this(app, Graph.get(app), PlayerConnection(app))
- private val graph = Graph.get(app)
private val _banner = MutableStateFlow<Banner?>(null)
val banner: StateFlow<Banner?> = _banner.asStateFlow()
@@ -37,10 +45,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
_banner.value = null
}
- private val connection = PlayerConnection(app).also { connection ->
- connection.connect()
- connection.onError = { code -> _banner.value = Banner("Playback failed: $code", isError = true) }
- }
val nowPlaying: StateFlow<NowPlaying> = connection.nowPlaying
/** The episode whose audio is being fetched before it can play, or null. */
@@ -48,6 +52,9 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
val preparing: StateFlow<String?> = _preparing.asStateFlow()
init {
+ connection.connect()
+ connection.onError = { code -> _banner.value = Banner("Playback failed: $code", isError = true) }
+
// Position advances with no callback of its own, so the bar is driven by a tick.
viewModelScope.launch {
while (true) {
diff --git a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
index 98ae063..c9703e1 100644
--- a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
@@ -11,12 +11,14 @@ import org.terst.listen.data.Graph
import org.terst.listen.data.RuleAction
import org.terst.listen.data.RuleEntity
import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.UiGraph
import java.util.UUID
/** The auto-queue rule set: listing, ordering, and editing one rule at a time. */
-class RulesViewModel(app: Application) : AndroidViewModel(app) {
+class RulesViewModel(app: Application, private val graph: UiGraph) : AndroidViewModel(app) {
- private val graph = Graph.get(app)
+ /** The real one. Kept as a constructor of its own so `by viewModels()` still finds it. */
+ constructor(app: Application) : this(app, Graph.get(app))
val rules: StateFlow<List<RuleEntity>> =
graph.ruleDao.observeAll()
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
index 8f4aeca..eecd5be 100644
--- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -4,7 +4,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
diff --git a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
index 62ff95e..adab3e1 100644
--- a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
@@ -9,7 +9,6 @@ import kotlinx.coroutines.launch
import org.terst.listen.data.Graph
import org.terst.listen.data.SubscriptionKind
import org.terst.listen.feed.FeedResolver
-import org.terst.listen.feed.SubscriptionResolver
import org.terst.listen.feed.YouTubeUrls
/**
diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
index 56d172f..064cb61 100644
--- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
@@ -11,6 +11,7 @@ import org.terst.listen.data.ArchiveOutcome
import org.terst.listen.data.FingerprintKind
import org.terst.listen.data.Graph
import org.terst.listen.data.ShowTier
+import org.terst.listen.data.UiGraph
import org.terst.listen.fingerprint.IntroOutroDetector
import org.terst.listen.fingerprint.MidrollDetector
import org.terst.listen.fingerprint.SkipSummary
@@ -40,9 +41,10 @@ private fun ArchiveOutcome.Fetched.toBanner(): Banner {
* archive and refreshing a feed are different operations of very different lengths, and one
* spinner covering both made the top bar claim a refresh was running when it was not.
*/
-class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) {
+class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : AndroidViewModel(app) {
- private val graph = Graph.get(app)
+ /** The real one. Kept as a constructor of its own so `by viewModels()` still finds it. */
+ constructor(app: Application) : this(app, Graph.get(app))
private val _banner = MutableStateFlow<Banner?>(null)
val banner: StateFlow<Banner?> = _banner.asStateFlow()
diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
index 3d80fea..5313e71 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -1,8 +1,6 @@
package org.terst.listen.ui
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -14,12 +12,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -27,37 +21,30 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
-import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.terst.listen.data.Artwork
-import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.SubscriptionEntity
-import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt b/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt
index b1479ef..85a3d49 100644
--- a/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt
@@ -3,6 +3,7 @@ package org.terst.listen.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import org.terst.listen.data.Graph
+import org.terst.listen.data.UiGraph
/**
* An episode's words.
@@ -12,9 +13,11 @@ import org.terst.listen.data.Graph
* library list is on screen the whole time the app is, and a thousand cues are wanted only while
* somebody is reading them.
*/
-class TranscriptViewModel(application: Application) : AndroidViewModel(application) {
+class TranscriptViewModel(application: Application, private val graph: UiGraph) :
+ AndroidViewModel(application) {
- private val graph = Graph.get(application)
+ /** The real one. Kept as a constructor of its own so `viewModel()` still finds it. */
+ constructor(application: Application) : this(application, Graph.get(application))
/**
* The two reads an episode's words need.
diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
index dc9c41b..f1e6b8c 100644
--- a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
+++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
@@ -1,6 +1,5 @@
package org.terst.listen.fingerprint
-import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.terst.listen.download.WaitingForServerText
@@ -10,7 +9,6 @@ import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.FingerprintKind
import org.terst.listen.data.ShowFingerprintEntity
import org.terst.listen.data.ShowMidrollEntity
-import org.terst.listen.data.SubscriptionEntity
/**
* The words the app uses to say what the skip detection has worked out.
diff --git a/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt b/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt
new file mode 100644
index 0000000..1a29593
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt
@@ -0,0 +1,98 @@
+package org.terst.listen.ui
+
+import androidx.media3.common.MediaItem
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlaybackControls
+
+/**
+ * Playback, recorded rather than performed.
+ *
+ * Stands in for [org.terst.listen.playback.PlayerConnection], which is a Media3 MediaController
+ * and cannot be built off a device; see [PlaybackControls].
+ */
+class FakePlaybackControls : PlaybackControls {
+
+ data class Started(val mediaId: String?, val title: String?, val startPositionMs: Long, val speed: Float)
+
+ private val _nowPlaying = MutableStateFlow(
+ NowPlaying(null, null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f)
+ )
+ override val nowPlaying: StateFlow<NowPlaying> = _nowPlaying
+
+ override var onError: ((String) -> Unit)? = null
+
+ var connects = 0
+ private set
+ var releases = 0
+ private set
+ var publishes = 0
+ private set
+ var toggles = 0
+ private set
+ var pauses = 0
+ private set
+ var adReports = 0
+ private set
+ val started = mutableListOf<Started>()
+ val seeksBy = mutableListOf<Long>()
+ val seeksTo = mutableListOf<Long>()
+ val volumes = mutableListOf<Float>()
+ val speeds = mutableListOf<Float>()
+
+ /** What the UI would be looking at; the view model reads this to decide what to apply now. */
+ fun playing(episodeId: String?) {
+ _nowPlaying.value = _nowPlaying.value.copy(episodeId = episodeId, isPlaying = episodeId != null)
+ }
+
+ override fun connect() {
+ connects++
+ }
+
+ override fun release() {
+ releases++
+ }
+
+ override fun publish() {
+ publishes++
+ }
+
+ override fun play(item: MediaItem, startPositionMs: Long, speed: Float) {
+ started += Started(
+ mediaId = item.mediaId,
+ title = item.mediaMetadata.title?.toString(),
+ startPositionMs = startPositionMs,
+ speed = speed,
+ )
+ playing(item.mediaId)
+ }
+
+ override fun togglePlayPause() {
+ toggles++
+ }
+
+ override fun seekBy(deltaMs: Long) {
+ seeksBy += deltaMs
+ }
+
+ override fun seekTo(positionMs: Long) {
+ seeksTo += positionMs
+ }
+
+ override fun pause() {
+ pauses++
+ }
+
+ override fun setVolume(volume: Float) {
+ volumes += volume
+ }
+
+ override fun setSpeed(speed: Float) {
+ speeds += speed
+ }
+
+ override fun reportAd() {
+ adReports++
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt b/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt
new file mode 100644
index 0000000..5b43bdb
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt
@@ -0,0 +1,196 @@
+package org.terst.listen.ui
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import okhttp3.OkHttpClient
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeBreakVerdictDao
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeMidrollDao
+import org.terst.listen.data.FakePlaybackStateDao
+import org.terst.listen.data.FakeRuleDao
+import org.terst.listen.data.FakeShowFingerprintDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.FakeTranscriptDao
+import org.terst.listen.data.QueueRepository
+import org.terst.listen.data.SubscriptionRepository
+import org.terst.listen.data.UiGraph
+import org.terst.listen.data.Unsubscriber
+import org.terst.listen.data.ViewPreferences
+import org.terst.listen.download.EpisodeDownloader
+import org.terst.listen.download.ServerAudio
+import org.terst.listen.server.ServerAnswer
+import org.terst.listen.server.ServerEpisode
+import org.terst.listen.feed.ChapterFetcher
+import org.terst.listen.feed.FeedFetcher
+import org.terst.listen.feed.FeedResolver
+import org.terst.listen.fingerprint.FileMidrollClipStore
+import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.playback.PlaybackAdvancer
+import org.terst.listen.playback.PlaybackSources
+import org.terst.listen.playback.VerdictRecorder
+import org.terst.listen.queue.DripReleaser
+import java.io.File
+
+/** The two library preferences, in memory. DataStore cannot run on the JVM; see [ViewPreferences]. */
+class FakeViewPreferences : ViewPreferences {
+ val showPlayedValue = MutableStateFlow(false)
+ val skipFurnitureValue = MutableStateFlow(true)
+
+ override val showPlayed: Flow<Boolean> = showPlayedValue
+ override val skipShowFurniture: Flow<Boolean> = skipFurnitureValue
+
+ override suspend fun setShowPlayed(value: Boolean) {
+ showPlayedValue.value = value
+ }
+}
+
+/**
+ * The graph the view models see, built from the fake DAOs.
+ *
+ * Everything but the DAOs and the preferences is the production class: a view model test that
+ * replaced [SubscriptionRepository] or [PlaybackAdvancer] with a stub would be asserting against
+ * its own stub's wording rather than the app's. The two that cannot be real are the ones
+ * [UiGraph] exists for -- Room's generated DAOs and DataStore's preferences.
+ *
+ * [dir] is a scratch library directory, deleted by [cleanup]. Nothing in these tests downloads
+ * anything into it, but [Unsubscriber] and [EpisodeDownloader] both want somewhere to point.
+ */
+class FakeUiGraph(private val dir: File = tempDir()) : UiGraph {
+
+ val client = OkHttpClient()
+ var clock = 1_000L
+
+ /** Fired by [QueueRepository] with the ids that have just joined the queue. */
+ val queued = mutableListOf<String>()
+
+ /**
+ * What yt-dlp would do, since it cannot run here.
+ *
+ * The real [PlaybackAdvancer] is used, reached through its own [PlaybackSources] seam; only
+ * the extraction is stood in for. Defaults to the failure [PlaybackSources.None] gives, so
+ * a test has to opt in to a fetch that works.
+ */
+ var fetchAudio: suspend (EpisodeEntity, SubscriptionKind) -> Result<Unit> =
+ { _, _ -> Result.failure(IllegalStateException("no audio source configured")) }
+
+ /**
+ * Whether there is a skip server to ask about an episode, and what it says.
+ *
+ * Defaults to no server at all, which is the configuration the app has to keep working in.
+ * Set it true for the case where a server exists but has no answer yet -- the episode that
+ * must wait rather than take the publisher's differently-ad-inserted copy.
+ */
+ var serverConfigured = false
+
+ override val subscriptions = FakeSubscriptionDao()
+ override val episodes = FakeEpisodeDao()
+ override val playbackState = FakePlaybackStateDao(episodes.rows)
+ val queueDao = org.terst.listen.data.FakeQueueDao()
+ override val queue: QueueRepository
+ override val ruleDao = FakeRuleDao()
+ override val fingerprints = FakeShowFingerprintDao()
+ override val midrolls = FakeMidrollDao()
+ override val transcripts = FakeTranscriptDao()
+ override val breakVerdicts = FakeBreakVerdictDao()
+ override val preferences = FakeViewPreferences()
+ override val resolver: FeedResolver
+ override val repository: SubscriptionRepository
+ override val unsubscriber: Unsubscriber
+ override val downloader: EpisodeDownloader
+ override val verdictRecorder: VerdictRecorder
+ override val advancer: PlaybackAdvancer
+ override val chapters: ChapterFetcher
+ override val drip: DripReleaser
+
+ init {
+ queueDao.episodeSource = { episodes.rows.toList() }
+ episodes.queuedIds = { queueDao.entries.map { it.episodeId }.toSet() }
+ fingerprints.episodeRows = episodes.rows
+ midrolls.episodeRows = episodes.rows
+ midrolls.subscriptionRows = subscriptions.rows
+ queue = QueueRepository(queueDao) { joined -> queued += joined }
+ resolver = FeedResolver(client)
+ repository = SubscriptionRepository(subscriptions, episodes, FeedFetcher(client)) { clock }
+ unsubscriber = Unsubscriber(subscriptions, episodes, FileMidrollClipStore(File(dir, "midrolls")))
+ downloader = EpisodeDownloader(
+ client,
+ episodes,
+ dir,
+ serverAudio = object : ServerAudio {
+ override suspend fun canAsk(episode: EpisodeEntity) = serverConfigured
+ override suspend fun resolve(episode: EpisodeEntity): ServerAnswer =
+ ServerAnswer.Unavailable
+ override suspend fun record(episode: EpisodeEntity, info: ServerEpisode) = Unit
+ },
+ )
+ verdictRecorder = VerdictRecorder(breakVerdicts) { clock }
+ advancer = PlaybackAdvancer(
+ episodes,
+ subscriptions,
+ queue,
+ sources = object : PlaybackSources {
+ override suspend fun chapters(episode: EpisodeEntity) = chapters.chapters(episode)
+ override suspend fun midrolls(episode: EpisodeEntity) = midrolls.getRanges(episode.id)
+ override suspend fun verdicts(episode: EpisodeEntity) =
+ breakVerdicts.getForEpisode(episode.id)
+ override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) =
+ this@FakeUiGraph.fetchAudio(episode, kind)
+ override suspend fun skipFurniture() = preferences.skipFurnitureValue.value
+ override suspend fun fastPlayBreaks() = false
+ },
+ )
+ chapters = ChapterFetcher(client, episodes)
+ drip = DripReleaser(subscriptions, episodes, queue) { clock }
+ }
+
+ fun cleanup() {
+ dir.deleteRecursively()
+ }
+
+ companion object {
+ fun tempDir(): File =
+ File.createTempFile("uigraph", "").let { it.delete(); it.mkdirs(); it }
+ }
+}
+
+/**
+ * An episode row with the fields these tests need and nothing else.
+ *
+ * The title is always "Episode <id>" and the length always ten minutes. Anything else a test
+ * cares about -- a played date, a rule's speed -- it says with `.copy()`.
+ */
+fun episodeRow(
+ id: String,
+ subscriptionId: String = "s1",
+ publishedAtMs: Long? = 1_000,
+ positionMs: Long = 0,
+) = EpisodeEntity(
+ id = id,
+ subscriptionId = subscriptionId,
+ guid = id,
+ title = "Episode $id",
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ publishedAtMs = publishedAtMs,
+ positionMs = positionMs,
+ durationMs = 600_000,
+)
+
+/**
+ * Reads a [StateFlow] a view model shares with `WhileSubscribed`.
+ *
+ * Those only run their upstream while something is collecting it, so reading `.value` with no
+ * subscriber reports the initial value for ever -- which looks exactly like a query that found
+ * nothing.
+ */
+fun <T> TestScope.collected(flow: StateFlow<T>): T {
+ backgroundScope.launch { flow.collect { } }
+ advanceUntilIdle()
+ return flow.value
+}
diff --git a/app/src/test/java/org/terst/listen/ui/LibraryReadsTest.kt b/app/src/test/java/org/terst/listen/ui/LibraryReadsTest.kt
new file mode 100644
index 0000000..cfe3266
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/LibraryReadsTest.kt
@@ -0,0 +1,191 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.EpisodeTranscriptEntity
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.TranscriptCueEntity
+import org.terst.listen.fingerprint.SkipSummary
+import org.terst.listen.playback.BreakVerdicts
+
+/**
+ * What the library screens observe, and the two small readers beside them.
+ *
+ * Separate from [ListenViewModelTest] because these are shared flows rather than actions: they
+ * only run while something is collecting, and reading one needs a subscriber and a scheduler to
+ * pump. See [collected].
+ */
+class LibraryReadsTest {
+
+ private val scheduler = TestCoroutineScheduler()
+ private val dispatcher = UnconfinedTestDispatcher(scheduler)
+ private lateinit var graph: FakeUiGraph
+
+ /** Built on first use; the fake DAOs snapshot their rows at the moment they are queried. */
+ private val vm: ListenViewModel by lazy { ListenViewModel(Application(), graph) }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(dispatcher)
+ graph = FakeUiGraph()
+ }
+
+ @After
+ fun tearDown() {
+ graph.cleanup()
+ Dispatchers.resetMain()
+ }
+
+ private fun show(id: String, bedtime: Boolean = false) {
+ graph.subscriptions.rows += SubscriptionEntity(
+ id = id, feedUrl = "https://example.com/$id", title = "Show $id",
+ addedAtMs = 0, bedtime = bedtime,
+ )
+ }
+
+ /**
+ * A bedtime show is absent from the ordinary library entirely, not merely sorted lower.
+ *
+ * [ListenViewModel.subscriptions] is deliberately the unfiltered list: a show screen opened
+ * from bedtime mode has to survive bedtime mode being switched off, and it would blank if it
+ * read the filtered one.
+ */
+ @Test
+ fun `the library lists one set or the other, and never both`() = runTest(dispatcher) {
+ show("ordinary")
+ show("sleepy", bedtime = true)
+
+ assertEquals(listOf("ordinary"), collected(vm.library).map { it.subscription.id })
+
+ vm.setBedtime(true)
+ assertEquals(listOf("sleepy"), collected(vm.library).map { it.subscription.id })
+
+ assertEquals(
+ "every show, so a screen can look one up whichever mode it was opened from",
+ setOf("ordinary", "sleepy"),
+ collected(vm.subscriptions).map { it.id }.toSet(),
+ )
+ }
+
+ @Test
+ fun `the newest waiting episode is keyed by its show`() = runTest(dispatcher) {
+ show("s1")
+
+ assertEquals(emptyMap<String, Any>(), collected(vm.newestWaiting))
+ }
+
+ @Test
+ fun `the queue is observed in order`() = runTest(dispatcher) {
+ graph.episodes.rows += episodeRow("a")
+ graph.episodes.rows += episodeRow("b")
+ graph.queue.append("a")
+ graph.queue.append("b")
+
+ assertEquals(listOf("a", "b"), collected(vm.queue).map { it.id })
+ }
+
+ /** Only so an episode can name the rule that claimed it; editing is RulesViewModel's. */
+ @Test
+ fun `the rules are observed so an episode can name the one that claimed it`() =
+ runTest(dispatcher) {
+ graph.ruleDao.rows += RuleEntity(
+ id = "r1", name = "Short ones", position = 0, action = RuleAction.QUEUE_TOP,
+ )
+
+ assertEquals(listOf("Short ones"), collected(vm.rules).map { it.name })
+ }
+
+ @Test
+ fun `played episodes are hidden by default`() = runTest(dispatcher) {
+ assertFalse("something already heard buries what is not", collected(vm.showPlayed))
+
+ graph.preferences.showPlayedValue.value = true
+ assertTrue(collected(vm.showPlayed))
+ }
+
+ // --- the break popup's reads and writes ---------------------------------------------
+
+ @Test
+ fun `a verdict on a break is recorded against the range the detector found`() = runBlocking {
+ val range = EpisodeMidrollEntity(
+ episodeId = "e1", candidateId = "c1", startMs = 10_000, endMs = 70_000,
+ )
+ graph.midrolls.rangeRows += range
+
+ assertEquals(emptyList<BreakVerdictEntity>(), vm.breakJudging.verdicts("e1"))
+
+ vm.breakJudging.record(range, BreakVerdicts.VALID)
+
+ val stored = vm.breakJudging.verdicts("e1").single()
+ assertEquals(BreakVerdicts.VALID, stored.verdict)
+ assertEquals(10_000L, stored.startMs)
+ assertEquals(
+ "the verdict carries the detector's own evidence, not a rebuilt range",
+ range.kind,
+ stored.kind,
+ )
+ }
+
+ @Test
+ fun `the popup reads whether the show's own furniture is being skipped`() = runBlocking {
+ assertTrue(graph.preferences.skipShowFurniture.first())
+ assertTrue(vm.breakJudging.skipShowFurniture.first())
+
+ graph.preferences.skipFurnitureValue.value = false
+ assertFalse(vm.breakJudging.skipShowFurniture.first())
+ }
+
+ /** The empty judging a screen uses before anything has been read. */
+ @Test
+ fun `the empty judging says nothing and records nothing`() {
+ val none = BreakJudging.NONE
+
+ assertEquals(emptyList<BreakVerdictEntity>(), none.verdicts)
+ assertTrue("the fail-safe direction: breaks stay skipped", none.skipFurniture)
+ none.judge(SkipSummary.BreakMarker(0.0f, 0.1f, "ad"), BreakVerdicts.VALID)
+ }
+
+ // --- an episode's words -------------------------------------------------------------
+
+ @Test
+ fun `the transcript reads are the note and the cues`() = runBlocking {
+ val transcripts = TranscriptViewModel(Application(), graph)
+ graph.transcripts.rows += EpisodeTranscriptEntity(
+ episodeId = "e1", source = "publisher", cues = 2,
+ note = "2 cues from the publisher", fetchedAtMs = 0,
+ )
+ graph.transcripts.cueRows += TranscriptCueEntity(
+ episodeId = "e1", startMs = 0, endMs = 1_000, text = "Hello",
+ )
+ graph.transcripts.cueRows += TranscriptCueEntity(
+ episodeId = "e1", startMs = 1_000, endMs = 2_000, text = "and welcome",
+ )
+
+ val reads = transcripts.reads("e1")
+
+ assertEquals("2 cues from the publisher", reads.note.first()!!.note)
+ assertEquals(listOf("Hello", "and welcome"), reads.cues.first().map { it.text })
+ assertEquals(
+ "an episode nobody has looked at has no note",
+ null,
+ transcripts.reads("other").note.first(),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt
new file mode 100644
index 0000000..4d60762
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt
@@ -0,0 +1,494 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+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.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.download.WaitingForServerText
+import java.util.concurrent.TimeUnit
+
+/**
+ * The library view model: subscribing, refreshing, the queue and the episode actions.
+ *
+ * All of it ends in a banner or a row, and both have been wrong in ways nothing noticed. The
+ * cases pinned here are the ones with a decision in them -- "Up to date" against "N new
+ * episode(s)", a refresh that is already running, an episode with no date to mark older than,
+ * and a download that is waiting on the server rather than failing.
+ */
+class ListenViewModelTest {
+
+ private lateinit var graph: FakeUiGraph
+ private lateinit var server: MockWebServer
+
+ /** Built on first use; the fake DAOs snapshot their rows when queried. */
+ private val vm: ListenViewModel by lazy { ListenViewModel(Application(), graph) }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(Dispatchers.Unconfined)
+ graph = FakeUiGraph()
+ server = MockWebServer()
+ server.start()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ graph.cleanup()
+ Dispatchers.resetMain()
+ }
+
+ private fun show(
+ id: String = "s1",
+ feedUrl: String = server.url("/$id.xml").toString(),
+ bedtime: Boolean = false,
+ ): SubscriptionEntity {
+ val row = SubscriptionEntity(
+ id = id, feedUrl = feedUrl, title = "Show $id", addedAtMs = 0, bedtime = bedtime,
+ )
+ graph.subscriptions.rows += row
+ return row
+ }
+
+ private fun feed(items: String = oneItem, title: String = "A Show") = """<?xml version="1.0"?>
+ <rss version="2.0"><channel>
+ <title>$title</title><link>https://example.com</link><description>d</description>
+ $items
+ </channel></rss>""".trimIndent()
+
+ private val oneItem = """<item><title>One</title><guid>g1</guid>
+ <enclosure url="https://cdn/1.mp3" length="10" type="audio/mpeg"/></item>"""
+
+ private val twoItems = oneItem + """<item><title>Two</title><guid>g2</guid>
+ <enclosure url="https://cdn/2.mp3" length="10" type="audio/mpeg"/></item>"""
+
+ /** Anything that reaches the network comes back after the call returns. */
+ private fun awaitBanner(): Banner {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
+ while (System.nanoTime() < deadline) {
+ vm.banner.value?.let { return it }
+ Thread.sleep(POLL_MS)
+ }
+ throw AssertionError("no banner after $TIMEOUT_SECONDS seconds")
+ }
+
+ // --- bedtime mode -------------------------------------------------------------------
+
+ @Test
+ fun `bedtime mode starts off and is not a stored setting`() {
+ assertFalse("waking to an empty-looking library is the failure this avoids", vm.bedtime.value)
+
+ vm.setBedtime(true)
+ assertTrue(vm.bedtime.value)
+
+ vm.setBedtime(false)
+ assertFalse(vm.bedtime.value)
+ }
+
+ // --- subscribing --------------------------------------------------------------------
+
+ @Test
+ fun `text with no link in it is refused by name`() {
+ vm.subscribe("have you heard this one")
+
+ val banner = awaitBanner()
+ assertEquals("No link found in that text.", banner.text)
+ assertTrue(banner.isError)
+ }
+
+ @Test
+ fun `subscribing to a feed names the show it found`() {
+ // Twice: resolution fetches the shared address to see what it is, and the repository
+ // then fetches the feed it decided on.
+ server.enqueue(MockResponse().setBody(feed(title = "Darknet Diaries")))
+ server.enqueue(MockResponse().setBody(feed(title = "Darknet Diaries")))
+
+ vm.subscribe(server.url("/feed.xml").toString())
+
+ assertEquals(Banner("Subscribed to Darknet Diaries"), awaitBanner())
+ assertEquals(1, graph.subscriptions.rows.size)
+ }
+
+ @Test
+ fun `an address that cannot be reached says so rather than appearing to work`() {
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ vm.subscribe(server.url("/feed.xml").toString())
+
+ assertTrue(awaitBanner().isError)
+ assertTrue(graph.subscriptions.rows.isEmpty())
+ }
+
+ /** Resolution found a feed and then subscribing to it failed, which is a separate branch. */
+ @Test
+ fun `a feed that resolves and then fails to fetch is reported`() {
+ server.enqueue(MockResponse().setBody(feed()))
+ server.enqueue(MockResponse().setResponseCode(503))
+
+ vm.subscribe(server.url("/feed.xml").toString())
+
+ assertTrue(awaitBanner().isError)
+ assertTrue(graph.subscriptions.rows.isEmpty())
+ }
+
+ // --- refreshing one show ------------------------------------------------------------
+
+ @Test
+ fun `a refresh that found episodes counts them`() {
+ show()
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+
+ vm.refresh("s1")
+
+ assertEquals(Banner("2 new episode(s)"), awaitBanner())
+ assertFalse(vm.refreshing.value)
+ }
+
+ @Test
+ fun `a refresh that found nothing new says up to date`() {
+ show()
+ server.enqueue(MockResponse().setBody(feed()))
+ vm.refresh("s1")
+ awaitBanner()
+ vm.dismissBanner()
+ server.enqueue(MockResponse().setBody(feed()))
+
+ vm.refresh("s1")
+
+ assertEquals(Banner("Up to date"), awaitBanner())
+ }
+
+ @Test
+ fun `a not-modified answer is also up to date`() {
+ show()
+ server.enqueue(MockResponse().setResponseCode(304))
+
+ vm.refresh("s1")
+
+ assertEquals(Banner("Up to date"), awaitBanner())
+ }
+
+ @Test
+ fun `a refresh that failed is an error and records why on the show`() {
+ show()
+ server.enqueue(MockResponse().setResponseCode(503))
+
+ vm.refresh("s1")
+
+ assertTrue(awaitBanner().isError)
+ assertNotNull(
+ "the row keeps the reason, for the library's error line",
+ graph.subscriptions.rows.single().lastError,
+ )
+ }
+
+ @Test
+ fun `refreshing a show that is not there stops refreshing rather than hanging`() {
+ vm.refresh("missing")
+
+ assertNull(vm.banner.value)
+ assertFalse("otherwise the top bar claims a refresh forever", vm.refreshing.value)
+ }
+
+ // --- refreshing everything ----------------------------------------------------------
+
+ @Test
+ fun `refreshing everything adds up what each feed found`() {
+ show("s1")
+ show("s2")
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+ server.enqueue(MockResponse().setBody(feed(oneItem, title = "B")))
+
+ vm.refreshAll()
+
+ assertEquals(Banner("3 new episode(s)"), awaitBanner())
+ }
+
+ @Test
+ fun `refreshing everything with nothing new says up to date`() {
+ show("s1")
+ server.enqueue(MockResponse().setResponseCode(304))
+
+ vm.refreshAll()
+
+ assertEquals(Banner("Up to date"), awaitBanner())
+ }
+
+ /** One failure is worth reporting even when every other feed worked. */
+ @Test
+ fun `one failed feed is reported, with its reason, over any successes`() {
+ show("s1")
+ show("s2")
+ server.enqueue(MockResponse().setResponseCode(500))
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+
+ vm.refreshAll()
+
+ val banner = awaitBanner()
+ assertTrue(banner.isError)
+ assertTrue(banner.text.startsWith("1 feed(s) failed: "))
+ }
+
+ // --- the queue ----------------------------------------------------------------------
+
+ @Test
+ fun `queueing an episode puts it at the bottom and says where it went`() {
+ graph.episodes.rows += episodeRow("a")
+ graph.episodes.rows += episodeRow("b")
+
+ vm.enqueue("a")
+ assertEquals(Banner("Added to Up Next"), vm.banner.value)
+ vm.enqueue("b")
+
+ assertEquals(listOf("a", "b"), graph.queueDao.entries.sortedBy { it.position }.map { it.episodeId })
+ }
+
+ @Test
+ fun `play next goes in behind whatever is playing`() {
+ graph.episodes.rows += episodeRow("a")
+ graph.episodes.rows += episodeRow("b")
+ graph.episodes.rows += episodeRow("c")
+ vm.enqueue("a")
+ vm.enqueue("b")
+
+ vm.playNext("c")
+
+ assertEquals(Banner("Playing next"), vm.banner.value)
+ assertEquals(
+ listOf("a", "c", "b"),
+ graph.queueDao.entries.sortedBy { it.position }.map { it.episodeId },
+ )
+ }
+
+ @Test
+ fun `removing and reordering the queue write through`() {
+ graph.episodes.rows += episodeRow("a")
+ graph.episodes.rows += episodeRow("b")
+ graph.episodes.rows += episodeRow("c")
+ vm.enqueue("a")
+ vm.enqueue("b")
+ vm.enqueue("c")
+
+ vm.removeFromQueue("b")
+ assertEquals(listOf("a", "c"), graph.queueDao.entries.sortedBy { it.position }.map { it.episodeId })
+
+ vm.reorderQueue(listOf("c", "a"))
+ assertEquals(listOf("c", "a"), graph.queueDao.entries.sortedBy { it.position }.map { it.episodeId })
+ }
+
+ @Test
+ fun `anything joining the queue is handed over to be downloaded`() {
+ graph.episodes.rows += episodeRow("a")
+
+ vm.enqueue("a")
+
+ assertEquals(
+ "the queue is the one choke point every route goes through",
+ listOf("a"),
+ graph.queued,
+ )
+ }
+
+ // --- downloading --------------------------------------------------------------------
+
+ @Test
+ fun `a download that failed is an error`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+ server.enqueue(MockResponse().setResponseCode(404))
+
+ vm.download(graph.episodes.rows.single().copy(mediaUrl = server.url("/1.mp3").toString()))
+
+ val banner = awaitBanner()
+ assertTrue(banner.isError)
+ assertTrue(banner.text.startsWith("Download failed: "))
+ }
+
+ /**
+ * An episode waiting on the server's copy is not a failure, and must not read as one: the
+ * next refresh retries it.
+ */
+ @Test
+ fun `an episode waiting on the server says it will be retried, and is not an error`() {
+ show()
+ graph.serverConfigured = true
+ graph.episodes.rows += episodeRow("e1")
+
+ vm.download(graph.episodes.rows.single())
+
+ assertEquals(Banner(WaitingForServerText.BANNER), awaitBanner())
+ assertFalse("waiting is a working state", vm.banner.value!!.isError)
+ }
+
+ @Test
+ fun `taking the publisher's copy reports back when it is done`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+ server.enqueue(MockResponse().setBody("audio bytes"))
+ var done = false
+
+ vm.downloadFromPublisher(
+ graph.episodes.rows.single().copy(mediaUrl = server.url("/1.mp3").toString()),
+ ) { done = true }
+
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
+ while (!done && System.nanoTime() < deadline) Thread.sleep(POLL_MS)
+ assertTrue("the screen has to be told to stop offering it", done)
+ assertNull("a download that worked says nothing", vm.banner.value)
+ }
+
+ @Test
+ fun `a publisher download that failed is an error and still reports back`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+ server.enqueue(MockResponse().setResponseCode(410))
+ var done = false
+
+ vm.downloadFromPublisher(
+ graph.episodes.rows.single().copy(mediaUrl = server.url("/1.mp3").toString()),
+ ) { done = true }
+
+ assertTrue(awaitBanner().isError)
+ assertTrue(done)
+ }
+
+ // --- played and unplayed ------------------------------------------------------------
+
+ @Test
+ fun `marking an episode played and unplayed again`() {
+ graph.episodes.rows += episodeRow("e1")
+
+ vm.markPlayed("e1")
+ assertNotNull(graph.episodes.rows.single().playedAtMs)
+
+ vm.markUnplayed("e1")
+ assertNull(graph.episodes.rows.single().playedAtMs)
+ }
+
+ @Test
+ fun `marking up to an episode takes everything older in that show`() {
+ graph.episodes.rows += episodeRow("old", publishedAtMs = 1_000)
+ graph.episodes.rows += episodeRow("mid", publishedAtMs = 2_000)
+ graph.episodes.rows += episodeRow("new", publishedAtMs = 3_000)
+ graph.episodes.rows += episodeRow("other", subscriptionId = "s2", publishedAtMs = 1_000)
+
+ vm.markPlayedUpTo(graph.episodes.rows.first { it.id == "mid" })
+
+ assertEquals(Banner("Marked this and older as played"), vm.banner.value)
+ assertEquals(
+ setOf("old", "mid"),
+ graph.episodes.rows.filter { it.playedAtMs != null }.map { it.id }.toSet(),
+ )
+ }
+
+ /** With no date there is no "older", and saying so beats marking an arbitrary set played. */
+ @Test
+ fun `marking up to an episode with no date refuses and explains`() {
+ graph.episodes.rows += episodeRow("e1", publishedAtMs = null)
+ graph.episodes.rows += episodeRow("e2", publishedAtMs = 1_000)
+
+ vm.markPlayedUpTo(graph.episodes.rows.first { it.id == "e1" })
+
+ val banner = vm.banner.value!!
+ assertTrue(banner.isError)
+ assertEquals("That episode has no date, so there's no 'older' to mark", banner.text)
+ assertTrue(
+ "nothing may be marked played",
+ graph.episodes.rows.none { it.playedAtMs != null },
+ )
+ }
+
+ // --- positions, preferences and the odd reads ---------------------------------------
+
+ @Test
+ fun `a position dragged on a screen is stored, and never below zero`() {
+ graph.episodes.rows += episodeRow("e1")
+
+ vm.setPosition("e1", 120_000)
+ assertEquals(120_000L, graph.episodes.rows.single().positionMs)
+
+ vm.setPosition("e1", -5_000)
+ assertEquals("the crown jewel of the data model does not go negative", 0L, graph.episodes.rows.single().positionMs)
+ }
+
+ @Test
+ fun `whether played episodes are listed is a preference this writes`() {
+ assertFalse(graph.preferences.showPlayedValue.value)
+
+ vm.setShowPlayed(true)
+
+ assertTrue(graph.preferences.showPlayedValue.value)
+ }
+
+ @Test
+ fun `unsubscribing removes the show`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+
+ vm.unsubscribe("s1")
+
+ assertTrue(graph.subscriptions.rows.isEmpty())
+ }
+
+ @Test
+ fun `the odd single reads a screen needs`() = kotlinx.coroutines.runBlocking {
+ graph.episodes.rows += episodeRow("e1")
+ graph.midrolls.rangeRows += EpisodeMidrollEntity(
+ episodeId = "e1",
+ candidateId = "c1",
+ startMs = 10_000,
+ endMs = 70_000,
+ )
+
+ assertEquals("Episode e1", vm.episode("e1")!!.title)
+ assertNull(vm.episode("nope"))
+ assertEquals(listOf(10_000L), vm.midrollRanges("e1").map { it.startMs })
+ assertEquals(
+ "the verdicts the break popup reads come off the same graph",
+ emptyList<Any>(),
+ vm.breakJudging.verdicts("e1"),
+ )
+ }
+
+ @Test
+ fun `a show's episode list honours drip and the played preference`() =
+ kotlinx.coroutines.runBlocking {
+ graph.episodes.rows += episodeRow("heard").copy(playedAtMs = 5)
+ graph.episodes.rows += episodeRow("unheard")
+
+ val hidden = vm.episodesOf("s1", dripEnabled = false, showPlayed = false).first()
+ assertEquals(listOf("unheard"), hidden.map { it.id })
+
+ val shown = vm.episodesOf("s1", dripEnabled = false, showPlayed = true).first()
+ assertEquals(setOf("heard", "unheard"), shown.map { it.id }.toSet())
+ }
+
+ @Test
+ fun `dismissing clears the banner`() {
+ graph.episodes.rows += episodeRow("a")
+ vm.enqueue("a")
+ assertNotNull(vm.banner.value)
+
+ vm.dismissBanner()
+
+ assertNull(vm.banner.value)
+ }
+
+ private companion object {
+ const val TIMEOUT_SECONDS = 10L
+ const val POLL_MS = 5L
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt
new file mode 100644
index 0000000..af44f48
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt
@@ -0,0 +1,190 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.opml.Opml
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * OPML import and export, which is the Pocket Casts migration path and the only bulk way in.
+ *
+ * The counting is the whole point: importing 40 of 60 feeds silently is the kind of partial
+ * success that only surfaces weeks later, when a show somebody thought they had never appears.
+ *
+ * The content resolver is reached through the lambda [OpmlViewModel.importFrom] takes, because a
+ * `Uri` cannot be built off a device -- see that function's own note.
+ */
+class OpmlViewModelTest {
+
+ private lateinit var graph: FakeUiGraph
+ private lateinit var server: MockWebServer
+
+ private val vm: OpmlViewModel by lazy { OpmlViewModel(Application(), graph) }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(Dispatchers.Unconfined)
+ graph = FakeUiGraph()
+ server = MockWebServer()
+ server.start()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ graph.cleanup()
+ Dispatchers.resetMain()
+ }
+
+ private fun feedBody(title: String) = """<?xml version="1.0"?>
+ <rss version="2.0"><channel>
+ <title>$title</title><link>https://example.com</link><description>d</description>
+ <item><title>One</title><guid>g1</guid>
+ <enclosure url="https://cdn/1.mp3" length="10" type="audio/mpeg"/></item>
+ </channel></rss>""".trimIndent()
+
+ private fun opml(vararg urls: String) = buildString {
+ append("""<?xml version="1.0"?><opml version="2.0"><head/><body>""")
+ urls.forEachIndexed { i, url -> append("""<outline text="Show $i" type="rss" xmlUrl="$url"/>""") }
+ append("""</body></opml>""")
+ }
+
+ private fun stream(xml: String): () -> InputStream? = { xml.byteInputStream() }
+
+ /** Both halves go to the network, so the banner arrives after the call returns. */
+ private fun awaitBanner(): Banner {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
+ while (System.nanoTime() < deadline) {
+ vm.banner.value?.let { return it }
+ Thread.sleep(POLL_MS)
+ }
+ throw AssertionError("no banner after $TIMEOUT_SECONDS seconds")
+ }
+
+ // --- import -------------------------------------------------------------------------
+
+ @Test
+ fun `every feed imported is counted`() {
+ server.enqueue(MockResponse().setBody(feedBody("A Show")))
+ server.enqueue(MockResponse().setBody(feedBody("B Show")))
+
+ vm.importFrom(stream(opml(server.url("/a.xml").toString(), server.url("/b.xml").toString())))
+
+ assertEquals(Banner("Imported 2 subscription(s)"), awaitBanner())
+ assertEquals(2, graph.subscriptions.rows.size)
+ }
+
+ /**
+ * The case this wording exists for: some feeds landed and some did not, and the import must
+ * not look like a clean success.
+ */
+ @Test
+ fun `a partial import says how many failed and is an error`() {
+ server.enqueue(MockResponse().setBody(feedBody("A Show")))
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ vm.importFrom(stream(opml(server.url("/a.xml").toString(), server.url("/b.xml").toString())))
+
+ val banner = awaitBanner()
+ assertEquals("Imported 1, 1 failed", banner.text)
+ assertTrue("a partial import is not a success", banner.isError)
+ }
+
+ @Test
+ fun `a file with no subscriptions in it is reported rather than counted as zero`() {
+ vm.importFrom(stream("""<?xml version="1.0"?><opml version="2.0"><body/></opml>"""))
+
+ assertEquals(Banner("No subscriptions found in that file", isError = true), awaitBanner())
+ }
+
+ /** A resolver that hands back nothing is the same to a reader as an empty file. */
+ @Test
+ fun `a file the content resolver could not open reads as empty`() {
+ vm.importFrom { null }
+
+ assertEquals(Banner("No subscriptions found in that file", isError = true), awaitBanner())
+ }
+
+ @Test
+ fun `a file that cannot be read says why`() {
+ vm.importFrom { throw IOException("permission denied") }
+
+ val banner = awaitBanner()
+ assertEquals("Could not read that file: permission denied", banner.text)
+ assertTrue(banner.isError)
+ }
+
+ // --- export -------------------------------------------------------------------------
+
+ @Test
+ fun `export writes every subscription and says how many`() {
+ graph.subscriptions.rows += SubscriptionEntity(
+ id = "s1", feedUrl = "https://a.com/feed", title = "A Show",
+ link = "https://a.com", addedAtMs = 0,
+ )
+ graph.subscriptions.rows += SubscriptionEntity(
+ id = "s2", feedUrl = "https://b.com/feed", title = "B Show", addedAtMs = 0,
+ )
+ val out = ByteArrayOutputStream()
+
+ vm.exportTo { out }
+
+ assertEquals(Banner("Exported 2 subscription(s)"), awaitBanner())
+ val written = Opml.parse(out.toByteArray().inputStream())
+ assertEquals(listOf("A Show", "B Show"), written.map { it.title })
+ assertEquals(
+ "the feed url is the only part of a subscription that travels",
+ listOf("https://a.com/feed", "https://b.com/feed"),
+ written.map { it.xmlUrl },
+ )
+ }
+
+ @Test
+ fun `an export that cannot be written says why`() {
+ vm.exportTo { throw IOException("read-only") }
+
+ val banner = awaitBanner()
+ assertEquals("Export failed: read-only", banner.text)
+ assertTrue(banner.isError)
+ }
+
+ /** No subscriptions is a legitimate export, not a failure. */
+ @Test
+ fun `exporting an empty library is still a success`() {
+ val out: OutputStream = ByteArrayOutputStream()
+
+ vm.exportTo { out }
+
+ assertEquals(Banner("Exported 0 subscription(s)"), awaitBanner())
+ }
+
+ @Test
+ fun `dismissing clears the banner`() {
+ vm.importFrom { null }
+ awaitBanner()
+
+ vm.dismissBanner()
+
+ assertNull(vm.banner.value)
+ }
+
+ private companion object {
+ const val TIMEOUT_SECONDS = 10L
+ const val POLL_MS = 5L
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt
new file mode 100644
index 0000000..844b90f
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt
@@ -0,0 +1,365 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+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.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.playback.BedtimePolicy
+import org.terst.listen.playback.PlaybackSpeed
+
+/**
+ * The controls that act on whatever is playing.
+ *
+ * Playback itself is [FakePlaybackControls]; the decisions are this class's. The ones worth
+ * pinning are the ones that have been wrong before: a tap on Play has to make the episode the
+ * head of the queue (otherwise finishing it discards whatever was queued), the start position
+ * has to come from [org.terst.listen.playback.PlaybackAdvancer] rather than being decided here
+ * twice, and a yt-dlp fetch has to say it is happening rather than leave the screen looking
+ * broken.
+ */
+class PlayerViewModelTest {
+
+ private lateinit var graph: FakeUiGraph
+ private lateinit var controls: FakePlaybackControls
+
+ private val vm: PlayerViewModel by lazy { PlayerViewModel(Application(), graph, controls) }
+
+ @Before
+ fun setUp() {
+ // Unconfined, not virtual time: the sleep timer reads the wall clock, so a virtual
+ // dispatcher would move `delay` and leave `System.currentTimeMillis` where it was --
+ // the timer would then never expire however far time was advanced.
+ Dispatchers.setMain(Dispatchers.Unconfined)
+ graph = FakeUiGraph()
+ controls = FakePlaybackControls()
+ }
+
+ @After
+ fun tearDown() {
+ graph.cleanup()
+ Dispatchers.resetMain()
+ }
+
+ private fun show(
+ id: String = "s1",
+ kind: SubscriptionKind = SubscriptionKind.PODCAST,
+ speed: Float? = null,
+ ) {
+ graph.subscriptions.rows += SubscriptionEntity(
+ id = id,
+ feedUrl = "https://example.com/$id",
+ title = "A Show",
+ addedAtMs = 0,
+ kind = kind,
+ playbackSpeed = speed,
+ )
+ }
+
+ // --- starting an episode ------------------------------------------------------------
+
+ @Test
+ fun `playing an episode makes it the head of the queue first`() {
+ show()
+ graph.episodes.rows += episodeRow("queued")
+ graph.episodes.rows += episodeRow("tapped")
+ vm.play(graph.episodes.rows.first { it.id == "queued" })
+ controls.started.clear()
+
+ vm.play(graph.episodes.rows.first { it.id == "tapped" })
+
+ assertEquals(
+ "the playing episode is always the head, or finishing it deletes a queued one",
+ "tapped",
+ graph.queueDao.entries.minByOrNull { it.position }!!.episodeId,
+ )
+ }
+
+ @Test
+ fun `an episode starts where the advancer says, at the show's speed`() {
+ show(speed = 1.25f)
+ graph.episodes.rows += episodeRow("e1", positionMs = 90_000)
+
+ vm.play(graph.episodes.rows.single())
+
+ val started = controls.started.single()
+ assertEquals("e1", started.mediaId)
+ assertEquals("Episode e1", started.title)
+ assertEquals(90_000L, started.startPositionMs)
+ assertEquals(1.25f, started.speed, 0.0f)
+ }
+
+ @Test
+ fun `an episode that has gone starts nothing`() {
+ vm.play(episodeRow("missing"))
+
+ assertTrue(controls.started.isEmpty())
+ assertNull(vm.banner.value)
+ }
+
+ // --- the yt-dlp fetch ---------------------------------------------------------------
+
+ @Test
+ fun `a youtube episode is fetched before it plays, and says so while it is`() {
+ show(kind = SubscriptionKind.YOUTUBE_CHANNEL)
+ graph.episodes.rows += episodeRow("y1").copy(title = "A video")
+ val seen = mutableListOf<String?>()
+ graph.fetchAudio = { episode, _ ->
+ seen += vm.preparing.value
+ // What the real extractor does: writes a file and records where it went.
+ graph.episodes.updateDownload(
+ episode.id,
+ org.terst.listen.data.DownloadState.DOWNLOADED,
+ "/tmp/${episode.id}.mp3",
+ 1,
+ )
+ Result.success(Unit)
+ }
+
+ vm.play(graph.episodes.rows.single())
+
+ assertEquals("the title has to be on screen while the fetch runs", listOf("A video"), seen)
+ assertNull("and gone once it is over", vm.preparing.value)
+ assertEquals("y1", controls.started.single().mediaId)
+ }
+
+ @Test
+ fun `a fetch that fails says why, and stops claiming to be preparing`() {
+ show(kind = SubscriptionKind.YOUTUBE_CHANNEL)
+ graph.episodes.rows += episodeRow("y1")
+ graph.fetchAudio = { _, _ -> Result.failure(IllegalStateException("video unavailable")) }
+
+ vm.play(graph.episodes.rows.single())
+
+ assertEquals(
+ Banner("Could not fetch audio: video unavailable", isError = true),
+ vm.banner.value,
+ )
+ assertNull(vm.preparing.value)
+ assertTrue(controls.started.isEmpty())
+ }
+
+ // --- a share ------------------------------------------------------------------------
+
+ @Test
+ fun `a shared episode is opened as well as played`() {
+ show()
+ graph.episodes.rows += episodeRow("e1", positionMs = 42_000)
+
+ vm.playShared("e1")
+
+ assertEquals("e1", vm.openEpisode.value)
+ assertEquals(42_000L, controls.started.single().startPositionMs)
+
+ vm.openEpisodeHandled()
+ assertNull("cleared so a rotation does not reopen it", vm.openEpisode.value)
+ }
+
+ @Test
+ fun `a share naming an episode that is not there opens nothing`() {
+ vm.playShared("missing")
+
+ assertNull(vm.openEpisode.value)
+ assertTrue(controls.started.isEmpty())
+ }
+
+ // --- the transport ------------------------------------------------------------------
+
+ @Test
+ fun `the transport buttons go straight through`() {
+ vm.togglePlayPause()
+ vm.skipBack()
+ vm.skipForward()
+ vm.seekTo(1_234)
+ vm.reportAd()
+
+ assertEquals(1, controls.toggles)
+ assertEquals(listOf(-Transport.BACK_MS, Transport.FORWARD_MS), controls.seeksBy)
+ assertEquals(listOf(1_234L), controls.seeksTo)
+ assertEquals(1, controls.adReports)
+ }
+
+ @Test
+ fun `it connects on construction and reports a playback failure as an error`() {
+ assertNotNull("touching it is what builds it; `vm` is lazy", vm.nowPlaying)
+ assertEquals(1, controls.connects)
+
+ controls.onError!!("ERROR_CODE_IO_BAD_HTTP_STATUS")
+
+ assertEquals(
+ Banner("Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS", isError = true),
+ vm.banner.value,
+ )
+ vm.dismissBanner()
+ assertNull(vm.banner.value)
+ }
+
+ // --- speed --------------------------------------------------------------------------
+
+ @Test
+ fun `an episode plays at the rule's speed, else the show's, else normal`() = runBlockingTest {
+ show(speed = 1.25f)
+ graph.episodes.rows += episodeRow("e1")
+ graph.episodes.rows += episodeRow("e2").copy(speedOverride = 2.0f)
+ graph.episodes.rows += episodeRow("e3", subscriptionId = "nosuch")
+
+ assertEquals(1.25f, vm.speedFor(graph.episodes.rows[0]), 0.0f)
+ assertEquals(2.0f, vm.speedFor(graph.episodes.rows[1]), 0.0f)
+ assertEquals(PlaybackSpeed.DEFAULT, vm.speedFor(graph.episodes.rows[2]), 0.0f)
+ }
+
+ /** A speed stored before the offered list was trimmed keeps playing at what it says. */
+ @Test
+ fun `a speed that is no longer offered survives as stored`() = runBlockingTest {
+ show(speed = 1.75f)
+ graph.episodes.rows += episodeRow("e1")
+
+ assertEquals(1.75f, vm.speedFor(graph.episodes.rows.single()), 0.0f)
+ }
+
+ @Test
+ fun `choosing a speed stores it on the show and applies it to the episode playing`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+ controls.playing("e1")
+
+ vm.setSpeedFor(graph.episodes.rows.single(), 2.0f)
+
+ assertEquals(2.0f, graph.subscriptions.rows.single().playbackSpeed)
+ assertEquals(listOf(2.0f), controls.speeds)
+ }
+
+ @Test
+ fun `choosing a speed for a show you are not listening to changes nothing playing`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+ controls.playing("something-else")
+
+ vm.setSpeedFor(graph.episodes.rows.single(), 2.0f)
+
+ assertEquals(2.0f, graph.subscriptions.rows.single().playbackSpeed)
+ assertTrue("the speed of whatever is playing is not the one being set", controls.speeds.isEmpty())
+ }
+
+ // --- chapters -----------------------------------------------------------------------
+
+ @Test
+ fun `an episode's chapters come from its own chapter file`() = runBlockingTest {
+ graph.episodes.rows += episodeRow("e1").copy(
+ chaptersJson = """{"version":"1.2.0","chapters":[
+ {"startTime":0,"title":"Intro"},{"startTime":65,"title":"The thing"}]}""",
+ )
+
+ val chapters = vm.chaptersFor(graph.episodes.rows.single())
+
+ assertEquals(listOf("Intro", "The thing"), chapters.map { it.title })
+ }
+
+ @Test
+ fun `a show that publishes no chapters has none`() = runBlockingTest {
+ graph.episodes.rows += episodeRow("e1")
+
+ assertTrue(vm.chaptersFor(graph.episodes.rows.single()).isEmpty())
+ }
+
+ // --- the sleep timer ----------------------------------------------------------------
+
+ @Test
+ fun `arming the sleep timer starts a countdown at full volume`() {
+ vm.armSleepTimer(45)
+
+ val remaining = vm.sleepRemainingMs.value!!
+ assertTrue("45 minutes, give or take the clock", remaining in 44 * MINUTE..45 * MINUTE)
+ assertEquals("nothing fades until the last stretch", listOf(1.0f), controls.volumes)
+ assertEquals(0, controls.pauses)
+ }
+
+ @Test
+ fun `cancelling the sleep timer takes the countdown away`() {
+ vm.armSleepTimer(45)
+
+ vm.cancelSleepTimer()
+
+ assertNull(vm.sleepRemainingMs.value)
+ }
+
+ /**
+ * A timer already past its deadline when it is watched: it pauses, at silence, and says so.
+ *
+ * Zero minutes is the only way to reach the expiry branch without waiting a real minute --
+ * the timer reads the wall clock on purpose, so that a deadline survives the process being
+ * paused, which also means no test dispatcher can move it.
+ */
+ @Test
+ fun `an expired sleep timer fades to silence, pauses, and says it finished`() {
+ vm.armSleepTimer(0)
+
+ assertEquals(1, controls.pauses)
+ assertEquals("silence before the pause, not after", 0.0f, controls.volumes.last(), 0.0f)
+ assertNull(vm.sleepRemainingMs.value)
+ assertEquals(Banner("Sleep timer finished"), vm.banner.value)
+ }
+
+ // --- bedtime mode -------------------------------------------------------------------
+
+ @Test
+ fun `entering bedtime mode arms the timer`() {
+ vm.onBedtimeChanged(entering = true)
+
+ val remaining = vm.sleepRemainingMs.value!!
+ assertTrue(remaining in (BedtimePolicy.DEFAULT_MINUTES - 1) * MINUTE..BedtimePolicy.DEFAULT_MINUTES * MINUTE)
+ }
+
+ @Test
+ fun `entering bedtime mode leaves a timer set by hand alone`() {
+ vm.armSleepTimer(5)
+ val before = vm.sleepRemainingMs.value!!
+
+ vm.onBedtimeChanged(entering = true)
+
+ val after = vm.sleepRemainingMs.value!!
+ assertTrue("a hand-set 5 minutes must not become 45", after <= before)
+ }
+
+ @Test
+ fun `leaving bedtime mode cancels only the timer bedtime armed`() {
+ vm.onBedtimeChanged(entering = true)
+ assertNotNull(vm.sleepRemainingMs.value)
+
+ vm.onBedtimeChanged(entering = false)
+
+ assertNull(vm.sleepRemainingMs.value)
+ }
+
+ @Test
+ fun `leaving bedtime mode does not kill a timer set by hand`() {
+ vm.armSleepTimer(30)
+
+ vm.onBedtimeChanged(entering = false)
+
+ assertNotNull(
+ "flicking the moon icon off must not silently end a timer you chose",
+ vm.sleepRemainingMs.value,
+ )
+ }
+
+ /**
+ * The suspend reads, run to completion on the calling thread.
+ *
+ * Not `runTest`: the view model's own ticker is an endless loop on the main dispatcher, and
+ * handing that a virtual clock gives a scheduler that never goes idle.
+ */
+ private fun runBlockingTest(body: suspend () -> Unit) = kotlinx.coroutines.runBlocking { body() }
+
+ private companion object {
+ const val MINUTE = 60_000L
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/RulesViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/RulesViewModelTest.kt
new file mode 100644
index 0000000..18d896a
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/RulesViewModelTest.kt
@@ -0,0 +1,139 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * The auto-queue rule set as the rules screen drives it.
+ *
+ * Worth testing rather than trusting: where a new rule lands in the order decides which rule
+ * claims an episode, and "last, never first" is a decision rather than an accident -- a new rule
+ * appearing at the top would silently pre-empt rules already being relied on.
+ */
+class RulesViewModelTest {
+
+ private val scheduler = TestCoroutineScheduler()
+ private val dispatcher = UnconfinedTestDispatcher(scheduler)
+ private lateinit var graph: FakeUiGraph
+
+ /**
+ * Built on first use, not in setUp.
+ *
+ * The fake DAOs hand out `flowOf(rows)`, which snapshots the list as it is when the query is
+ * made -- and the view model makes all of its queries in its constructor. A view model built
+ * before the test seeds its rows observes the empty list for ever.
+ */
+ private val vm: RulesViewModel by lazy { RulesViewModel(Application(), graph) }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(dispatcher)
+ graph = FakeUiGraph()
+ }
+
+ @After
+ fun tearDown() {
+ graph.cleanup()
+ Dispatchers.resetMain()
+ }
+
+ private fun rule(id: String, position: Int, name: String = id, enabled: Boolean = true) =
+ RuleEntity(id = id, name = name, position = position, action = RuleAction.QUEUE_BOTTOM, enabled = enabled)
+
+ @Test
+ fun `a created rule goes last so it cannot pre-empt the rules already there`() {
+ graph.ruleDao.rows += rule("a", 0)
+ graph.ruleDao.rows += rule("b", 1)
+
+ var created: String? = null
+ vm.create { created = it }
+
+ val added = graph.ruleDao.rows.single { it.id == created }
+ assertEquals("a new rule must sort after every existing one", 2, added.position)
+ assertEquals("New rule", added.name)
+ assertEquals(RuleAction.QUEUE_BOTTOM, added.action)
+ }
+
+ @Test
+ fun `the first rule created starts at position zero`() {
+ vm.create { }
+ assertEquals(0, graph.ruleDao.rows.single().position)
+ }
+
+ @Test
+ fun `saving replaces the rule with that id rather than adding a second`() {
+ graph.ruleDao.rows += rule("a", 0, name = "before")
+
+ vm.save(rule("a", 0, name = "after"))
+
+ assertEquals(1, graph.ruleDao.rows.size)
+ assertEquals("after", graph.ruleDao.rows.single().name)
+ }
+
+ @Test
+ fun `deleting removes only that rule`() {
+ graph.ruleDao.rows += rule("a", 0)
+ graph.ruleDao.rows += rule("b", 1)
+
+ vm.delete("a")
+
+ assertEquals(listOf("b"), graph.ruleDao.rows.map { it.id })
+ }
+
+ @Test
+ fun `disabling a rule leaves it in the list`() {
+ graph.ruleDao.rows += rule("a", 0)
+
+ vm.setEnabled("a", false)
+
+ assertFalse(graph.ruleDao.rows.single().enabled)
+ vm.setEnabled("a", true)
+ assertTrue(graph.ruleDao.rows.single().enabled)
+ }
+
+ @Test
+ fun `reordering renumbers every rule from zero`() {
+ graph.ruleDao.rows += rule("a", 0)
+ graph.ruleDao.rows += rule("b", 1)
+ graph.ruleDao.rows += rule("c", 2)
+
+ vm.reorder(listOf("c", "a", "b"))
+
+ assertEquals(
+ "position is what decides which rule wins, so it has to be contiguous",
+ listOf("c" to 0, "a" to 1, "b" to 2),
+ graph.ruleDao.rows.sortedBy { it.position }.map { it.id to it.position },
+ )
+ }
+
+ @Test
+ fun `the listed rules come back in order`() = runTest(dispatcher) {
+ graph.ruleDao.rows += rule("b", 1)
+ graph.ruleDao.rows += rule("a", 0)
+
+ assertEquals(listOf("a", "b"), collected(vm.rules).map { it.id })
+ }
+
+ @Test
+ fun `every show is offered for the only-this-show condition`() = runTest(dispatcher) {
+ graph.subscriptions.rows += SubscriptionEntity(
+ id = "s1", feedUrl = "https://example.com/f", title = "A Show", addedAtMs = 0,
+ )
+
+ assertEquals(listOf("A Show"), collected(vm.subscriptions).map { it.title })
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt
new file mode 100644
index 0000000..4ec0554
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt
@@ -0,0 +1,321 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+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.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.FingerprintKind
+import org.terst.listen.data.ShowFingerprintEntity
+import org.terst.listen.data.ShowMidrollEntity
+import org.terst.listen.data.ShowTier
+import org.terst.listen.data.SubscriptionEntity
+import java.util.concurrent.TimeUnit
+
+/**
+ * The per-show sheet: drip, the archive walk, the tier and the skip readout.
+ *
+ * Every function here ends in a banner, and the banners are the point -- "nothing to release
+ * yet" and "N episode(s) queued" are the difference between a feature that looks broken and one
+ * that looks like it worked, and a partial archive walk is reported as an error on purpose even
+ * though episodes did arrive.
+ */
+class ShowSettingsViewModelTest {
+
+ private lateinit var graph: FakeUiGraph
+ private lateinit var server: MockWebServer
+
+ /** Built on first use; see the same note in [RulesViewModelTest]. */
+ private val vm: ShowSettingsViewModel by lazy { ShowSettingsViewModel(Application(), graph) }
+
+ @Before
+ fun setUp() {
+ // Unconfined rather than a virtual-time dispatcher: nothing in this view model
+ // delays, and the archive walk goes to a real local socket -- virtual time cannot
+ // wait for that, so there would be nothing to gain and a scheduler to pump.
+ Dispatchers.setMain(Dispatchers.Unconfined)
+ graph = FakeUiGraph()
+ server = MockWebServer()
+ server.start()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ graph.cleanup()
+ Dispatchers.resetMain()
+ }
+
+ private fun show(
+ id: String = "s1",
+ feedUrl: String = server.url("/feed.xml").toString(),
+ ): SubscriptionEntity {
+ val row = SubscriptionEntity(id = id, feedUrl = feedUrl, title = "A Show", addedAtMs = 0)
+ graph.subscriptions.rows += row
+ return row
+ }
+
+ private fun page(items: String, prevArchive: String? = null) = """<?xml version="1.0"?>
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
+ <title>A Show</title><description>d</description>
+ ${prevArchive?.let { """<atom:link rel="prev-archive" href="$it"/>""" }.orEmpty()}
+ $items
+ </channel></rss>""".trimIndent()
+
+ private fun item(guid: String) = """<item><title>$guid</title><guid>$guid</guid>
+ <enclosure url="https://cdn/$guid.mp3" length="10" type="audio/mpeg"/></item>"""
+
+
+ /**
+ * Waits for a view-model coroutine that went to the network to come back.
+ *
+ * The archive walk is the one thing here that leaves the thread, and it leaves it for a
+ * real local socket. Everything else is a fake DAO and has already finished by the time the
+ * call returns.
+ */
+ private fun awaitBanner(): Banner {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
+ while (System.nanoTime() < deadline) {
+ vm.banner.value?.let { return it }
+ Thread.sleep(POLL_MS)
+ }
+ throw AssertionError("no banner after $TIMEOUT_SECONDS seconds")
+ }
+
+ // --- drip ---------------------------------------------------------------------------
+
+ @Test
+ fun `enabling drip pays out what is already owed and says how much`() {
+ show()
+ graph.episodes.rows += episodeRow("e1")
+ graph.episodes.rows += episodeRow("e2")
+
+ vm.enableDrip("s1", intervalMs = 1_000, count = 2, oldestFirst = true)
+
+ assertEquals(Banner("Dripping — 2 episode(s) queued"), vm.banner.value)
+ assertEquals(
+ "a release always enqueues",
+ setOf("e1", "e2"),
+ graph.queueDao.entries.map { it.episodeId }.toSet(),
+ )
+ }
+
+ @Test
+ fun `enabling drip on a show with nothing to release says so rather than nothing`() {
+ show()
+
+ vm.enableDrip("s1", intervalMs = 1_000, count = 2, oldestFirst = true)
+
+ assertEquals(Banner("Dripping — nothing to release yet"), vm.banner.value)
+ }
+
+ @Test
+ fun `enabling drip for a show that is not there reports nothing released`() {
+ vm.enableDrip("missing", intervalMs = 1_000, count = 1, oldestFirst = true)
+
+ assertEquals(Banner("Dripping — nothing to release yet"), vm.banner.value)
+ }
+
+ @Test
+ fun `disabling drip promises the whole catalogue back`() {
+ show()
+ vm.enableDrip("s1", intervalMs = 1_000, count = 1, oldestFirst = true)
+
+ vm.disableDrip("s1")
+
+ assertEquals(Banner("Drip off — the whole catalogue is back"), vm.banner.value)
+ assertFalse(graph.subscriptions.rows.single().dripEnabled)
+ }
+
+ @Test
+ fun `dismissing clears the banner`() {
+ show()
+ vm.disableDrip("s1")
+ assertTrue(vm.banner.value != null)
+
+ vm.dismissBanner()
+
+ assertNull(vm.banner.value)
+ }
+
+ // --- the archive walk ---------------------------------------------------------------
+
+ @Test
+ fun `a feed with no archive pages says there is nothing older rather than failing`() {
+ show()
+ server.enqueue(MockResponse().setBody(page(item("a"))))
+
+ vm.fetchArchive("s1")
+
+ assertEquals(
+ Banner("This feed publishes no archive pages, so there is nothing older to fetch"),
+ awaitBanner(),
+ )
+ assertFalse("the busy flag has to come back down", vm.busy.value)
+ }
+
+ @Test
+ fun `a completed walk reports what it found`() {
+ show()
+ server.enqueue(MockResponse().setBody(page(item("a"), server.url("/page2.xml").toString())))
+ server.enqueue(MockResponse().setBody(page(item("old1") + item("old2"))))
+
+ vm.fetchArchive("s1")
+
+ assertEquals(Banner("2 older episode(s) from 1 page(s)"), awaitBanner())
+ }
+
+ /**
+ * A walk that stopped early is an error even though episodes arrived: the rest of the
+ * catalogue is missing, and a plain success would leave the show looking permanently short
+ * with no indication why.
+ */
+ @Test
+ fun `a walk that stopped early is reported as an error`() {
+ show()
+ server.enqueue(MockResponse().setBody(page(item("a"), server.url("/page2.xml").toString())))
+ server.enqueue(MockResponse().setBody(page(item("old1"), server.url("/page3.xml").toString())))
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ vm.fetchArchive("s1")
+
+ val banner = awaitBanner()
+ assertTrue(banner.isError)
+ assertTrue("it has to say what it found as well", banner.text.startsWith("1 older episode(s)"))
+ assertTrue("and why it stopped", banner.text.contains("then stopped: "))
+ }
+
+ @Test
+ fun `an unreachable feed is an error`() {
+ show()
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ vm.fetchArchive("s1")
+
+ assertTrue(awaitBanner().isError)
+ assertFalse(vm.busy.value)
+ }
+
+ @Test
+ fun `fetching an archive for a show that is not there clears the busy flag`() {
+ vm.fetchArchive("missing")
+
+ assertNull(vm.banner.value)
+ assertFalse("otherwise the spinner never stops", vm.busy.value)
+ }
+
+ // --- the one-line writes ------------------------------------------------------------
+
+ @Test
+ fun `skip offsets are stored on the show`() {
+ show()
+
+ vm.setSkips("s1", introMs = 30_000, outroMs = 45_000)
+
+ assertEquals(30_000L, graph.subscriptions.rows.single().skipIntroMs)
+ assertEquals(45_000L, graph.subscriptions.rows.single().skipOutroMs)
+ }
+
+ @Test
+ fun `ad-chapter skipping is per show`() {
+ show()
+
+ vm.setSkipAdChapters("s1", true)
+
+ assertTrue(graph.subscriptions.rows.single().skipAdChapters)
+ }
+
+ @Test
+ fun `each tier says what the server will do, and nothing about playback`() {
+ show()
+
+ vm.setTier("s1", ShowTier.TOPICAL)
+ assertEquals(Banner("The server will do this show first"), vm.banner.value)
+ assertEquals(ShowTier.TOPICAL.wire, graph.subscriptions.rows.single().tier)
+
+ vm.setTier("s1", ShowTier.ORDINARY)
+ assertEquals(Banner("The server will do this show in its usual turn"), vm.banner.value)
+
+ vm.setTier("s1", ShowTier.ARCHIVE)
+ assertEquals(
+ Banner("The server will do this show when nothing else is waiting"),
+ vm.banner.value,
+ )
+ assertEquals(ShowTier.ARCHIVE.wire, graph.subscriptions.rows.single().tier)
+ }
+
+ @Test
+ fun `bedtime says where the show has gone, both ways`() {
+ show()
+
+ vm.setBedtime("s1", true)
+ assertEquals(Banner("Bedtime — hidden until you switch to bedtime mode"), vm.banner.value)
+ assertTrue(graph.subscriptions.rows.single().bedtime)
+
+ vm.setBedtime("s1", false)
+ assertEquals(Banner("Back in the main library"), vm.banner.value)
+ assertFalse(graph.subscriptions.rows.single().bedtime)
+ }
+
+ // --- the detection readout ----------------------------------------------------------
+
+ @Test
+ fun `a show nothing has been found for reads as three empty states`() {
+ show()
+
+ vm.loadDetection("s1")
+
+ assertEquals(
+ ShowDetection(
+ intro = "Nothing found yet",
+ outro = "Nothing found yet",
+ midrolls = "None found yet",
+ ),
+ vm.detection.value,
+ )
+ }
+
+ @Test
+ fun `a confirmed intro and a gathering midroll read differently`() {
+ show()
+ graph.fingerprints.rows += ShowFingerprintEntity(
+ subscriptionId = "s1",
+ kind = FingerprintKind.INTRO,
+ referenceClipPcm = byteArrayOf(1, 2),
+ pendingEpisodeId = null,
+ sampleRate = 8_000,
+ confirmedCount = 9,
+ lastConfirmedEpisodeId = "e1",
+ updatedAtMs = 0,
+ )
+ graph.midrolls.candidateRows += ShowMidrollEntity(
+ subscriptionId = "s1",
+ id = "c1",
+ referenceClipPath = "/dev/null",
+ sampleRate = 8_000,
+ confirmedCount = 1,
+ lastConfirmedEpisodeId = "e1",
+ updatedAtMs = 0,
+ )
+
+ vm.loadDetection("s1")
+
+ val detection = vm.detection.value!!
+ assertEquals("Confirmed by 9 episodes", detection.intro)
+ assertEquals("the ends are read separately", "Nothing found yet", detection.outro)
+ assertEquals("1 still gathering", detection.midrolls)
+ }
+
+ private companion object {
+ const val TIMEOUT_SECONDS = 10L
+ const val POLL_MS = 5L
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/ViewModelConstructionTest.kt b/app/src/test/java/org/terst/listen/ui/ViewModelConstructionTest.kt
new file mode 100644
index 0000000..8e88650
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/ViewModelConstructionTest.kt
@@ -0,0 +1,93 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+import java.lang.reflect.Modifier
+
+/**
+ * Every view model keeps the constructor `by viewModels()` looks for.
+ *
+ * The view models each gained a second constructor taking their collaborators, so they could be
+ * tested off a device at all -- see [org.terst.listen.data.UiGraph]. Nothing in the app calls
+ * those: the screens use `by viewModels()` and `viewModel()`, which go through
+ * `AndroidViewModelFactory` and ask reflectively for a public constructor taking exactly an
+ * `Application`. Lose that constructor and every screen throws the moment it opens, with
+ * nothing failing at build time and no unit test noticing -- so this test is the thing that
+ * notices.
+ *
+ * It enumerates the source files rather than a list of names, so a seventh view model is
+ * covered on the day it is written.
+ */
+class ViewModelConstructionTest {
+
+ private val uiDir = File("src/main/java/org/terst/listen/ui")
+
+ private val declaration =
+ Regex("""^class (\w+)\((?:[^)]*)\)\s*:\s*\n?\s*AndroidViewModel""", RegexOption.MULTILINE)
+
+ private fun declaredViewModels(): List<String> =
+ uiDir.listFiles { f: File -> f.extension == "kt" }
+ .orEmpty()
+ .flatMap { file ->
+ declaration.findAll(file.readText()).map { it.groupValues[1] }
+ }
+ .sorted()
+
+ @Test
+ fun `the view models this test knows about are the ones in the source`() {
+ assertEquals(
+ listOf(
+ "ListenViewModel",
+ "OpmlViewModel",
+ "PlayerViewModel",
+ "RulesViewModel",
+ "ShowSettingsViewModel",
+ "TranscriptViewModel",
+ ),
+ declaredViewModels(),
+ )
+ }
+
+ /**
+ * The regex is the weak point: a view model declared in a shape it does not match would be
+ * absent from the list, the list would still equal the six expected, and the new one would go
+ * unguarded in silence. So the files are counted a second way -- anything extending
+ * AndroidViewModel at all has to be one the regex found. The looser check has to tolerate the
+ * supertype wrapping onto its own line, which TranscriptViewModel's does; a plain substring
+ * search for ": AndroidViewModel" misses it and was the first version of this test.
+ */
+ @Test
+ fun `every file that names AndroidViewModel is one the regex recognised`() {
+ val mentioning = uiDir.listFiles { f: File -> f.extension == "kt" }
+ .orEmpty()
+ .filter { Regex(""":\s*AndroidViewModel\b""").containsMatchIn(it.readText()) }
+ .map { it.nameWithoutExtension }
+ .sorted()
+
+ assertEquals(
+ "a file declares an AndroidViewModel in a shape the regex above does not match, " +
+ "so it is not being checked for the constructor `by viewModels()` needs",
+ mentioning,
+ declaredViewModels(),
+ )
+ }
+
+ @Test
+ fun `each one can still be built from an Application alone`() {
+ for (name in declaredViewModels()) {
+ val type = Class.forName("org.terst.listen.ui.$name")
+ assertTrue("$name must extend AndroidViewModel", AndroidViewModel::class.java.isAssignableFrom(type))
+
+ val constructor = runCatching { type.getConstructor(Application::class.java) }.getOrNull()
+ assertTrue(
+ "$name has no public (Application) constructor, so `by viewModels()` cannot " +
+ "build it and every screen using it throws on open",
+ constructor != null && Modifier.isPublic(constructor.modifiers),
+ )
+ }
+ }
+}