diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-11-11 16:19:08 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-11-11 16:19:08 +0000 |
| commit | 4d352677070e6e5b0f2b32c85f058a4108a86ac6 (patch) | |
| tree | febf7e5aeb4a3f7de6cd9056c4cd277d9c1782ec /app | |
| parent | e6636c49251785af218f5a44970fa662fc2bdccf (diff) | |
The car groups shows by category, and a silent show says so
Two things, both about finding something rather than playing it.
Android Auto lists category folders under Podcasts instead of forty-five shows
in a row -- a car screen holds six to eight, so anything not near the top was
several flicks away. The categories already existed and already had an order,
so this costs no new data. With only one category present the folders would
separate nothing, so the shows are listed directly and the level disappears.
A show that has not published in a year says so above its drip control:
"No new episodes in 3 years". That is the whole of the special treatment a
finished show gets -- no flag, no automatic behaviour, no guessing that a show
is over. A run of silence is a fact and "finished" is not. A year, because
shorter fires on everything merely between seasons, and a line that appears on
everything says nothing. It costs no extra query: the sheet already reads every
episode for the detection readout.
The browse id format moved out of LibraryTree into BrowseIds, because that
object went past its function limit and the split is along a real line. The
format is the seam that has actually broken -- an id that does not survive the
round trip to the browser is what made Up Next show something other than the
queue -- and it could not be tested off a device before, because LibraryTree
builds Media3 items. It has six tests now, including that an episode id and a
show id carrying the same raw string are never confused for one another.
Verified over the real session protocol on the emulator: the folders appear,
the News folder holds the news show and not the fiction one. 8 browse tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
11 files changed, 389 insertions, 33 deletions
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)) + } +} |
