From aa046a7ee11a20914a5851dd0e5a89d129683b28 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 27 Oct 2026 07:46:46 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../org/terst/listen/ui/EpisodeTransportTest.kt | 105 +++++++++++++++++++++ .../java/org/terst/listen/ui/NowPlayingBarTest.kt | 91 ++++++++++++++++++ .../java/org/terst/listen/ui/SkipReadoutTest.kt | 94 ++++++++++++++++++ ...2026-10-20-corrections-and-signature-library.md | 9 +- 4 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/org/terst/listen/ui/EpisodeTransportTest.kt create mode 100644 app/src/androidTest/java/org/terst/listen/ui/NowPlayingBarTest.kt create mode 100644 app/src/androidTest/java/org/terst/listen/ui/SkipReadoutTest.kt 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() + 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() + } +} diff --git a/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md index 331e883..687a293 100644 --- a/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md +++ b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md @@ -386,8 +386,13 @@ everything measured, though not directly caught in a probe. ## Known gaps, deliberate -- **Compose has no automated coverage** — no Robolectric. Everything visual is verified by eye on - the emulator only. +- **Compose now has automated coverage — started 2026-10-27.** Eighteen tests over the episode + transport, the skip readout and the now-playing bar, run on the emulator with + `createComposeRule`. The dependencies for this were already in the build and had never been + used, so it cost one test to find out rather than a spike. Screens that need view models are + still checked by eye; what is covered is the data-driven composables, where a regression would + look completely normal — a readout quietly dropping one of its three numbers, or a control that + stops reporting its state. - **The rejected-token notice is written down — fixed 2026-10-27.** It lives in the preferences file now, so it is on the Settings screen at launch rather than after the next refresh has gone and been refused all over again. Saving *different* credentials clears it at once; saving the -- cgit v1.2.3