From 995f2e2bdbbdcf9fa8fc7b79e3bd08cb26fe3bec Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 19:00:27 +0000 Subject: [PATCH] Drive-In event ideas, phase 2: Approve, Defer and Dismiss Approved by Paul on 5 November 2026. The idea pop-up in the Director's Chair now offers Approve, Defer and Dismiss. Approve keeps each film's identity (title, year, IMDb ID, library item) in the history and turns the idea into a Drive-In event booked exactly like the ten curated ones: only once the Drive-In's plan reaches its evening, only appending, and only if every film passes the box's checks again then. Defer brings an idea back after four weeks; Dismiss drops it unless its films change. Approved events count for editorial competition and evenings like the curated ones. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/ChannelScheduler.kt | 7 ++- .../java/tv/orbit/classic/DirectorChair.kt | 31 +++++++--- .../java/tv/orbit/classic/DirectorPremiere.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 11 +++- .../main/java/tv/orbit/classic/EventIdeas.kt | 59 ++++++++++++++++++- .../java/tv/orbit/classic/EventIdeasTest.kt | 29 +++++++++ 6 files changed, 124 insertions(+), 15 deletions(-) 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 04ab18c..2e0ec4b 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -140,7 +140,8 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl val studios = EventStudios.associateWith { catalogue.filmsByStudio(it.first) } val read = catalogue.ideaFilms(lines.keys).map(::enrich) val held = seasonalFilms().map { it.first.itemId } + ApprovedDebuts.map { it.film.itemId } - val eventFilms = DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held + ApprovedDriveInEvents.refresh(historyFile) + val eventFilms = allDriveInEvents().flatMap { e -> e.films.map { it.itemId } }.toSet() + held val seeds = ideaSeeds(collections, studios, read, eventFilms) var films = catalogue.ideaFilms(seeds.flatMap { it.members }).associateBy { it.id }.mapValues { enrich(it.value) } // Evidence for the candidates that have none yet, read only and bounded: a theme can't be judged without its ratings. @@ -188,7 +189,9 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl val failed = store.loadFailed(userId) fun films(numbers: List) = numbers.flatMap { number -> withoutFailed(store.loadPool(userId, number)?.series.orEmpty().filter { it.isFilm }, failed) }.distinctBy { seriesKey(it.title) } val avoid = clashesWith(SmartChannels.mapNotNull { store.loadPlan(userId, it.number) }) - val events = DriveInEvents.filter { !it.evening.isBefore(java.time.Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()).toLocalDate().minusDays(1)) } + // The curated events and the ideas you've approved (Drive-In event ideas, phase 2), booked alike. + ApprovedDriveInEvents.refresh(store.eventIdeaHistoryFile()) + val events = allDriveInEvents().filter { !it.evening.isBefore(java.time.Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()).toLocalDate().minusDays(1)) } val eventFilms = withoutFailed(eventFilms(userId, session, events, now), failed).associateBy { it.id } val plan = planDriveIn(existing, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, avoid = avoid, events = events, eventFilms = eventFilms, note = { Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER $it") }, alsoHeld = seasonalHeld()) ?: return existing diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt index 6b97b1b..53b2923 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -53,24 +53,28 @@ internal class DeskAsk(val title: String, val first: String, val second: String, val options: List = emptyList(), val line: String = "", val firstRun: DeskOption? = null) /** A Drive-In event idea as the Chair shows it (phase 1, read only): the occasion, its evening, its films and why. */ -internal class DeskEventIdea(val theme: String, val format: String, val evening: String, val films: List, val reasons: List) { +internal class DeskEventIdea(val theme: String, val format: String, val evening: String, val films: List, val reasons: List, + val signature: String = "", val decision: String = "proposed", val filmIds: List = emptyList()) { fun toJson(): JSONObject = JSONObject().put("t", theme).put("f", format).put("e", evening).put("m", JSONArray(films)).put("r", JSONArray(reasons)) + .put("s", signature).put("d", decision).put("i", JSONArray(filmIds)) companion object { fun of(o: JSONObject): DeskEventIdea { fun strings(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty() - return DeskEventIdea(o.optString("t"), o.optString("f"), o.optString("e"), strings("m"), strings("r")) + return DeskEventIdea(o.optString("t"), o.optString("f"), o.optString("e"), strings("m"), strings("r"), o.optString("s"), o.optString("d", "proposed"), strings("i")) } /** From the ideas file the box keeps: this week's ideas, with each film's year and viewer rating. */ fun fromFile(file: File): List = runCatching { val ideas = JSONObject(file.readText()).getJSONArray("ideas") - (0 until ideas.length()).map { ideas.getJSONObject(it) }.map { o -> + // Deferred and dismissed ideas leave the Chair; approved ones stay, marked, until their evening has passed. + (0 until ideas.length()).map { ideas.getJSONObject(it) }.filter { it.optString("decision") in setOf("proposed", "approved") }.map { o -> val details = o.optJSONArray("details")?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty() val evening = o.optString("evening").takeIf { it.isNotEmpty() && it != "null" }?.let { java.time.LocalDateTime.parse(it) } DeskEventIdea(o.optString("theme"), IdeaFormat.valueOf(o.optString("format")).label, evening?.let { "%s %d %s, %02d:%02d".format(it.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.dayOfMonth, it.month.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.hour, it.minute) } ?: "no free evening yet", details.map { d -> d.optString("title") + (if (d.isNull("year")) "" else " (${d.optInt("year")})") + (if (d.isNull("community")) "" else ", rated %.1f".format(d.optDouble("community"))) }, - o.optJSONArray("reasons")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()) + o.optJSONArray("reasons")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty(), o.optString("signature"), o.optString("decision"), + o.optJSONArray("films")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()) } }.getOrDefault(emptyList()) } @@ -389,7 +393,9 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ /** Your learning switch: saved, then the report is worked out again. */ setLearning: suspend (Boolean) -> Unit = {}, /** Confirms (true) or dismisses (false) a proposed lesson by its key. */ - decideLesson: suspend (String, Boolean) -> Unit = { _, _ -> }) { + decideLesson: suspend (String, Boolean) -> Unit = { _, _ -> }, + /** Your decision on a Drive-In event idea: approved, deferred or dismissed. Returns what to tell you. */ + decideIdea: suspend (DeskEventIdea, String) -> String = { _, _ -> "" }) { var mode by remember { mutableStateOf(DirectorMode.Balanced) } var version by remember { mutableIntStateOf(0) } var report by remember { mutableStateOf(null) } @@ -452,7 +458,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ } // Drive-In event ideas (phase 1, read only): at most two a week, none when nothing is worthwhile. r.eventIdeas.forEach { idea -> - ChairCard("DRIVE-IN EVENT IDEA", idea.theme, idea.format.uppercase(java.util.Locale.UK), idea.evening, onClick = { eventOpen = idea }) + ChairCard(if (idea.decision == "approved") "DRIVE-IN EVENT · APPROVED" else "DRIVE-IN EVENT IDEA", idea.theme, idea.format.uppercase(java.util.Locale.UK), idea.evening, onClick = { eventOpen = idea }) } if (r.premiereApproved.isNotEmpty()) Text("Approved for Premiere, waiting for booking: ${r.premiereApproved.joinToString(", ")}", color = ChairMint, fontSize = 10.sp) if (r.premiereQueued > 0) Text("${r.premiereQueued} recent major films still being read for Premiere", color = Muted, fontSize = 10.sp) @@ -493,9 +499,18 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ Text("${idea.format} · ${idea.evening}", color = ChairSky, fontSize = 13.sp) idea.films.forEachIndexed { i, f -> Text("${i + 1}. $f", color = Color.White, fontSize = 13.sp) } idea.reasons.forEach { Text("· $it", color = Muted, fontSize = 11.sp) } - Text("Checked on the box: every film is the right library item with a copy this box can play, rested, not booked elsewhere around the evening, and nothing competes for the night. Read only for now: nothing is booked.", + Text(if (idea.decision == "approved") "Approved: it books like the Drive-In's other events once the Drive-In plans that evening (about a week ahead), after every film is checked on the box again. Nothing already booked changes." + else "Checked on the box: every film is the right library item with a copy this box can play, rested, not booked elsewhere around the evening, and nothing competes for the night. Approve books it like the Drive-In's other events; Defer brings it back in four weeks; Dismiss drops it.", color = Muted, fontSize = 10.sp) - OrbitButton("Close", Modifier.focusRequester(first), onClick = { eventOpen = null }) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + fun decide(decision: String) { eventOpen = null; scope.launch { said = decideIdea(idea, decision); version++ } } + if (idea.decision != "approved") { + OrbitButton("Approve", Modifier.focusRequester(first), onClick = { decide("approved") }) + OrbitButton("Defer", onClick = { decide("deferred") }) + OrbitButton("Dismiss", onClick = { decide("dismissed") }) + } + OrbitButton("Close", if (idea.decision == "approved") Modifier.focusRequester(first) else Modifier, onClick = { eventOpen = null }) + } } } } diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt index cc44a69..c483f0d 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt @@ -161,7 +161,7 @@ internal fun premiereHeld(channel: Int, zone: ZoneId = ZoneId.systemDefault()): internal fun competingEvent(startMs: Long, endMs: Long, zone: ZoneId = ZoneId.systemDefault(), except: String? = null): String? { fun ms(t: java.time.LocalDateTime) = t.atZone(zone).toInstant().toEpochMilli() val hour = 60 * 60_000L - val events = DriveInEvents.map { e -> Triple("the Drive-In's ${e.name}", ms(e.evening.atTime(e.hour, e.minute)), ms(e.evening.atTime(e.hour, e.minute)) + e.films.size * 2 * hour) } + + val events = allDriveInEvents().map { e -> Triple("the Drive-In's ${e.name}", ms(e.evening.atTime(e.hour, e.minute)), ms(e.evening.atTime(e.hour, e.minute)) + e.films.size * 2 * hour) } + AdventDoors.map { d -> Triple("Advent's Door ${d.door}", ms(d.at), ms(d.at) + 2 * hour) } + NaughtyList.map { n -> Triple("The Naughty List's ${n.film.title}", ms(n.at), ms(n.at) + 2 * hour) } + ApprovedDebuts.filter { it.film.itemId != except }.map { d -> Triple("the premiere of ${d.film.title}", d.startMs(zone), d.startMs(zone) + 3 * hour) } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index cfaab59..3de166f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -527,7 +527,16 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea else "${choice.title} premieres on Orbit Premiere ${java.time.Instant.ofEpochMilli(debut).atZone(java.time.ZoneId.systemDefault()).let { "%s %d %s at %02d:%02d".format(it.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.dayOfMonth, it.month.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.hour, it.minute) }}, then joins ${home.name}." } }, setLearning = { on -> withContext(Dispatchers.IO) { ChairLearning.set(scheduleStore.learningFile(), on) }; android.util.Log.i("OrbitSchedule", "director learning=$on") }, - decideLesson = { key, confirm -> withContext(Dispatchers.IO) { ChairLearning.decide(scheduleStore.learningFile(), key, confirm) }; android.util.Log.i("OrbitSchedule", "director lesson confirmed=$confirm") }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, + decideLesson = { key, confirm -> withContext(Dispatchers.IO) { ChairLearning.decide(scheduleStore.learningFile(), key, confirm) }; android.util.Log.i("OrbitSchedule", "director lesson confirmed=$confirm") }, + decideIdea = { idea, decision -> withContext(Dispatchers.IO) { + val films = runCatching { catalogue.ideaFilms(idea.filmIds) }.getOrDefault(emptyList()) + val ok = EventIdeaHistory.decide(scheduleStore.eventIdeaHistoryFile(), scheduleStore.eventIdeasFile(), idea.signature, decision, films, System.currentTimeMillis()) + android.util.Log.i("OrbitSchedule", "event idea decision=$decision saved=$ok") + when { !ok -> "That decision couldn't be saved. Try again." + decision == "approved" -> "${idea.theme} approved for ${idea.evening}. It books once the Drive-In plans that evening, after its films are checked again." + decision == "deferred" -> "${idea.theme} deferred: it can come back in four weeks." + else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." } + } }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on -> val tots = scheduledChannels[TOTS_NUMBER] val active = session diff --git a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt index 4537f8c..eb58499 100644 --- a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt +++ b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt @@ -224,7 +224,7 @@ internal fun weeklyEventIdeas(seeds: List, films: Map, filmsMs: Long, zone: ZoneId = ZoneId.systemDefault()): LocalDateTime? { var day = from repeat(42) { - if (day.dayOfWeek in format.days && DriveInEvents.none { it.evening == day }) { + if (day.dayOfWeek in format.days && allDriveInEvents().none { it.evening == day }) { val at = day.atTime(format.hour, 0); val ms = at.atZone(zone).toInstant().toEpochMilli() if (ms > planEndMs && at !in taken && competingEvent(ms, ms + filmsMs, zone) == null) return at } @@ -233,10 +233,63 @@ internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, t return null } -/** The proposal history: every idea shown, when, and (from phase 2) what you decided. Kept for explainable learning. */ +/** + * Phase 2 (approved by Paul on 5 November 2026): an idea you approve becomes a Drive-In event, booked like the ten curated ones + * ([DriveInEvents]): only once the Drive-In's booking reaches its evening (it only ever appends), and only if every film is + * verified on the box again then. Deferred ideas wait [EVENT_IDEA_DEFER_MS] before they can be proposed again; dismissed ones + * don't return unless their films change. + */ +internal const val EVENT_IDEA_DEFER_MS = 28 * 24 * 60 * 60_000L + +/** Ideas you've approved, as Drive-In events. Read from the history whenever it changes or the Drive-In books. */ +internal object ApprovedDriveInEvents { + @Volatile var list: List = emptyList() + fun refresh(historyFile: File) { list = EventIdeaHistory.approvedEvents(historyFile) } +} + +/** The curated events and the ideas you've approved: every Drive-In event. */ +internal fun allDriveInEvents(): List = DriveInEvents + ApprovedDriveInEvents.list + +/** The proposal history: every idea shown, when, and what you decided. Kept for explainable learning. */ internal object EventIdeaHistory { fun load(file: File): List = runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { a.getJSONObject(it) } } }.getOrDefault(emptyList()) - fun dismissed(file: File): Set = load(file).filter { it.optString("decision") == "dismissed" }.map { it.optString("signature") }.toSet() + /** Not to be proposed: dismissed, deferred and still waiting, or approved already. */ + fun dismissed(file: File, nowMs: Long = System.currentTimeMillis()): Set = load(file).filter { + val d = it.optString("decision") + d == "dismissed" || d == "approved" || (d == "deferred" && nowMs - it.optLong("decidedMs") < EVENT_IDEA_DEFER_MS) + }.map { it.optString("signature") }.toSet() + + /** + * Records your decision on an idea ([decision]: approved, deferred or dismissed), in the history and in this week's ideas. + * An approval keeps each film's identity (title, year, IMDb ID, library item) so the event can be verified and booked. + */ + fun decide(historyFile: File, ideasFile: File, signature: String, decision: String, films: List, nowMs: Long): Boolean = runCatching { + require(decision in setOf("approved", "deferred", "dismissed")) + fun update(o: JSONObject): JSONObject { + if (o.optString("signature") != signature) return o + val n = JSONObject(o.toString()).put("decision", decision).put("decidedMs", nowMs) + if (decision == "approved") n.put("filmData", JSONArray(films.map { f -> JSONObject().put("id", f.id).put("title", f.title).put("year", f.year ?: 0).put("imdb", f.imdb ?: "") })) + return n + } + val all = load(historyFile) + if (all.none { it.optString("signature") == signature }) return@runCatching false + historyFile.writeText(JSONArray(all.map(::update)).toString()) + runCatching { JSONObject(ideasFile.readText()).let { root -> + val ideas = root.getJSONArray("ideas") + ideasFile.writeText(root.put("ideas", JSONArray((0 until ideas.length()).map { update(ideas.getJSONObject(it)) })).toString()) + } } + ApprovedDriveInEvents.refresh(historyFile) + true + }.getOrDefault(false) + + /** Your approved ideas as Drive-In events, each with its films pinned by library item and IMDb ID. */ + fun approvedEvents(file: File): List = load(file).filter { it.optString("decision") == "approved" }.mapNotNull { o -> + val evening = o.optString("evening").takeIf { it.isNotEmpty() && it != "null" }?.let { runCatching { LocalDateTime.parse(it) }.getOrNull() } ?: return@mapNotNull null + val films = o.optJSONArray("filmData")?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty() + .map { EventFilm(it.optString("title"), it.optInt("year"), it.optString("imdb"), it.optString("id")) }.filter { it.imdb.isNotBlank() && EmbyClient.validId(it.itemId) } + if (films.size < 2) return@mapNotNull null + DriveInEvent("idea-" + Integer.toHexString(o.optString("signature").hashCode()), o.optString("theme"), evening.toLocalDate(), evening.hour, evening.minute, films) + } /** Ideas shown under older rules and never decided are withdrawn, kept in the history with the reason. */ fun withdrawOld(file: File) = runCatching { val all = load(file) diff --git a/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt b/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt index ed25014..915199a 100644 --- a/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt @@ -95,4 +95,33 @@ class EventIdeasTest { assertEquals("proposed", EventIdeaHistory.load(file).single().optString("decision")) assertEquals(2L, EventIdeaHistory.load(file).single().optLong("shownMs")) } + + @Test fun anApprovedIdeaBecomesADriveInEventAndBooksLikeOne() { + val history = java.io.File.createTempFile("history", ".json").apply { deleteOnExit(); writeText("[]") } + val ideasFile = java.io.File.createTempFile("ideas", ".json").apply { deleteOnExit() } + val a = film("5000001", "Comedy One", setOf("Comedy"), "U", 8.0, 1949, 81, "tt0041546"); val b = film("5000002", "Comedy Two", setOf("Comedy"), "U", 7.5, 1951, 78, "tt0044829") + val idea = EventIdea("studio:Ealing Studios|FridayDouble", "Ealing Comedy Night", "studio", IdeaFormat.FridayDouble, java.time.LocalDateTime.of(2026, 11, 20, 20, 0), listOf(a, b), listOf("why"), 8.0) + EventIdeaHistory.record(history, listOf(idea), 1) + ideasFile.writeText(org.json.JSONObject().put("ideas", org.json.JSONArray(EventIdeaHistory.load(history))).toString()) + assertTrue(EventIdeaHistory.decide(history, ideasFile, idea.signature, "approved", listOf(a, b), 2)) + val event = EventIdeaHistory.approvedEvents(history).single() + assertEquals(LocalDate.of(2026, 11, 20), event.evening); assertEquals(20, event.hour); assertEquals(listOf("5000001", "5000002"), event.films.map { it.itemId }) + assertEquals("tt0041546", event.films.first().imdb) + assertTrue(allDriveInEvents().any { it.name == "Ealing Comedy Night" }) // the Drive-In books it like its own + assertEquals("approved", org.json.JSONObject(ideasFile.readText()).getJSONArray("ideas").getJSONObject(0).optString("decision")) + assertTrue(idea.signature in EventIdeaHistory.dismissed(history, 3)) // never proposed again + // It books on its evening, from the verified films, as the curated events do. + val pool = (1..40).map { n -> PoolSeries("p$n", "Film $n", listOf(PoolEpisode("p$n", "s$n", EpisodeMark(1, 1), "Film $n", 100 * 60_000L))) } + val verified = listOf(a, b).associate { f -> f.id to PoolSeries(f.id, f.title, listOf(PoolEpisode(f.id, "src", EpisodeMark(1, 1), f.title, f.runtimeMs!!))) } + var plan: ChannelPlan? = null; var now = java.time.ZonedDateTime.of(2026, 11, 12, 12, 0, 0, 0, london).toInstant().toEpochMilli() + repeat(9) { plan = planDriveIn(plan, pool, pool, pool, now, london, events = listOf(event), eventFilms = verified); now += 24 * 60 * 60_000L } + val booked = plan!!.entries.filter { it.seriesId in verified.keys } + assertEquals(listOf("5000001", "5000002"), booked.map { it.seriesId }) + assertTrue(booked.first().why.startsWith(DRIVE_IN_EVENT_WHY)) + // Deferred: back after four weeks; dismissed: not again. + val h2 = java.io.File.createTempFile("history", ".json").apply { deleteOnExit(); writeText("[]") } + EventIdeaHistory.record(h2, listOf(idea), 1); EventIdeaHistory.decide(h2, ideasFile, idea.signature, "deferred", emptyList(), 10) + assertTrue(idea.signature in EventIdeaHistory.dismissed(h2, 10 + EVENT_IDEA_DEFER_MS - 1)); assertFalse(idea.signature in EventIdeaHistory.dismissed(h2, 10 + EVENT_IDEA_DEFER_MS)) + ApprovedDriveInEvents.list = emptyList() + } }