diff options
Diffstat (limited to 'app/src')
9 files changed, 491 insertions, 5 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt b/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt new file mode 100644 index 0000000..76e1177 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt @@ -0,0 +1,184 @@ +package org.terst.listen.data + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.terst.listen.playback.CarLists + +/** + * Android Auto's three lists against the real tables. + * + * The conditions that decide what a driver is offered live in SQL -- they have to, because + * "unplayed across every show" is thousands of rows on a real library -- so this is where they + * are checked. The unit tests cover which lists appear; these cover what is in them. + */ +@RunWith(AndroidJUnit4::class) +class CarListsDbTest { + + private lateinit var db: AppDatabase + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + } + + @After + fun tearDown() = db.close() + + private suspend fun show( + id: String, + bedtime: Boolean = false, + drip: Boolean = false, + ) = db.subscriptions().insert( + SubscriptionEntity( + id = id, feedUrl = "https://x/$id", title = "Show $id", addedAtMs = 0, + bedtime = bedtime, dripEnabled = drip, + ) + ) + + private suspend fun episode( + id: String, + showId: String = "s1", + positionMs: Long = 0, + playedAtMs: Long? = null, + durationMs: Long? = MINUTES_10, + actualDurationMs: Long? = null, + publishedAtMs: Long? = 1_000, + dripReleasedAtMs: Long? = null, + ) = db.episodes().insertAll( + listOf( + EpisodeEntity( + id = id, subscriptionId = showId, guid = id, title = "Episode $id", + mediaUrl = "https://x/$id.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, + positionMs = positionMs, playedAtMs = playedAtMs, durationMs = durationMs, + actualDurationMs = actualDurationMs, publishedAtMs = publishedAtMs, + dripReleasedAtMs = dripReleasedAtMs, + ) + ) + ) + + private suspend fun ids(of: List<EpisodeEntity>) = of.map { it.id } + + @Test + fun continueHoldsWhatWasStartedAndNotFinished() = runBlocking { + show("s1") + episode("started", positionMs = 30_000) + episode("untouched") + episode("finished", positionMs = 30_000, playedAtMs = 5_000) + + assertEquals(listOf("started"), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST))) + } + + /** + * The episode you are in the middle of is normally the head of the queue, so the exclusion + * the other two lists apply would empty this one in the ordinary case. + */ + @Test + fun continueStillOffersAnEpisodeThatIsAlsoQueued() = runBlocking { + show("s1") + episode("started", positionMs = 30_000) + db.queue().upsertAll(listOf(QueueEntity("started", 0))) + + assertEquals(listOf("started"), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST))) + } + + /** + * The case the two duration columns exist for: Radiolab declares 22:05 and serves 28:18, so + * trusting the feed would put a half-hour episode in a list promising twenty minutes. + */ + @Test + fun shortBelievesTheMeasuredLengthOverTheFeedsClaim() = runBlocking { + show("s1") + episode("overrun", durationMs = MINUTES_19, actualDurationMs = MINUTES_28) + episode("honest", durationMs = MINUTES_19, actualDurationMs = MINUTES_19) + + val short = db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST) + + assertEquals(listOf("honest"), ids(short)) + } + + @Test + fun shortLeavesOutAnEpisodeOfUnknownLength() = runBlocking { + show("s1") + episode("nameless", durationMs = null) + episode("known", durationMs = MINUTES_10) + + assertEquals( + listOf("known"), + ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)), + ) + } + + @Test + fun shortAndNewLeaveOutAnythingAlreadyStarted() = runBlocking { + show("s1") + episode("started", positionMs = 30_000) + episode("fresh") + + assertEquals( + listOf("fresh"), + ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)), + ) + assertEquals(listOf("fresh"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + @Test + fun newLeavesOutWhatIsAlreadyQueued() = runBlocking { + show("s1") + episode("queued") + episode("loose") + db.queue().upsertAll(listOf(QueueEntity("queued", 0))) + + assertEquals(listOf("loose"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + /** Driving is the opposite of falling asleep, which is why the car hides bedtime shows. */ + @Test + fun noListOffersABedtimeShow() = runBlocking { + show("bed", bedtime = true) + episode("sleepy", showId = "bed", positionMs = 30_000) + episode("alsoSleepy", showId = "bed") + + assertEquals(emptyList<String>(), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST))) + assertEquals( + emptyList<String>(), + ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)), + ) + assertEquals(emptyList<String>(), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + @Test + fun aDrippingShowOffersOnlyWhatDripHasReleased() = runBlocking { + show("drip", drip = true) + episode("released", showId = "drip", dripReleasedAtMs = 500) + episode("held", showId = "drip", dripReleasedAtMs = null) + + assertEquals(listOf("released"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + @Test + fun newIsNewestFirstAndCapped() = runBlocking { + show("s1") + episode("old", publishedAtMs = 1_000) + episode("newest", publishedAtMs = 3_000) + episode("middle", publishedAtMs = 2_000) + + assertEquals(listOf("newest", "middle", "old"), ids(db.carLists().getNewest(10))) + assertEquals(listOf("newest"), ids(db.carLists().getNewest(1))) + } + + private companion object { + const val MINUTES_10 = 10 * 60 * 1000L + const val MINUTES_19 = 19 * 60 * 1000L + const val MINUTES_28 = 28 * 60 * 1000L + } +} 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 f452c9f..61ffe6a 100644 --- a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt +++ b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt @@ -172,6 +172,47 @@ class MediaBrowseTest { } } + /** + * The root offers "Continue", and it holds the part-heard episode. + * + * Peter's report was that discovery in the car was bad: browsing by show means remembering + * what you were doing and then finding it, which is not a thing to do at a traffic light. + * This walks the real protocol rather than calling the callback, because the root is + * assembled from three queries and a browse id has to survive the round trip -- the same + * seam that made "Up Next" show something other than the queue. + */ + @Test + fun theRootOffersContinueAndItHoldsThePartHeardEpisode() = runBlocking { + val graph = Graph.get(context) + graph.subscriptions.insert(seedShow("browse-cont", "Continue Hour")) + graph.episodes.insertAll( + listOf(seedEpisode("browse-cont-ep", "browse-cont", 1_000).copy(positionMs = 42_000)) + ) + + val browser = connect() + try { + withContext(Dispatchers.Main) { + val continueId = LibraryTree.carListId(CarLists.Node.CONTINUE) + val topIds = browser.getChildren(LibraryTree.ROOT, 0, 20, null) + .await().value!!.map { it.mediaId } + assertTrue( + "root should offer Continue, got $topIds", + topIds.contains(continueId), + ) + + val held = browser.getChildren(continueId, 0, CarLists.MAX_PER_LIST, null) + .await().value!!.map { it.mediaId } + assertTrue( + "Continue should hold the part-heard episode, got $held", + held.contains(LibraryTree.episode(seedEpisode("browse-cont-ep", "browse-cont", 1_000)).mediaId), + ) + } + } finally { + withContext(Dispatchers.Main) { browser.release() } + graph.subscriptions.delete("browse-cont") + } + } + 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/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index 0ba2e82..5c6f460 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -41,6 +41,8 @@ class Converters { abstract class AppDatabase : RoomDatabase() { abstract fun subscriptions(): SubscriptionDao abstract fun episodes(): EpisodeDao + abstract fun carLists(): CarListDao + abstract fun playbackState(): PlaybackStateDao abstract fun queue(): QueueDao abstract fun rules(): RuleDao diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index 2dda8de..b6c82bd 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -478,6 +478,90 @@ interface MidrollDao { * collaborator, [org.terst.listen.playback.PositionWriter], on a timer, and they are the only * writes in the app whose loss is not recoverable from anywhere else. */ +/** + * The cross-show reads that build Android Auto's three lists, and nothing else. + * + * Its own interface rather than three more methods on [EpisodeDao], which is already the widest + * one in the app: these are read-only, they all cross every show rather than belonging to one, + * and they exist for a single surface. Narrow by the same reasoning as [PlaybackStateDao]. + * + * All three share the conditions the phone's library calls "waiting" -- unplayed, released if + * the show drips, and never a bedtime show, because driving is the opposite of falling asleep. + */ +@Dao +interface CarListDao { + + /** + * Part-heard episodes, for the car's "Continue". + * + * `positionMs > 0` is the whole condition: the app has no last-played timestamp, so what + * makes an episode interesting here is that it was started and not finished, and the order + * has to come from the publish date like everything else. That is a real gap rather than a + * choice -- the ideal order is most-recently-listened -- but it costs a column and a + * migration, and with a handful of part-heard episodes the order barely matters. + * + * Queued episodes are deliberately *not* excluded here, unlike the three lists below and + * the phone's library: the episode you are in the middle of is normally the head of the + * queue, so excluding it would empty this list in the ordinary case. + */ + @Query( + """SELECT ep.* FROM episodes ep + JOIN subscriptions owner ON owner.id = ep.subscriptionId + WHERE ep.playedAtMs IS NULL + AND ep.positionMs > 0 + AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL) + AND owner.bedtime = 0 + ORDER BY ep.publishedAtMs DESC + LIMIT :limit""" + ) + suspend fun getContinuing(limit: Int): List<EpisodeEntity> + + /** + * Unstarted episodes short enough for a short drive, for the car's "Short". + * + * `COALESCE(actualDurationMs, durationMs)` is `playbackDurationMs` in SQL, and the + * preference matters more here than almost anywhere: a feed declaring 22:05 for a file that + * runs 28:18 would put a half-hour episode in a list promising twenty minutes. An episode + * whose length is unknown is left out -- unknown is not short, and guessing wrong is the + * failure this list exists to avoid. + */ + @Query( + """SELECT ep.* FROM episodes ep + JOIN subscriptions owner ON owner.id = ep.subscriptionId + WHERE ep.playedAtMs IS NULL + AND ep.positionMs = 0 + AND ep.id NOT IN (SELECT episodeId FROM queue) + AND COALESCE(ep.actualDurationMs, ep.durationMs) IS NOT NULL + AND COALESCE(ep.actualDurationMs, ep.durationMs) <= :maxDurationMs + AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL) + AND owner.bedtime = 0 + ORDER BY ep.publishedAtMs DESC + LIMIT :limit""" + ) + suspend fun getShort(maxDurationMs: Long, limit: Int): List<EpisodeEntity> + + /** + * The newest unstarted episodes across every show, for the car's "New". + * + * The same set the phone's library counts as waiting -- unplayed, unqueued, released if the + * show drips -- flattened across shows instead of grouped by one. Anything started is left + * to `getContinuing`, which is what keeps the two lists from offering the same episode + * under two names. + */ + @Query( + """SELECT ep.* FROM episodes ep + JOIN subscriptions owner ON owner.id = ep.subscriptionId + WHERE ep.playedAtMs IS NULL + AND ep.positionMs = 0 + AND ep.id NOT IN (SELECT episodeId FROM queue) + AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL) + AND owner.bedtime = 0 + ORDER BY ep.publishedAtMs DESC + LIMIT :limit""" + ) + suspend fun getNewest(limit: Int): List<EpisodeEntity> +} + @Dao interface PlaybackStateDao { @Query("UPDATE episodes SET positionMs = :positionMs WHERE id = :id") diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index e4a35f0..f08c484 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -104,6 +104,11 @@ class AppGraph(private val context: Context) : UiGraph { override val subscriptions: SubscriptionDao get() = db.subscriptions() override val episodes: EpisodeDao get() = db.episodes() + /** + * Deliberately not on [UiGraph]: the three cross-show lists exist for Android Auto, and no + * view model has any use for them. + */ + val carLists: CarListDao get() = db.carLists() override val playbackState: PlaybackStateDao get() = db.playbackState() val queueDao: QueueDao get() = db.queue() override val ruleDao: RuleDao get() = db.rules() diff --git a/app/src/main/java/org/terst/listen/playback/CarLists.kt b/app/src/main/java/org/terst/listen/playback/CarLists.kt new file mode 100644 index 0000000..6e01be4 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/CarLists.kt @@ -0,0 +1,57 @@ +package org.terst.listen.playback + +import java.util.concurrent.TimeUnit + +/** + * The three lists the car offers above the shows. + * + * Browsing by show is wrong for driving: Peter's report was "discovery sucks" and "scrolling + * the lists in the car is bad experience", and the cause is that a show list makes you remember + * what you were doing and then find it. These three answer the question actually being asked at + * a traffic light -- what was I in the middle of, what fits this drive, what is new -- from data + * the app already keeps, with no new column and no migration. + * + * They are deliberately disjoint from each other on one axis: anything started appears in + * [CONTINUE] and nowhere else, so finishing something is never buried under a newer arrival. + */ +object CarLists { + + /** + * The longest episode [SHORT] will offer, 20 minutes. + * + * Peter's call, asked rather than guessed: it catches the daily news and the shorter + * curiosity episodes and leaves out conversation and deep-dive shows. The number is a + * judgement about his driving, not about the data, which is why it is pinned by a test -- + * changing it silently changes what the car offers. + */ + val SHORT_MAX_MS: Long = TimeUnit.MINUTES.toMillis(20) + + /** + * How many episodes any one of these lists will hold. + * + * Smaller than the per-show cap by an order of magnitude, and for the opposite reason: that + * one exists so the browser protocol is not handed 2,753 rows, this one exists because a + * list you have to scroll in a car has already failed. Peter measured the screen at 6-8 + * rows with icons, so twenty is about three flicks -- enough that the list is not obviously + * truncated, few enough that the answer is near the top. + */ + const val MAX_PER_LIST = 20 + + /** One of the three lists, in the order the car shows them. */ + enum class Node(val wire: String, val label: String) { + CONTINUE("continue", "Continue"), + SHORT("short", "Short"), + NEW("new", "New"), + } + + /** + * Which of the lists to put on the root, given how many episodes each holds. + * + * An empty list is left off entirely rather than shown and found empty. Tapping a folder in + * a car costs a glance away from the road, and spending that to be told "nothing here" is + * the same dead end as a show row with nothing waiting -- which the phone's library already + * dims for the same reason. + */ + fun offered(counts: Map<Node, Int>): List<Node> = + Node.entries.filter { (counts[it] ?: 0) > 0 } +} 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 371dd1c..d4acdf2 100644 --- a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt +++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt @@ -22,11 +22,19 @@ object LibraryTree { 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:" 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) } @@ -42,10 +50,18 @@ object LibraryTree { fun episodeRef(episodeId: String): MediaItem = MediaItem.Builder().setMediaId(EPISODE_PREFIX + episodeId).build() - fun topLevel(): List<MediaItem> = listOf( - browsable(QUEUE, "Up Next"), - browsable(SUBSCRIPTIONS, "Podcasts"), - ) + /** + * The root, with whichever of the car lists currently hold something. + * + * Up Next stays first -- in a car it is nearly always what you want, and it is the one list + * the listener curated rather than the app inferred. [CarLists.offered] decides which of + * Continue, Short and New appear at all, so the root never offers a folder that turns out + * to be empty. + */ + fun topLevel(lists: List<CarLists.Node> = emptyList()): List<MediaItem> = + listOf(browsable(QUEUE, "Up Next")) + + lists.map { browsable(carListId(it), it.label) } + + browsable(SUBSCRIPTIONS, "Podcasts") fun subscription(entity: SubscriptionEntity): MediaItem = browsable(SUBSCRIPTION_PREFIX + entity.id, entity.title, entity.author, entity.imageUrl) 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 7a02ba6..e9e8ca7 100644 --- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt +++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.guava.await 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.server.ServerAnswer @@ -112,7 +113,7 @@ class ListenSessionCallback( params: MediaLibraryService.LibraryParams?, ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future { val children = when { - parentId == LibraryTree.ROOT -> LibraryTree.topLevel() + parentId == LibraryTree.ROOT -> LibraryTree.topLevel(offeredCarLists()) parentId == LibraryTree.QUEUE -> graph.queueDao.getEntries().mapNotNull { entry -> @@ -131,6 +132,9 @@ class ListenSessionCallback( graph.subscriptions.observeAll(bedtime = false).first() .map { LibraryTree.subscription(it.subscription) } + LibraryTree.carList(parentId) != null -> + withShows(carListEpisodes(LibraryTree.carList(parentId)!!)) + LibraryTree.subscriptionId(parentId) != null -> { val id = LibraryTree.subscriptionId(parentId)!! val show = graph.subscriptions.getById(id) @@ -313,6 +317,46 @@ class ListenSessionCallback( return (showHits.map(LibraryTree::subscription) + episodeHits).take(MAX_SEARCH_RESULTS) } + /** + * The episodes one of the car's lists holds, newest first and capped. + * + * Each list is its own query rather than one broad fetch filtered here: unplayed across + * every show is thousands of rows on a real library -- one feed in testing produced 2,753 + * on a single refresh -- and all three of these want a different slice of it. + */ + private suspend fun carListEpisodes(node: CarLists.Node) = when (node) { + CarLists.Node.CONTINUE -> graph.carLists.getContinuing(CarLists.MAX_PER_LIST) + CarLists.Node.SHORT -> graph.carLists.getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST) + CarLists.Node.NEW -> graph.carLists.getNewest(CarLists.MAX_PER_LIST) + } + + /** + * Which car lists the root should offer, by asking each one what it holds. + * + * The counts come from the lists themselves rather than from three `COUNT` queries, so the + * root can never advertise a list that turns out to be empty when opened -- the two would + * otherwise be free to drift apart as the conditions change. Each is capped at twenty rows, + * so this is three small queries on a browse of the root. + */ + private suspend fun offeredCarLists(): List<CarLists.Node> = CarLists.offered( + CarLists.Node.entries.associateWith { carListEpisodes(it).size } + ) + + /** + * Episodes as browsable items, each carrying its show's name and artwork. + * + * One pass over the subscriptions rather than a lookup per episode: these lists cross every + * show by definition, so the per-episode query the queue gets away with would be twenty + * round trips here. + */ + private suspend fun withShows(episodes: List<EpisodeEntity>): List<MediaItem> { + val shows = graph.subscriptions.getAll().associateBy { it.id } + return episodes.map { episode -> + val show = shows[episode.subscriptionId] + LibraryTree.episode(episode, show?.title, show?.imageUrl) + } + } + private companion object { const val MAX_EPISODES = 100 const val MAX_EPISODE_HITS_PER_SHOW = 5 diff --git a/app/src/test/java/org/terst/listen/playback/CarListsTest.kt b/app/src/test/java/org/terst/listen/playback/CarListsTest.kt new file mode 100644 index 0000000..f64b334 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/CarListsTest.kt @@ -0,0 +1,53 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.terst.listen.playback.CarLists.Node + +class CarListsTest { + + @Test + fun offersEveryListThatHasSomethingInIt() { + val offered = CarLists.offered(mapOf(Node.CONTINUE to 2, Node.SHORT to 7, Node.NEW to 31)) + + assertEquals(listOf(Node.CONTINUE, Node.SHORT, Node.NEW), offered) + } + + @Test + fun leavesOutAnEmptyList() { + val offered = CarLists.offered(mapOf(Node.CONTINUE to 0, Node.SHORT to 3, Node.NEW to 5)) + + assertEquals(listOf(Node.SHORT, Node.NEW), offered) + } + + @Test + fun treatsAnAbsentCountAsEmpty() { + val offered = CarLists.offered(mapOf(Node.NEW to 4)) + + assertEquals(listOf(Node.NEW), offered) + } + + @Test + fun offersNothingWhenEveryListIsEmpty() { + val offered = CarLists.offered(mapOf(Node.CONTINUE to 0, Node.SHORT to 0, Node.NEW to 0)) + + assertEquals(emptyList<Node>(), offered) + } + + @Test + fun keepsTheOrderTheCarShowsRatherThanTheCallersOrder() { + val offered = CarLists.offered(mapOf(Node.NEW to 1, Node.CONTINUE to 1, Node.SHORT to 1)) + + assertEquals(listOf(Node.CONTINUE, Node.SHORT, Node.NEW), offered) + } + + /** + * Pins the cutoff Peter chose. Not a tautology: it is the one number here that is a + * judgement about his driving rather than about the data, and moving it changes what the + * car offers on a short trip without changing anything that would otherwise fail. + */ + @Test + fun offersShortEpisodesUpToTwentyMinutes() { + assertEquals(20 * 60 * 1000L, CarLists.SHORT_MAX_MS) + } +} |
