diff --git a/VERIFICATION-0.82.0.md b/VERIFICATION-0.82.0.md new file mode 100644 index 0000000..b4bfbee --- /dev/null +++ b/VERIFICATION-0.82.0.md @@ -0,0 +1,16 @@ +# Orbit TV 0.82.0: selection reasons (Phase 1) + +Each newly planned programme now keeps a short reason saying which block it came from, whether it was next in turn or a fallback, and which choices were passed over and why. Film channels record how long the film had rested and how many films it was picked from. The hourly `OrbitSchedule` log line now adds `added`, `deferred` and `fallbacks` for the programmes just planned. + +Scheduling is unchanged: +- `PlanBaselineTest` fingerprints nine planning scenarios, taken from 0.81.4 before any change: turns, finish together, day parts, clashes, every choice clashing, shuffle, films, films with clashes, and a top-up. All nine match exactly. +- Choosing a film and the day-part pools go through the same code as before, with the reason worked out alongside. + +Backward compatibility: +- The reason is an optional `w` field on each programme in `schedule-N.json`. Plans written before 0.82.0 load with an empty reason, as the test proves on a file with the field removed. +- 0.81.4 and earlier read named fields only, so they ignore `w`, and rolling back keeps every plan readable. +- Programmes already committed keep no reason and stay exactly as planned. Reasons appear from the next top-up onwards. + +Unit tests: 313 passed, 0 failed. Lint passed. + +On the box: see the install notes below. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 063495e..35d1584 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 95 - versionName = "0.81.4-drive-in-days" + versionCode = 96 + versionName = "0.82.0-selection-reasons" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 2643a38..229c14c 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -106,7 +106,9 @@ internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: /** When each film last started, for its rest period. */ val filmShown: Map = emptyMap()) -internal data class ScheduleEntry(val seriesId: String, val mark: EpisodeMark, val media: ScheduledMedia, val startMs: Long) { +internal data class ScheduleEntry(val seriesId: String, val mark: EpisodeMark, val media: ScheduledMedia, val startMs: Long, + /** Why the planner chose this, for diagnostics only. Empty for programmes planned before reasons were kept. */ + val why: String = "") { val endMs get() = startMs + media.durationMs } @@ -133,19 +135,27 @@ internal fun shuffledEpisodes(series: PoolSeries): List = series.ep * What a programme starting at [startMs] may use, by the local hour then, so the clocks changing moves blocks with the wall clock. * A block with nothing in the pool leaves the whole pool, so a channel never goes off air for lack of a title. */ -internal fun dayPartPool(pool: List, parts: List, startMs: Long, zone: java.time.ZoneId): List { +internal fun dayPartPool(pool: List, parts: List, startMs: Long, zone: java.time.ZoneId): List = + dayPartChoice(pool, parts, startMs, zone).first + +/** [dayPartPool], with a short name for the block it came from, for the reasons kept with each programme. */ +internal fun dayPartChoice(pool: List, parts: List, startMs: Long, zone: java.time.ZoneId): Pair, String> { val local = java.time.Instant.ofEpochMilli(startMs).atZone(zone) // An event with nothing to show gives way to the ordinary block for that time. for (part in parts.filter { it.covers(local.hour, local.dayOfWeek) }) { - if (part.titles.isEmpty() && part.group.isEmpty()) return pool + val name = (if (part.days.isNotEmpty()) "event " else "block ") + part.group.ifEmpty { "${part.fromHour}-${part.toHour}" } + if (part.titles.isEmpty() && part.group.isEmpty()) return pool to name val titles = part.titles.map(::seriesKey).toSet() val allowed = pool.filter { seriesKey(it.title) in titles || (part.group.isNotEmpty() && it.group == part.group) } - if (allowed.isNotEmpty()) return allowed + if (allowed.isNotEmpty()) return allowed to name } // A film saved for an event doesn't appear in everyday programming. - return pool.filter { !it.isFilm || pool.all(PoolSeries::isFilm) }.ifEmpty { pool } + return pool.filter { !it.isFilm || pool.all(PoolSeries::isFilm) }.ifEmpty { pool } to (if (parts.isEmpty()) "whole pool" else "no block, whole pool") } +/** A few names and how many more, so a reason stays short. */ +private fun someOf(names: List) = names.take(3).joinToString(", ") + if (names.size > 3) " +${names.size - 3}" else "" + internal fun turnOrder(pool: List, last: String?): List { val after = pool.indexOfFirst { it.id == last } + 1 return (0 until pool.size).map { pool[(after + it) % pool.size] } @@ -173,11 +183,27 @@ internal fun clashesWith(plans: List): ProgrammeClash { * A film past its rest, chosen by a generator seeded from the start time so a plan can be explained and reproduced. * If the pool is too small for the rest period, the film that has waited longest. */ -internal fun nextFilm(pool: List, shown: Map, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): PoolSeries { - val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }.let { films -> - films.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }.ifEmpty { films } } - return if (rested.isNotEmpty()) rested[java.util.Random(startMs).nextInt(rested.size)] - else pool.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }.ifEmpty { pool }.minBy { shown[it.id] ?: Long.MIN_VALUE } +internal fun nextFilm(pool: List, shown: Map, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): PoolSeries = + chooseFilm(pool, shown, startMs, restMs, avoid).first + +/** [nextFilm], with the reason for its choice. The choice itself is made exactly as before. */ +internal fun chooseFilm(pool: List, shown: Map, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): Pair { + val restedAll = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true } + val clear = restedAll.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) } + val rested = clear.ifEmpty { restedAll } + if (rested.isNotEmpty()) { + val film = rested[java.util.Random(startMs).nextInt(rested.size)] + val waited = shown[film.id]?.let { "last shown ${(startMs - it) / 3_600_000}h ago" } ?: "not shown before" + val deferred = restedAll.size - clear.size + return film to buildString { + append("picked from ${rested.size} rested films, $waited") + if (clear.isEmpty()) append("; fallback: every rested film airs elsewhere then") + else if (deferred > 0) append("; deferred $deferred airing elsewhere") + } + } + val unclashed = pool.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) } + val film = unclashed.ifEmpty { pool }.minBy { shown[it.id] ?: Long.MIN_VALUE } + return film to "fallback: no film past its rest, so the longest waiting" + if (unclashed.isEmpty()) ", even though it airs elsewhere then" else "" } /** Films by their rest period, back to back from [fromMs] until [untilMs] is covered. */ @@ -188,10 +214,11 @@ internal fun extendFilmSchedule(pool: List, cursor: ScheduleCursor, var last = cursor.lastSeriesId var start = fromMs while (start < untilMs) { - val film = nextFilm(dayPartPool(pool, dayParts, start, zone), shown, start, restMs, avoid) + val (available, block) = dayPartChoice(pool, dayParts, start, zone) + val (film, why) = chooseFilm(available, shown, start, restMs, avoid) val copy = film.episodes.first() val item = SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs) - entries += ScheduleEntry(film.id, copy.mark, ScheduledMedia(item, copy.sourceId, copy.durationMs), start) + entries += ScheduleEntry(film.id, copy.mark, ScheduledMedia(item, copy.sourceId, copy.durationMs), start, "$block; $why") shown[film.id] = start last = film.id start += copy.durationMs @@ -216,16 +243,33 @@ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, from return mix[(mix.indexOfFirst { it.mark == marks[show.id] } + 1) % mix.size] } while (start < untilMs) { - val available = dayPartPool(pool, dayParts, start, zone) + val (available, block) = dayPartChoice(pool, dayParts, start, zone) // Shows only wait for others in the same block, or a daytime show could stand idle for an evening one. - if (finishTogether && available.all { marks[it.id] == running(it).last().mark }) available.forEach { marks.remove(it.id) } + val restarted = finishTogether && available.all { marks[it.id] == running(it).last().mark } + if (restarted) available.forEach { marks.remove(it.id) } val allowed = { show: PoolSeries -> show in available && (!finishTogether || marks[show.id] != running(show).last().mark) } // Skips a show already on elsewhere, unless that would leave nothing to show. - val series = turnOrder(pool, last).firstOrNull { allowed(it) && !avoid(it, start, start + following(it).durationMs) } ?: nextInTurn(pool, last, allowed) + val waiting = mutableListOf(); val elsewhere = mutableListOf() + val picked = turnOrder(pool, last).firstOrNull { show -> + when { + !allowed(show) -> { if (show in available) waiting += show.title; false } + avoid(show, start, start + following(show).durationMs) -> { elsewhere += show.title; false } + else -> true + } + } + val series = picked ?: nextInTurn(pool, last, allowed) val episode = following(series) + val why = buildString { + append(block) + append(if (picked != null) "; next in turn" else "; fallback: every show in turn airs elsewhere then") + if (shuffle) append(", episode from its shuffled order") + if (restarted) append("; every show had finished, so all started again") + if (waiting.isNotEmpty()) append("; deferred until the rest finish: ${someOf(waiting)}") + if (elsewhere.isNotEmpty()) append("; deferred, airing elsewhere: ${someOf(elsewhere)}") + } val item = if (series.isFilm) SampleItem(series.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(series.title, episode) val media = ScheduledMedia(item, episode.sourceId, episode.durationMs) - entries += ScheduleEntry(series.id, episode.mark, media, start) + entries += ScheduleEntry(series.id, episode.mark, media, start, why) marks[series.id] = episode.mark last = series.id start += episode.durationMs diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 359b9e6..22d4b2f 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -6,8 +6,10 @@ internal const val POOL_REFRESH_MS = 7 * 24 * 60 * 60_000L /** Only counts and times, so it's safe to log. Never titles, addresses or the account. */ internal class SchedulerReport(val programmes: Int, val episodes: Int, val shows: List, val requests: Int, - val fetchMs: Long, val planMs: Long, val bytes: Long, val fromCache: Boolean, val endMs: Long) { - fun logLine(number: Int) = "channel=$number programmes=$programmes episodes=$episodes shows=${shows.size} requests=$requests fetchMs=$fetchMs planMs=$planMs bytes=$bytes cache=$fromCache" + val fetchMs: Long, val planMs: Long, val bytes: Long, val fromCache: Boolean, val endMs: Long, + /** Of the programmes just added: how many passed over another choice, and how many had to break a rule to fill the slot. */ + val added: Int = 0, val deferred: Int = 0, val fallbacks: Int = 0) { + fun logLine(number: Int) = "channel=$number programmes=$programmes episodes=$episodes shows=${shows.size} requests=$requests fetchMs=$fetchMs planMs=$planMs bytes=$bytes cache=$fromCache added=$added deferred=$deferred fallbacks=$fallbacks" } internal class ChannelScheduler(private val store: ScheduleStore, private val clock: () -> Long = System::currentTimeMillis) { @@ -110,8 +112,10 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl store.saveProgress(userId, number, ScheduleStore.SavedProgress(progress?.enabled ?: true, airedCursor(base, plan.entries, now))) session.rememberScheduledItems(plan.entries.map { it.media.item }) val planMs = android.os.SystemClock.elapsedRealtime() - started + val added = if (plan === existing) emptyList() else plan.entries.filter { it.startMs >= (existing?.endMs ?: Long.MIN_VALUE) } val report = SchedulerReport(plan.entries.size, pool?.series.orEmpty().sumOf { it.episodes.size }, pool?.series.orEmpty().map { it.title }, - requests, fetchMs, planMs, store.sizes(number).values.sum(), requests == 0, plan.endMs) + requests, fetchMs, planMs, store.sizes(number).values.sum(), requests == 0, plan.endMs, + added.size, added.count { "deferred" in it.why }, added.count { "fallback" in it.why }) Log.i("OrbitSchedule", report.logLine(number)) return report } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 2543eee..9dfe716 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.81.4\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.82.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt index b5ad229..049c76d 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -84,12 +84,14 @@ internal class ScheduleStore(private val directory: File) { o.optJSONObject("shown")?.let { shown -> shown.keys().asSequence().associate { id -> validId(id) to shown.getLong(id) } }.orEmpty()) } private fun validId(id: String) = id.also { require(EmbyClient.validId(it)) } + /** Reasons are diagnostics. A long one is cut rather than refused, so it can never cost a plan. */ + private val MAX_REASON_CHARS = 400 private fun validDuration(ms: Long) = ms.also { require(it in 1_000..SampleChannel.MAX_DURATION_MS) } fun savePlan(userId: String, number: Int, plan: ChannelPlan) { val entries = JSONArray() plan.entries.forEach { e -> entries.put(JSONObject().put("s", e.seriesId).put("n", JSONArray().put(e.mark.season).put(e.mark.episode)) - .put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs).also { row -> if (e.media.item.type == "Movie") row.put("k", "Movie") }) } + .put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs).also { row -> if (e.media.item.type == "Movie") row.put("k", "Movie"); if (e.why.isNotEmpty()) row.put("w", e.why) }) } write(file("schedule", number), root(userId, number).put("tail", encodeCursor(plan.tail)).put("entries", entries)) } fun loadPlan(userId: String, number: Int): ChannelPlan? = read(file("schedule", number), userId, number) { root -> @@ -99,7 +101,7 @@ internal class ScheduleStore(private val directory: File) { val duration = validDuration(o.getLong("d")) val mark = o.getJSONArray("n").let { EpisodeMark(it.getInt(0), it.getInt(1)) } val item = SampleItem(o.getString("t"), o.optString("k", "Episode").also { require(it in listOf("Episode", "Movie")) }, duration / 60_000, "", validId(o.getString("i")), duration) - ScheduleEntry(validId(o.getString("s")), mark, ScheduledMedia(item, validId(o.getString("c")), duration), o.getLong("a")) + ScheduleEntry(validId(o.getString("s")), mark, ScheduledMedia(item, validId(o.getString("c")), duration), o.getLong("a"), o.optString("w").take(MAX_REASON_CHARS)) } require(entries.zipWithNext().all { (first, second) -> first.endMs <= second.startMs }) ChannelPlan(entries, decodeCursor(root.getJSONObject("tail"))) diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 63b933b..7758e98 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.81.4") + next.setRequestProperty("User-Agent", "OrbitTV/0.82.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt b/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt new file mode 100644 index 0000000..6b0f7d7 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt @@ -0,0 +1,81 @@ +package tv.orbit.classic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneId + +/** + * Plans made before selection reasons existed, reduced to a fingerprint. Reasons must never change what gets planned, + * so these stay fixed: if one changes, scheduling behaviour has changed. + */ +class PlanBaselineTest { + private val minute = 60_000L + private val hour = 60 * minute + private val start = 1_793_700_000_000L + private val zone = ZoneId.of("Europe/London") + + private fun show(id: String, seasons: Int, perSeason: Int, minutes: Long, group: String = "") = PoolSeries(id, "Show $id", (1..seasons).flatMap { season -> + (1..perSeason).map { n -> PoolEpisode("$id-$season-$n", "src-$id-$season-$n", EpisodeMark(season, n), "E$n", (minutes + (n % 3)) * minute) } + }, group) + private fun film(id: String, minutes: Long) = PoolSeries(id, "Film $id", listOf(PoolEpisode(id, "src-$id", EpisodeMark(1, 1), "Film $id", minutes * minute))) + + private val shows = listOf(show("a", 2, 7, 22, "day"), show("b", 1, 5, 44, "day"), show("c", 3, 9, 11, "night"), show("d", 2, 4, 30, "night"), show("e", 1, 3, 25)) + private val films = (1..18).map { film("f$it", 85L + it * 3) } + private val parts = listOf(DayPart(20, 22, group = "movie", days = setOf(java.time.DayOfWeek.FRIDAY)), DayPart(6, 18, group = "day"), DayPart(18, 6, group = "night")) + // Another channel's plan, so the clash check has something to avoid. + private val other = extendSchedule(listOf(show("a", 2, 7, 22), show("c", 3, 9, 11)), ScheduleCursor(), start + 3 * hour, start + 2 * 24 * hour) + private val avoid = clashesWith(listOf(other)) + + private fun fingerprint(plan: ChannelPlan?): String { + val text = plan!!.entries.joinToString("|") { "${it.seriesId}:${it.mark.season}.${it.mark.episode}@${(it.startMs - start) / 1000}" } + "#" + plan.tail.lastSeriesId + plan.tail.marks.toSortedMap() + plan.tail.filmShown.toSortedMap() + return java.security.MessageDigest.getInstance("SHA-256").digest(text.toByteArray()).joinToString("") { "%02x".format(it) }.take(16) + } + + private val day = 24 * hour + private fun plans() = linkedMapOf( + "turns" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day), + "finishTogether" to extendSchedule(shows, ScheduleCursor(), start, start + 4 * day, finishTogether = true), + "dayParts" to extendSchedule(shows, ScheduleCursor(), start, start + 4 * day, finishTogether = true, dayParts = parts, zone = zone), + "clash" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid), + "everythingClashes" to extendSchedule(listOf(show("a", 2, 7, 22)), ScheduleCursor(), start, start + day, avoid = avoid), + "shuffle" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid, shuffle = true), + "films" to extendSchedule(films, ScheduleCursor(), start, start + 5 * day, filmRestMs = 2 * day), + "filmsClash" to extendSchedule(films + listOf(PoolSeries("a", "Show a", listOf(PoolEpisode("a", "src-a", EpisodeMark(1, 1), "Show a", 90 * minute)))), ScheduleCursor(), start, start + 3 * day, + filmRestMs = day, dayParts = parts, zone = zone, avoid = avoid), + "topUp" to topUpPlan(extendSchedule(shows, ScheduleCursor(), start, start + 2 * day), ScheduleCursor(), shows, start + day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid), + ) + + @Test fun everyNewProgrammeSaysWhy() { + plans().values.forEach { plan -> assertTrue(plan!!.entries.all { it.why.isNotBlank() }) } + val shows = plans().getValue("clash")!!.entries + assertTrue(shows.any { "deferred, airing elsewhere" in it.why }) + assertTrue(shows.all { it.why.startsWith("block ") || it.why.startsWith("event ") }) + } + + @Test fun fallbacksAreNamed() { + assertTrue(plans().getValue("everythingClashes")!!.entries.any { "fallback: every show in turn airs elsewhere" in it.why }) + assertTrue(plans().getValue("films")!!.entries.all { "rested" in it.why || "fallback" in it.why }) + } + + @Test fun reasonsSurviveASaveAndOldPlansStillRead() { + val folder = java.nio.file.Files.createTempDirectory("plans").toFile() + val store = ScheduleStore(folder) + val plan = plans().getValue("dayParts")!! + store.savePlan("u", 601, plan) + assertEquals(plan, store.loadPlan("u", 601)) + // A file written before reasons existed: the same plan without the field. + val file = folder.listFiles()!!.first { it.name == "schedule-601.json" } + file.writeText(file.readText().replace(Regex(",\\\"w\\\":\\\"(?:[^\\\"\\\\]|\\\\.)*\\\""), "")) + assertTrue("w" !in file.readText().let { org.json.JSONObject(it).getJSONArray("entries").getJSONObject(0).keys().asSequence().toList() }) + assertEquals(plan.entries.map { it.copy(why = "") }, store.loadPlan("u", 601)!!.entries) + folder.deleteRecursively() + } + + @Test fun planningIsUnchanged() { + // Taken from 0.81.4, before reasons were recorded. + val expected = mapOf("turns" to "5cc4eee2cbfa29e6", "finishTogether" to "c357d2fae0d6a5df", "dayParts" to "764b7b7ac15a5b26", "clash" to "8d3c92c8c4beed8e", + "everythingClashes" to "9f38ee7d889140c5", "shuffle" to "cee9b485bc705af2", "films" to "2f4f2786280d4589", "filmsClash" to "7fe17b8f3746e164", "topUp" to "40ce5b5f8e0e8ed3") + assertEquals(expected, plans().mapValues { fingerprint(it.value) }) + } +}