diff options
12 files changed, 390 insertions, 34 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index c20f199..1395f44 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -94.21 +94.26 diff --git a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt index 807b0d7..12dc0ed 100644 --- a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt +++ b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt @@ -16,6 +16,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.Graph +import org.terst.listen.data.ShowCategory import org.terst.listen.data.SubscriptionEntity /** @@ -125,7 +126,7 @@ class MediaBrowseTest { assertTrue("browsed item should be playable", item.mediaMetadata.isPlayable == true) assertTrue( "the browse id must survive a round trip", - LibraryTree.episodeId(item.mediaId) != null, + BrowseIds.episodeId(item.mediaId) != null, ) } } finally { @@ -192,7 +193,7 @@ class MediaBrowseTest { val browser = connect() try { withContext(Dispatchers.Main) { - val continueId = LibraryTree.carListId(CarLists.Node.CONTINUE) + val continueId = BrowseIds.forCarList(CarLists.Node.CONTINUE) val topIds = browser.getChildren(LibraryTree.ROOT, 0, 20, null) .await().value!!.map { it.mediaId } assertTrue( @@ -213,6 +214,56 @@ class MediaBrowseTest { } } + /** + * Shows are grouped into category folders, and a folder holds its own shows. + * + * Forty-five shows in one list is several flicks from anything on a screen that holds six to + * eight, which was the other half of "discovery sucks". Walked over the real protocol because + * the grouping is assembled from a query and two browse ids, and an id that does not survive + * the round trip is what made Up Next show something other than the queue. + */ + @Test + fun podcastsAreGroupedByCategory() = runBlocking { + val graph = Graph.get(context) + graph.subscriptions.insert( + seedShow("cat-news", "News Hour").copy(category = ShowCategory.NEWS.wire) + ) + graph.subscriptions.insert( + seedShow("cat-fiction", "Fiction Hour").copy(category = ShowCategory.FICTION.wire) + ) + graph.episodes.insertAll(listOf(seedEpisode("cat-news-ep", "cat-news", 2_000))) + graph.episodes.insertAll(listOf(seedEpisode("cat-fiction-ep", "cat-fiction", 1_000))) + + val browser = connect() + try { + withContext(Dispatchers.Main) { + val folders = browser.getChildren(LibraryTree.SUBSCRIPTIONS, 0, 100, null) + .await().value!!.map { it.mediaId } + assertTrue( + "expected a News folder, got $folders", + folders.contains(BrowseIds.forCategory(ShowCategory.NEWS)), + ) + assertTrue( + "expected a Fiction folder, got $folders", + folders.contains(BrowseIds.forCategory(ShowCategory.FICTION)), + ) + + val inNews = browser + .getChildren(BrowseIds.forCategory(ShowCategory.NEWS), 0, 100, null) + .await().value!!.mapNotNull { it.mediaMetadata.title?.toString() } + assertTrue("News should hold News Hour, got $inNews", inNews.contains("News Hour")) + assertTrue( + "News must not hold a fiction show, got $inNews", + !inNews.contains("Fiction Hour"), + ) + } + } finally { + withContext(Dispatchers.Main) { browser.release() } + graph.subscriptions.delete("cat-news") + graph.subscriptions.delete("cat-fiction") + } + } + private fun seedShow(id: String, title: String) = SubscriptionEntity( id = id, feedUrl = "https://example.invalid/$id", diff --git a/app/src/main/java/org/terst/listen/playback/BrowseIds.kt b/app/src/main/java/org/terst/listen/playback/BrowseIds.kt new file mode 100644 index 0000000..91f5d2b --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/BrowseIds.kt @@ -0,0 +1,48 @@ +package org.terst.listen.playback + +import org.terst.listen.data.ShowCategory + +/** + * The wire format of a browse id, and the only thing that knows it. + * + * Split out of [LibraryTree] when that object outgrew its function limit, and the split is along + * a real line: this is a string format with no Media3 in it, while what is left there builds + * `MediaItem`s and can only be exercised on a device. The format is also the seam that has + * actually broken -- an id that does not survive the round trip to the browser and back is what + * made the car's Up Next show something other than the queue -- so it is worth being able to + * test it off a device, which it was not before. + * + * Every id is prefixed by what it names. A bare id would be ambiguous the moment two kinds of + * thing shared one, and episode ids and show ids both come from feeds. + */ +object BrowseIds { + + const val ROOT = "listen:root" + const val QUEUE = "listen:queue" + const val SUBSCRIPTIONS = "listen:subscriptions" + + private const val SUBSCRIPTION_PREFIX = "listen:subscription:" + private const val CATEGORY_PREFIX = "listen:category:" + private const val LIST_PREFIX = "listen:list:" + private const val EPISODE_PREFIX = "listen:episode:" + + fun forSubscription(id: String): String = SUBSCRIPTION_PREFIX + id + + fun subscriptionId(mediaId: String): String? = + mediaId.removePrefix(SUBSCRIPTION_PREFIX).takeIf { mediaId.startsWith(SUBSCRIPTION_PREFIX) } + + fun forEpisode(id: String): String = EPISODE_PREFIX + id + + fun episodeId(mediaId: String): String? = + mediaId.removePrefix(EPISODE_PREFIX).takeIf { mediaId.startsWith(EPISODE_PREFIX) } + + fun forCategory(category: ShowCategory): String = CATEGORY_PREFIX + category.wire + + fun category(mediaId: String): ShowCategory? = + ShowCategory.entries.firstOrNull { mediaId == forCategory(it) } + + fun forCarList(node: CarLists.Node): String = LIST_PREFIX + node.wire + + fun carList(mediaId: String): CarLists.Node? = + CarLists.Node.entries.firstOrNull { mediaId == forCarList(it) } +} diff --git a/app/src/main/java/org/terst/listen/playback/CarCategories.kt b/app/src/main/java/org/terst/listen/playback/CarCategories.kt new file mode 100644 index 0000000..2f9460b --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/CarCategories.kt @@ -0,0 +1,26 @@ +package org.terst.listen.playback + +import org.terst.listen.data.ShowCategory + +/** + * How the car groups the shows. + * + * One list of forty-five shows is not browsable from a driver's seat -- the screen holds six to + * eight rows, so the thing being looked for is usually several flicks away, which was the other + * half of "discovery sucks" and "scrolling the lists in the car is bad experience". The + * categories already exist and already have an order, so they cost nothing to group by. + */ +object CarCategories { + + /** + * The category folders to show, or none at all when grouping would not help. + * + * An empty answer means "list the shows directly": a single category wrapping every show is + * a folder that adds a tap and separates nothing, and a tap in a car is expensive. Shows are + * only ever grouped when there is more than one group to tell apart. + */ + fun offered(shows: List<ShowCategory>): List<ShowCategory> { + val present = ShowCategory.entries.filter { it in shows } + return if (present.size > 1) present else emptyList() + } +} diff --git a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt index d4acdf2..336e92e 100644 --- a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt +++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt @@ -4,6 +4,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import org.terst.listen.data.Artwork import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.ShowCategory import org.terst.listen.data.SubscriptionEntity /** @@ -18,25 +19,13 @@ import org.terst.listen.data.SubscriptionEntity */ object LibraryTree { - const val ROOT = "listen:root" - const val QUEUE = "listen:queue" - const val SUBSCRIPTIONS = "listen:subscriptions" - private const val SUBSCRIPTION_PREFIX = "listen:subscription:" - private const val LIST_PREFIX = "listen:list:" - private const val EPISODE_PREFIX = "listen:episode:" + const val ROOT = BrowseIds.ROOT + const val QUEUE = BrowseIds.QUEUE + const val SUBSCRIPTIONS = BrowseIds.SUBSCRIPTIONS - fun subscriptionId(mediaId: String): String? = - mediaId.removePrefix(SUBSCRIPTION_PREFIX).takeIf { mediaId.startsWith(SUBSCRIPTION_PREFIX) } - - /** The browse id for one of the car's lists. */ - fun carListId(node: CarLists.Node): String = LIST_PREFIX + node.wire - - /** The car list a browse id names, or null if it names something else. */ - fun carList(mediaId: String): CarLists.Node? = CarLists.Node.entries - .firstOrNull { mediaId == carListId(it) } - - fun episodeId(mediaId: String): String? = - mediaId.removePrefix(EPISODE_PREFIX).takeIf { mediaId.startsWith(EPISODE_PREFIX) } + /** A category as a folder of shows. */ + fun categoryFolder(category: ShowCategory): MediaItem = + browsable(BrowseIds.forCategory(category), category.label) fun root(): MediaItem = browsable(ROOT, "Listen") @@ -48,7 +37,7 @@ object LibraryTree { * so there is no title or artwork to look up. */ fun episodeRef(episodeId: String): MediaItem = - MediaItem.Builder().setMediaId(EPISODE_PREFIX + episodeId).build() + MediaItem.Builder().setMediaId(BrowseIds.forEpisode(episodeId)).build() /** * The root, with whichever of the car lists currently hold something. @@ -60,11 +49,11 @@ object LibraryTree { */ fun topLevel(lists: List<CarLists.Node> = emptyList()): List<MediaItem> = listOf(browsable(QUEUE, "Up Next")) + - lists.map { browsable(carListId(it), it.label) } + + lists.map { browsable(BrowseIds.forCarList(it), it.label) } + browsable(SUBSCRIPTIONS, "Podcasts") fun subscription(entity: SubscriptionEntity): MediaItem = - browsable(SUBSCRIPTION_PREFIX + entity.id, entity.title, entity.author, entity.imageUrl) + browsable(BrowseIds.forSubscription(entity.id), entity.title, entity.author, entity.imageUrl) /** * An episode, playable. @@ -74,7 +63,7 @@ object LibraryTree { */ fun episode(entity: EpisodeEntity, showTitle: String? = null, showImageUrl: String? = null): MediaItem = MediaItem.Builder() - .setMediaId(EPISODE_PREFIX + entity.id) + .setMediaId(BrowseIds.forEpisode(entity.id)) .setMediaMetadata( MediaMetadata.Builder() .setTitle(entity.title) diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt index e9e8ca7..d8e1856 100644 --- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt +++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.Graph +import org.terst.listen.data.ShowCategory import org.terst.listen.server.ServerAnswer /** @@ -123,20 +124,40 @@ class ListenSessionCallback( } } - parentId == LibraryTree.SUBSCRIPTIONS -> + parentId == LibraryTree.SUBSCRIPTIONS -> { // The library's own query, not a second one ordered differently: the car lists // shows newest-waiting-first for the same reason the phone does, and a separate // ordering here is what made the car list look unsorted. Bedtime shows are left // out by it -- driving is the opposite of falling asleep -- while voice search // still goes through getAll, since asking for a show by name is unambiguous. + val listings = graph.subscriptions.observeAll(bedtime = false).first() + // Grouped by category when there is more than one, because forty-five shows on a + // screen that holds six to eight is several flicks from anything. With one + // category the folders would separate nothing, so the shows are listed directly. + val categories = CarCategories.offered( + listings.map { ShowCategory.of(it.subscription.category) } + ) + if (categories.isEmpty()) { + listings.map { LibraryTree.subscription(it.subscription) } + } else { + categories.map { LibraryTree.categoryFolder(it) } + } + } + + BrowseIds.category(parentId) != null -> { + val category = BrowseIds.category(parentId)!! + // Filtered from the same query rather than a second one, so a show sits in the + // same place in its category as it did in the flat list. graph.subscriptions.observeAll(bedtime = false).first() + .filter { ShowCategory.of(it.subscription.category) == category } .map { LibraryTree.subscription(it.subscription) } + } - LibraryTree.carList(parentId) != null -> - withShows(carListEpisodes(LibraryTree.carList(parentId)!!)) + BrowseIds.carList(parentId) != null -> + withShows(carListEpisodes(BrowseIds.carList(parentId)!!)) - LibraryTree.subscriptionId(parentId) != null -> { - val id = LibraryTree.subscriptionId(parentId)!! + BrowseIds.subscriptionId(parentId) != null -> { + val id = BrowseIds.subscriptionId(parentId)!! val show = graph.subscriptions.getById(id) // Newest first, and capped: a feed here held 2753 episodes, and neither a car // screen nor the browser protocol wants that list. @@ -157,7 +178,7 @@ class ListenSessionCallback( browser: MediaSession.ControllerInfo, mediaId: String, ): ListenableFuture<LibraryResult<MediaItem>> = scope.future { - val episodeId = LibraryTree.episodeId(mediaId) + val episodeId = BrowseIds.episodeId(mediaId) val episode = episodeId?.let { graph.episodes.getById(it) } if (episode == null) { LibraryResult.ofError(SessionError.ERROR_BAD_VALUE) @@ -179,7 +200,7 @@ class ListenSessionCallback( mediaItems: MutableList<MediaItem>, ): ListenableFuture<MutableList<MediaItem>> = scope.future { mediaItems.mapNotNull { item -> - val episodeId = LibraryTree.episodeId(item.mediaId) ?: return@mapNotNull item + val episodeId = BrowseIds.episodeId(item.mediaId) ?: return@mapNotNull item val episode = graph.episodes.getById(episodeId) ?: return@mapNotNull null val show = graph.subscriptions.getById(episode.subscriptionId) @@ -239,7 +260,7 @@ class ListenSessionCallback( // the resolved item gives null, and null resumes at zero, which is the bug this fixes // wearing a different hat. val index = BrowsedStart.index(startIndex, mediaItems.size) - val chosen = mediaItems.getOrNull(index)?.let { LibraryTree.episodeId(it.mediaId) } + val chosen = mediaItems.getOrNull(index)?.let { BrowseIds.episodeId(it.mediaId) } val resumeMs = chosen ?.let { graph.advancer.start(it) } @@ -256,7 +277,7 @@ class ListenSessionCallback( val queue = graph.queueDao.getEntries().map { it.episodeId } BrowsedStart.playlist(chosen, queue).map { LibraryTree.episodeRef(it) }.toMutableList() } - val startOn = chosen?.let { BrowsedStart.indexOf(it, wanted.mapNotNull { m -> LibraryTree.episodeId(m.mediaId) }) } ?: index + val startOn = chosen?.let { BrowsedStart.indexOf(it, wanted.mapNotNull { m -> BrowseIds.episodeId(m.mediaId) }) } ?: index val resolved = onAddMediaItems(mediaSession, controller, wanted).await() MediaSession.MediaItemsWithStartPosition( diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt index 1e6fd99..49fb955 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt @@ -38,6 +38,7 @@ fun ShowSettingsSheet( ) { val busy by settings.busy.collectAsState() val detection by settings.detection.collectAsState() + val silence by settings.silence.collectAsState() // Read once per opening. Nothing can move it while the sheet is up: detection only advances // in a background worker. @@ -68,6 +69,12 @@ fun ShowSettingsSheet( HorizontalDivider() + // Above the drip control on purpose: a show that stopped years ago is the case slow + // drip is for, and saying so here is the whole of the "special treatment" it gets. + silence?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline) + } + DripSettings( subscription = show, busy = busy, 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 ab22010..c5fb36c 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -159,6 +159,16 @@ class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : Andr } } + private val _silence = MutableStateFlow<String?>(null) + + /** + * How long the show on screen has been silent, or null while it is still publishing. + * + * Filled by the same read as [detection], because that already fetches every episode and a + * second query for one date would be a query per show screen for nothing. + */ + val silence: StateFlow<String?> = _silence.asStateFlow() + private val _detection = MutableStateFlow<ShowDetection?>(null) /** What Tier 3 has worked out for the show currently on screen. */ @@ -178,6 +188,10 @@ class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : Andr // writes; the show-level confirmation rows this used to read have had no writer // since detection moved, so every show read "Nothing found yet" whatever was true. val episodes = graph.episodes.getForSubscription(subscriptionId) + _silence.value = ShowSilence.label( + episodes.mapNotNull { it.publishedAtMs }.maxOrNull(), + System.currentTimeMillis(), + ) val checked = episodes.count { it.audioFromServer } val withRanges = episodes.count { it.audioFromServer && graph.midrolls.getRanges(it.id).isNotEmpty() } _detection.value = ShowDetection( diff --git a/app/src/main/java/org/terst/listen/ui/ShowSilence.kt b/app/src/main/java/org/terst/listen/ui/ShowSilence.kt new file mode 100644 index 0000000..7ef5920 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/ShowSilence.kt @@ -0,0 +1,35 @@ +package org.terst.listen.ui + +import java.util.concurrent.TimeUnit + +/** + * How long a show has been silent, said out loud, or nothing at all. + * + * Peter's steer was that a finished show probably wants slow drip on and needs no special + * treatment beyond "maybe some UI" -- so this is the UI: a line above the drip control saying + * how long it has been, and nothing else. No flag on the show, no automatic behaviour, no + * guessing whether a show is over. A run of silence is a fact; "finished" is not. + */ +object ShowSilence { + + /** + * How quiet a show has to be before the line appears, a year. + * + * Shorter would fire on shows that are merely between seasons, which is most of the + * archive-ish half of the library in any given month, and a line that appears on everything + * says nothing. A year is long enough that the answer is interesting whichever way it + * turns out. + */ + val QUIET_AFTER_MS: Long = TimeUnit.DAYS.toMillis(DAYS_PER_YEAR) + + /** The line, or null when the show is still publishing and there is nothing to say. */ + fun label(newestPublishedAtMs: Long?, nowMs: Long): String? { + if (newestPublishedAtMs == null) return null + val silentMs = nowMs - newestPublishedAtMs + if (silentMs < QUIET_AFTER_MS) return null + val years = TimeUnit.MILLISECONDS.toDays(silentMs) / DAYS_PER_YEAR + return if (years <= 1) "No new episodes in a year" else "No new episodes in $years years" + } +} + +private const val DAYS_PER_YEAR = 365L diff --git a/app/src/test/java/org/terst/listen/playback/BrowseIdsTest.kt b/app/src/test/java/org/terst/listen/playback/BrowseIdsTest.kt new file mode 100644 index 0000000..132387d --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/BrowseIdsTest.kt @@ -0,0 +1,64 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.terst.listen.data.ShowCategory + +/** + * Pins the browse id format. Written when the format moved out of `LibraryTree`, so these + * characterise what it already did rather than having driven it -- the point of having them is + * the next change, not this one. The format could not be tested at all before the move: + * `LibraryTree` builds Media3 items and only runs on a device. + */ +class BrowseIdsTest { + + @Test + fun anEpisodeIdSurvivesTheRoundTrip() { + assertEquals("ep-1", BrowseIds.episodeId(BrowseIds.forEpisode("ep-1"))) + } + + @Test + fun aSubscriptionIdSurvivesTheRoundTrip() { + assertEquals("sub-1", BrowseIds.subscriptionId(BrowseIds.forSubscription("sub-1"))) + } + + @Test + fun everyCategorySurvivesTheRoundTrip() { + ShowCategory.entries.forEach { category -> + assertEquals(category, BrowseIds.category(BrowseIds.forCategory(category))) + } + } + + @Test + fun everyCarListSurvivesTheRoundTrip() { + CarLists.Node.entries.forEach { node -> + assertEquals(node, BrowseIds.carList(BrowseIds.forCarList(node))) + } + } + + /** + * The reason every id carries a prefix. Episode ids and show ids both come from feeds, so + * the same raw string can genuinely name one of each, and an id read as the wrong kind of + * thing is how the car ends up playing something nobody asked for. + */ + @Test + fun anIdIsOnlyEverReadAsTheKindOfThingItNames() { + val sameRawId = "clash" + + assertNull(BrowseIds.episodeId(BrowseIds.forSubscription(sameRawId))) + assertNull(BrowseIds.subscriptionId(BrowseIds.forEpisode(sameRawId))) + assertNull(BrowseIds.carList(BrowseIds.forEpisode(sameRawId))) + assertNull(BrowseIds.category(BrowseIds.forSubscription(sameRawId))) + } + + @Test + fun theFixedNodesAreNotMistakenForAnythingElse() { + listOf(BrowseIds.ROOT, BrowseIds.QUEUE, BrowseIds.SUBSCRIPTIONS).forEach { id -> + assertNull(BrowseIds.episodeId(id)) + assertNull(BrowseIds.subscriptionId(id)) + assertNull(BrowseIds.carList(id)) + assertNull(BrowseIds.category(id)) + } + } +} diff --git a/app/src/test/java/org/terst/listen/playback/CarCategoriesTest.kt b/app/src/test/java/org/terst/listen/playback/CarCategoriesTest.kt new file mode 100644 index 0000000..b58b25a --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/CarCategoriesTest.kt @@ -0,0 +1,53 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.terst.listen.data.ShowCategory + +class CarCategoriesTest { + + @Test + fun offersAFolderForEveryCategoryThatHasAShow() { + val offered = CarCategories.offered( + listOf(ShowCategory.DEEP, ShowCategory.NEWS, ShowCategory.FICTION) + ) + + assertEquals(listOf(ShowCategory.NEWS, ShowCategory.FICTION, ShowCategory.DEEP), offered) + } + + @Test + fun offersEachCategoryOnceHoweverManyShowsAreInIt() { + val offered = CarCategories.offered( + listOf(ShowCategory.NEWS, ShowCategory.NEWS, ShowCategory.NEWS, ShowCategory.SAILING) + ) + + assertEquals(listOf(ShowCategory.NEWS, ShowCategory.SAILING), offered) + } + + /** + * The order is the categories' own, not the order the shows happened to arrive in: it is the + * same order everywhere else in the app, and a car list that sorts differently from the + * phone reads as unsorted -- which is what was reported about the show list. + */ + @Test + fun keepsTheCategoryOrderRatherThanTheOrderTheShowsCameIn() { + val offered = CarCategories.offered( + listOf(ShowCategory.FICTION, ShowCategory.SAILING, ShowCategory.NEWS) + ) + + assertEquals(listOf(ShowCategory.NEWS, ShowCategory.SAILING, ShowCategory.FICTION), offered) + } + + /** One group is not a grouping; it is a folder that adds a tap and separates nothing. */ + @Test + fun doesNotGroupWhenEveryShowIsInTheSameCategory() { + val offered = CarCategories.offered(listOf(ShowCategory.NEWS, ShowCategory.NEWS)) + + assertEquals(emptyList<ShowCategory>(), offered) + } + + @Test + fun doesNotGroupWhenThereAreNoShowsAtAll() { + assertEquals(emptyList<ShowCategory>(), CarCategories.offered(emptyList())) + } +} diff --git a/app/src/test/java/org/terst/listen/ui/ShowSilenceTest.kt b/app/src/test/java/org/terst/listen/ui/ShowSilenceTest.kt new file mode 100644 index 0000000..b9ec17c --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/ShowSilenceTest.kt @@ -0,0 +1,48 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.concurrent.TimeUnit + +class ShowSilenceTest { + + private val now = 1_800_000_000_000L + + private fun agedDays(days: Long) = now - TimeUnit.DAYS.toMillis(days) + + @Test + fun saysNothingAboutAShowThatIsStillPublishing() { + assertNull(ShowSilence.label(agedDays(30), now)) + } + + /** The line is about a run of silence, so it waits until there is one worth mentioning. */ + @Test + fun saysNothingWhileTheShowIsOnlyBetweenSeasons() { + assertNull(ShowSilence.label(agedDays(300), now)) + } + + @Test + fun namesAYearOfSilenceInWordsRatherThanACount() { + assertEquals("No new episodes in a year", ShowSilence.label(agedDays(400), now)) + } + + @Test + fun countsTheYearsOnceThereAreSeveral() { + assertEquals("No new episodes in 3 years", ShowSilence.label(agedDays(3 * 365 + 10), now)) + } + + @Test + fun saysNothingWhenNothingIsKnownAboutWhenItLastPublished() { + assertNull(ShowSilence.label(null, now)) + } + + /** + * A feed dating an episode in the future would otherwise produce a negative age, and the + * floor of that is a show reported as silent for no years at all. + */ + @Test + fun saysNothingAboutAnEpisodeDatedInTheFuture() { + assertNull(ShowSilence.label(now + TimeUnit.DAYS.toMillis(5), now)) + } +} |
