diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-11-03 08:29:32 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-11-03 08:29:32 +0000 |
| commit | 913ba0e829ca20ccbb1a8db213541760dfc42a4b (patch) | |
| tree | b56b62b4739e0f4c8ed5b36cae5404936048dc22 /app/src | |
| parent | 87b8cfa942b995d1c013827ec7014cf6ef4138ca (diff) | |
Measure and test the view models the ui exclusion was hiding
The coverage gate excluded `**/ui/**` wholesale, justified as "Compose screens are impractical to
unit-test". It also hid six view models -- plain Kotlin classes holding real decisions, every one
at 0%, not one of them a screen -- behind a justification that only ever applied to screens.
PlayerViewModel and ListenViewModel were 150 lines of untested logic the gate could not see.
Done by a subagent; its coverage figure, test count and detekt result were re-derived here from
the XML rather than taken on trust, and its two findings checked against the source.
The exclusion now names each screen file instead of the package. All six view models go from 0%
to 92-99%, the measured `ui` package to 96.6%, and the project to **95.17% against a 94.44%
baseline** -- the gate passes with the exclusion narrowed and no COVERAGE_EXCEPTION. 96 new tests,
suite 951 -> 1048. The tests drive the real SubscriptionRepository, PlaybackAdvancer,
QueueRepository, DripReleaser and FeedResolver against the existing FakeDaos and MockWebServer, so
the banners asserted are the app's own wording rather than a stub agreeing with itself.
Four production seams, all behaviour-preserving: `UiGraph` and `ViewPreferences` over the graph,
`PlaybackControls` over PlayerConnection (the same shape as the `Playhead` precedent), and a
second constructor per view model with the `(Application)` one kept as a delegate. That last is
load-bearing in a way nothing would catch: `by viewModels()` finds a constructor reflectively, so
losing it throws on screen open with a green build and no failing test. ViewModelConstructionTest
is the guard. Its first version checked for the literal ": AndroidViewModel" and so missed
TranscriptViewModel, which wraps its supertype onto the next line -- a check that would have
passed while leaving a seventh view model unguarded in silence. It now cross-checks the two ways
of finding them against each other.
The action and state objects are excluded on a different ground from the screens, and the comment
says so explicitly so the list does not grow by analogy: they construct fine off a device, but
every line jacoco counts in them is one Kotlin generated -- constructor, componentN, copy, equals,
hashCode, toString. Measuring them measures the compiler. A data class that gains a method or an
init block comes back off the list.
Also, found while reviewing: detekt's `UnusedImports` rule is off by default, which is how 71 dead
imports accumulated across nine files. EpisodeActions.kt was eleven lines of data class under
forty of them -- Scaffold, SnackbarHost, LazyColumn and the rest, left behind when the screens
were split per file, which made it read like a screen. The rule is on and the 71 are gone. Two in
that file needed removing by hand: detekt whitelists `getValue`/`setValue` unconditionally because
it cannot tell a `by` delegation from none, and that file has no delegation at all.
1048 tests pass, detekt 0 findings on a fresh report, lint clean, coverage 95.17%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
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), + ) + } + } +} |
