summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt55
-rw-r--r--app/src/main/java/org/terst/listen/playback/BrowseIds.kt48
-rw-r--r--app/src/main/java/org/terst/listen/playback/CarCategories.kt26
-rw-r--r--app/src/main/java/org/terst/listen/playback/LibraryTree.kt33
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt39
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt7
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt14
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSilence.kt35
-rw-r--r--app/src/test/java/org/terst/listen/playback/BrowseIdsTest.kt64
-rw-r--r--app/src/test/java/org/terst/listen/playback/CarCategoriesTest.kt53
-rw-r--r--app/src/test/java/org/terst/listen/ui/ShowSilenceTest.kt48
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))
+ }
+}