diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-27 07:46:46 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-27 07:46:46 +0000 |
| commit | aa046a7ee11a20914a5851dd0e5a89d129683b28 (patch) | |
| tree | 0dad76c115398c012b1708f5f4569553af330a18 /app/src | |
| parent | ec633d0daf17ab5806c563b7a95d6721b7b73c7b (diff) | |
Start testing the Compose screens
The dependencies for this were already in the build -- ui-test-junit4
on androidTest, ui-test-manifest on debug -- and had never been used.
So the spike was one test, and it passed first run. Eighteen now.
What they cover is the data-driven composables, chosen because a
regression in them looks completely normal: a readout that quietly
stops printing one of its three numbers, a control that stops
reporting whether it will play or pause, a bar that stops naming what
is playing while a screen shows something else.
The episode transport: the ad button is offered only while something
is playing, because with nothing playing there is no advertising to be
in the middle of and an enabled button would record a claim about a
position nobody is at. The primary control says what pressing it will
do. The skip buttons move by the amount they advertise.
The skip readout: all three numbers on one line, the status when there
is nothing to cut -- "nothing found" and "not checked yet" being
different answers is the mistake the old readout made -- and a legend
that names only what is actually on the track.
The now-playing bar: names the episode and the show, opens what is
playing when tapped, and reports play against pause.
Screens that need view models are still verified by eye. This is the
half that did not need them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
3 files changed, 290 insertions, 0 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/ui/EpisodeTransportTest.kt b/app/src/androidTest/java/org/terst/listen/ui/EpisodeTransportTest.kt new file mode 100644 index 0000000..26c6d4a --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/ui/EpisodeTransportTest.kt @@ -0,0 +1,105 @@ +package org.terst.listen.ui + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The episode transport, driven as a composable rather than looked at. + * + * The first Compose test in this app. Everything visual has been verified by eye on the emulator + * until now, which catches "does it look right" and never catches "does the button do the thing + * when the state changes". + */ +@RunWith(AndroidJUnit4::class) +class EpisodeTransportTest { + + @get:Rule + val compose = createComposeRule() + + private fun playback(isPlaying: Boolean, positionMs: Long = 0) = EpisodePlayback( + positionMs = positionMs, + durationMs = 600_000, + isPlaying = isPlaying, + breaks = emptyList(), + ) + + private fun actions( + onAd: () -> Unit = {}, + onSkip: (Long) -> Unit = {}, + onToggle: () -> Unit = {}, + ) = EpisodeDetailActions( + enqueue = {}, download = {}, downloadFromPublisher = {}, togglePlayed = {}, + markOlderPlayed = {}, togglePlayPause = onToggle, seekTo = {}, skip = onSkip, + setSpeed = {}, reportAd = onAd, + ) + + @Test + fun theAdButtonIsOfferedWhileSomethingIsPlaying() { + compose.setContent { EpisodeTransport(playback(isPlaying = true), 1.0f, actions()) } + + compose.onNodeWithContentDescription("This is an ad").assertIsEnabled() + } + + /** + * Nothing is playing, so there is no advertising to be in the middle of. An enabled button + * would record a claim about a position nobody is at. + */ + @Test + fun theAdButtonIsNotOfferedWhenNothingIsPlaying() { + compose.setContent { EpisodeTransport(playback(isPlaying = false), 1.0f, actions()) } + + compose.onNodeWithContentDescription("This is an ad").assertIsNotEnabled() + } + + @Test + fun pressingTheAdButtonReportsIt() { + var reported = 0 + compose.setContent { EpisodeTransport(playback(isPlaying = true), 1.0f, actions(onAd = { reported++ })) } + + compose.onNodeWithContentDescription("This is an ad").performClick() + + assertEquals(1, reported) + } + + /** The primary control says what pressing it will do, which is the whole reason it is worded. */ + @Test + fun theMainControlSaysPauseWhilePlaying() { + compose.setContent { EpisodeTransport(playback(isPlaying = true), 1.0f, actions()) } + + compose.onNodeWithText("Pause").assertIsEnabled() + } + + @Test + fun theMainControlSaysResumeWhenStoppedPartWayThrough() { + compose.setContent { EpisodeTransport(playback(isPlaying = false, positionMs = 60_000), 1.0f, actions()) } + + compose.onNodeWithText("Resume").assertIsEnabled() + } + + @Test + fun theMainControlSaysPlayFromTheStart() { + compose.setContent { EpisodeTransport(playback(isPlaying = false), 1.0f, actions()) } + + compose.onNodeWithText("Play").assertIsEnabled() + } + + @Test + fun theSkipButtonsMoveByTheAmountTheyAdvertise() { + val moved = mutableListOf<Long>() + compose.setContent { EpisodeTransport(playback(isPlaying = true), 1.0f, actions(onSkip = { moved += it })) } + + compose.onNodeWithContentDescription("Back 15 seconds").performClick() + compose.onNodeWithContentDescription("Forward 25 seconds").performClick() + + assertEquals(listOf(-15_000L, 25_000L), moved) + } +} diff --git a/app/src/androidTest/java/org/terst/listen/ui/NowPlayingBarTest.kt b/app/src/androidTest/java/org/terst/listen/ui/NowPlayingBarTest.kt new file mode 100644 index 0000000..483e934 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/ui/NowPlayingBarTest.kt @@ -0,0 +1,91 @@ +package org.terst.listen.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.terst.listen.playback.NowPlaying + +/** + * The only persistent chrome in the app, and the way back to whatever is playing. + * + * Worth testing because it is the thing that saves you when a screen is showing something else: + * if it stops naming the episode, or its play button stops reporting state, the app looks fine + * and strands you. + */ +@RunWith(AndroidJUnit4::class) +class NowPlayingBarTest { + + @get:Rule + val compose = createComposeRule() + + private fun playing(isPlaying: Boolean = true) = NowPlaying( + episodeId = "e1", + title = "Ukraine's President Sits With NPR", + showTitle = "Up First from NPR", + isPlaying = isPlaying, + positionMs = 281_000, + durationMs = 908_000, + speed = 1.25f, + ) + + private fun actions( + onToggle: () -> Unit = {}, + onOpenEpisode: () -> Unit = {}, + onOpenQueue: () -> Unit = {}, + ) = NowPlayingActions( + togglePlayPause = onToggle, + skipBack = {}, + skipForward = {}, + openEpisode = onOpenEpisode, + openQueue = onOpenQueue, + ) + + @Test + fun namesTheEpisodeAndTheShow() { + compose.setContent { NowPlayingBar(playing(), actions()) } + + compose.onNodeWithText("Ukraine's President Sits With NPR", substring = true).assertIsDisplayed() + compose.onNodeWithText("Up First from NPR", substring = true).assertIsDisplayed() + } + + @Test + fun tappingTheBarOpensWhatIsPlaying() { + var opened = 0 + compose.setContent { NowPlayingBar(playing(), actions(onOpenEpisode = { opened++ })) } + + compose.onNodeWithText("Ukraine's President Sits With NPR", substring = true).performClick() + + assertEquals("the bar is the way back to the player", 1, opened) + } + + @Test + fun offersPauseWhilePlaying() { + compose.setContent { NowPlayingBar(playing(isPlaying = true), actions()) } + + compose.onNodeWithContentDescription("Pause").assertIsDisplayed() + } + + @Test + fun offersPlayWhileStopped() { + compose.setContent { NowPlayingBar(playing(isPlaying = false), actions()) } + + compose.onNodeWithContentDescription("Play").assertIsDisplayed() + } + + @Test + fun theControlActuallyToggles() { + var toggled = 0 + compose.setContent { NowPlayingBar(playing(), actions(onToggle = { toggled++ })) } + + compose.onNodeWithContentDescription("Pause").performClick() + + assertEquals(1, toggled) + } +} diff --git a/app/src/androidTest/java/org/terst/listen/ui/SkipReadoutTest.kt b/app/src/androidTest/java/org/terst/listen/ui/SkipReadoutTest.kt new file mode 100644 index 0000000..49d8da3 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/ui/SkipReadoutTest.kt @@ -0,0 +1,94 @@ +package org.terst.listen.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.terst.listen.fingerprint.EpisodeSkips +import org.terst.listen.fingerprint.SkipSummary + +/** + * The three numbers under the timeline, and the legend beside them. + * + * This is the sanity check Peter asked for: the gap between served and declared is the + * advertising in the file, and "cut" is what the detector took out, so a cut far from that gap + * is the detector being wrong. The point of testing it is that a readout which quietly stops + * saying one of the three looks completely normal. + */ +@RunWith(AndroidJUnit4::class) +class SkipReadoutTest { + + @get:Rule + val compose = createComposeRule() + + private fun marker(kind: String) = + SkipSummary.BreakMarker(start = 0.1f, end = 0.2f, kind = kind, startMs = 60_000, endMs = 120_000) + + @Test + fun showsAllThreeNumbersOnOneLine() { + val skips = EpisodeSkips(served = "15:08", declared = "13:30", cut = "0:47", status = null) + + compose.setContent { SkipReadout(skips, listOf(marker("ad"))) } + + compose.onNodeWithText("Served 15:08 · Declared 13:30 · Cut 0:47").assertIsDisplayed() + } + + /** + * "Nothing found" and "not checked yet" are different answers, and collapsing them is the + * mistake the old readout made. + */ + @Test + fun saysWhyWhenThereIsNothingToCut() { + val skips = EpisodeSkips(served = "15:08", declared = "13:30", cut = null, status = "Not checked yet") + + compose.setContent { SkipReadout(skips, emptyList()) } + + compose.onNodeWithText("Not checked yet").assertIsDisplayed() + } + + @Test + fun theLegendNamesAdvertisingWhenSomeIsMarked() { + compose.setContent { + SkipReadout(EpisodeSkips(null, null, "0:47", null), listOf(marker("ad"))) + } + + compose.onNodeWithText(SkipSummary.LEGEND_ADVERTISING).assertIsDisplayed() + } + + /** + * The one distinction that changes what happens: advertising is always cut, the show's own + * audio follows the setting. The legend only claims what is actually on the track. + */ + @Test + fun theLegendNamesTheShowsOwnAudioWhenThatIsWhatIsMarked() { + compose.setContent { + SkipReadout(EpisodeSkips(null, null, "0:12", null), listOf(marker("outro"))) + } + + compose.onNodeWithText(SkipSummary.LEGEND_OWN_AUDIO).assertIsDisplayed() + compose.onNodeWithText(SkipSummary.LEGEND_ADVERTISING).assertDoesNotExist() + } + + @Test + fun bothAreNamedWhenBothAreOnTheTrack() { + compose.setContent { + SkipReadout(EpisodeSkips(null, null, "1:00", null), listOf(marker("ad"), marker("cue"))) + } + + compose.onNodeWithText(SkipSummary.LEGEND_ADVERTISING).assertIsDisplayed() + compose.onNodeWithText(SkipSummary.LEGEND_OWN_AUDIO).assertIsDisplayed() + } + + @Test + fun noLegendWhenNothingIsMarked() { + compose.setContent { + SkipReadout(EpisodeSkips("15:08", "13:30", null, "None found"), emptyList()) + } + + compose.onNodeWithText(SkipSummary.LEGEND_ADVERTISING).assertDoesNotExist() + compose.onNodeWithText(SkipSummary.LEGEND_OWN_AUDIO).assertDoesNotExist() + } +} |
