diff options
Diffstat (limited to 'app')
28 files changed, 2374 insertions, 140 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36d6038..a7b74cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -254,6 +254,29 @@ jacoco { * Compose screens are excluded for the same reason as FeeDreader: they are impractical to * unit-test and including them makes the metric meaningless. * + * They are named one screen file at a time rather than by excluding the whole `ui` package, + * which is what this used to do. That blanket pattern also hid six view models -- plain Kotlin + * classes with real decisions in them, every one measuring 0% and not one of them a Compose + * screen -- behind a justification that only ever applied to the screens. The list below is + * longer, and that is its honest length: each glob names the classes one screen file compiles + * to (its `...Kt` class, its `ComposableSingletons` holder, and the state and action objects + * only that screen constructs). Nothing else under `ui` is excluded. + * + * The action and state objects are excluded on a different ground from the screens, and it is + * worth being explicit so the list does not grow by analogy. They are not hard to execute off a + * device -- `EpisodeActions(play = {}, ...)` constructs fine. They are excluded because every + * line jacoco counts in them is one Kotlin generated: the constructor, `componentN`, `copy`, + * `equals`, `hashCode`, `toString`. There is no statement of this app's own in any of them, so + * measuring them measures the compiler. **That is the whole of the reason**, and it does not + * extend to a data class that gains a method, an `init` block or a default computed from + * another field -- the moment one does, it comes off this list. + * + * Two entries need saying out loud. The BreakJudgingKt glob excludes only the composables in + * BreakJudging.kt; the `BreakJudging` and `BreakJudgingSource` classes beside them stay + * measured. And the EpisodeScreenKt glob takes the tested `stripHtml` out of the measurement + * along with the screen it happens to live in -- a cost of excluding by file, not a reason to + * stop testing it. + * * The rest are framework adapters -- classes whose whole body is a call into ExoPlayer, Media3, * Room, WorkManager or Chaquopy, none of which can execute on the JVM. They were overridden * eight times in a row with `COVERAGE_EXCEPTION`, and the last few were perverse: extracting @@ -267,7 +290,71 @@ jacoco { * logic runs fine against fakes. */ val coverageExclusions = listOf( - "**/ui/**", + // Compose screens, one file at a time. See the note above: `**/ui/**` also hid the view + // models, which are ordinary classes and are measured. + // + // Every Compose file compiles a `ComposableSingletons` holder for its constant lambdas. + // It is generated, there is nothing in it to test, and it belongs to the screen it is + // named after. + "**/ui/ComposableSingletons\$*", + + "**/ui/MainActivity*", + "**/ui/ShareReceiverActivity*", + "**/ui/BreakJudgingKt*", + "**/ui/DripSettingsKt*", + "**/ui/EpisodeDescriptionKt*", + "**/ui/EpisodeOverflowKt*", + "**/ui/EpisodeRowKt*", + "**/ui/EpisodeScreenKt*", + "**/ui/EpisodeTimelineKt*", + "**/ui/EpisodeTransportKt*", + "**/ui/FurnitureSkipSettingsStateKt*", + "**/ui/LibraryScreenKt*", + "**/ui/NowPlayingBarKt*", + "**/ui/PlayerBarKt*", + "**/ui/PlayerMessageKt*", + "**/ui/QueueScreenKt*", + "**/ui/RuleConditionFieldsKt*", + "**/ui/RuleEditScreenKt*", + "**/ui/RulesScreenKt*", + "**/ui/SettingRowsKt*", + "**/ui/SettingsScreenKt*", + "**/ui/ShowSettingsSheetKt*", + "**/ui/SkipServerSettingsStateKt*", + "**/ui/SkipSettingsKt*", + "**/ui/SubscriptionRowKt*", + "**/ui/SubscriptionScreenKt*", + "**/ui/TierSettingKt*", + "**/ui/TranscriptScreenKt*", + + // A screen's own parameter and state objects: data classes of callbacks and already-decided + // values, handed from one composable to another. They decide nothing, they are constructed + // only inside the files above, and the only test that could reach one would call its + // constructor and assert nothing. + // + // Excluded by what they are rather than by which file they sit in. Most are declared inside + // their screen and were already covered by the globs above; these five happen to have files + // of their own, and a line that excluded one and measured the other would be describing the + // author's filing rather than the code. + "**/ui/EpisodeActions*", + "**/ui/EpisodeDetailActions*", + "**/ui/EpisodeDetailState*", + "**/ui/EpisodeHooks*", + "**/ui/EpisodePlayback*", + "**/ui/FurnitureSkipSettings*", + "**/ui/LibraryContent*", + "**/ui/LibraryNav*", + "**/ui/LibraryTopBarActions*", + "**/ui/NowPlayingActions*", + "**/ui/OverflowContent*", + "**/ui/QueueNav*", + "**/ui/QueueRowActions*", + "**/ui/ScreenSource*", + "**/ui/SkipServerSettings*", + "**/ui/SkipSettings*", + "**/ui/SubscriptionNav*", + "**/ui/SubscriptionTopBarActions*", + "**/R.class", "**/R\$*.class", "**/BuildConfig.*", "**/*_Impl*", // ExoPlayer, MediaSession and the browse tree: no JVM equivalent to run against. 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), + ) + } + } +} |
