0.2.48 - Tv calender fixes

This commit is contained in:
ponzischeme89
2026-08-11 12:08:51 +12:00
parent dd2dd497f2
commit f46d6596c5
47 changed files with 3415 additions and 125 deletions
@@ -0,0 +1,239 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayCalendarDay
import com.ponzischeme89.memby.ui.calendar.CALENDAR_COLUMNS
import com.ponzischeme89.memby.ui.calendar.CalendarUiState
import com.ponzischeme89.memby.ui.calendar.calendarDate
import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekDate
import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekIndex
import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeeks
import com.ponzischeme89.memby.ui.calendar.calendarDayHeading
import com.ponzischeme89.memby.ui.calendar.calendarEpisodeCount
import com.ponzischeme89.memby.ui.calendar.calendarEpisodeCountLabel
import com.ponzischeme89.memby.ui.calendar.calendarInitialDate
import com.ponzischeme89.memby.ui.calendar.calendarWeeks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CalendarGridTest {
private fun episode(id: String, name: String) = BaseItem(id = id, name = name, type = "MembySonarrEpisode")
// August 2026: 31 days, beginning on a Saturday.
private fun august(days: List<GatewayCalendarDay> = emptyList(), today: String = "2026-08-11") =
GatewayCalendar(
available = true,
month = "2026-08",
label = "August 2026",
previous = "2026-07",
next = "2026-09",
today = today,
firstWeekday = 6,
dayCount = 31,
days = days,
)
@Test
fun `the grid pads to whole weeks`() {
val weeks = calendarWeeks(august())
// Six leading blanks plus thirty-one days is thirty-seven cells: six rows of seven.
assertEquals(6, weeks.size)
assertTrue(weeks.all { it.size == CALENDAR_COLUMNS })
assertTrue(weeks.first().take(6).all { it.isPad })
assertEquals(1, weeks.first().last().day)
assertEquals(31, weeks.flatten().count { !it.isPad })
}
@Test
fun `the weekly guide uses the gateway month shape`() {
val weeks = calendarAgendaWeeks(
august(
days = listOf(
GatewayCalendarDay(
date = "2026-08-12",
day = 12,
items = listOf(episode("a", "Northbound"), episode("b", "Harbour")),
),
),
),
)
assertEquals(6, weeks.size)
assertEquals("915 August", weeks[2].label)
assertEquals(2, weeks[2].episodeCount)
assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12"))
assertEquals("2026-08-11", calendarAgendaWeekDate(weeks[2]))
}
@Test
fun `another weekly page opens on its first programme`() {
val weeks = calendarAgendaWeeks(
august(
today = "",
days = listOf(
GatewayCalendarDay(
date = "2026-08-18",
day = 18,
items = listOf(episode("a", "Northbound")),
),
),
),
)
assertEquals("2026-08-18", calendarAgendaWeekDate(weeks[3]))
}
// A pad is drawn but never focusable: there is nothing on the 0th of August to be told
// about, and a D-pad that stopped there would read as the grid having stuck.
@Test
fun `pads are never focusable`() {
val weeks = calendarWeeks(august())
assertFalse(weeks.first().first().isFocusable)
assertTrue(weeks.first().last().isFocusable)
}
@Test
fun `days carry their episodes and their date`() {
val weeks = calendarWeeks(
august(
days = listOf(
GatewayCalendarDay(
date = "2026-08-12",
day = 12,
items = listOf(episode("a", "Northbound"), episode("b", "Harbour")),
),
),
),
)
val twelfth = weeks.flatten().first { it.day == 12 }
assertEquals("2026-08-12", twelfth.date)
assertEquals(2, twelfth.items.size)
assertTrue(weeks.flatten().first { it.day == 13 }.items.isEmpty())
}
@Test
fun `today is marked and only today`() {
val cells = calendarWeeks(august()).flatten()
assertEquals(listOf(11), cells.filter { it.today }.map { it.day })
}
@Test
fun `a month the household is not in marks no day`() {
val cells = calendarWeeks(august(today = "")).flatten()
assertTrue(cells.none { it.today })
}
@Test
fun `a leap February still lays out`() {
val weeks = calendarWeeks(
GatewayCalendar(available = true, month = "2028-02", dayCount = 29, firstWeekday = 2),
)
assertEquals(29, weeks.flatten().count { !it.isPad })
assertTrue(weeks.all { it.size == CALENDAR_COLUMNS })
}
@Test
fun `an unavailable month lays out nothing`() {
assertTrue(calendarWeeks(GatewayCalendar()).isEmpty())
}
// Today is what somebody opening a guide is asking about, whether or not anything airs.
@Test
fun `the page opens on today when the household is in this month`() {
assertEquals("2026-08-11", calendarInitialDate(august()))
}
// Travelling forward has no today to land on, and landing on the 1st of a month whose
// news is on the 14th means walking a fortnight of empty cells to find out there was any.
@Test
fun `another month opens on its first airing`() {
val calendar = august(
today = "",
days = listOf(
GatewayCalendarDay(date = "2026-08-14", day = 14, items = listOf(episode("a", "Northbound"))),
),
)
assertEquals("2026-08-14", calendarInitialDate(calendar))
}
@Test
fun `a month with nothing on opens on its first day`() {
assertEquals("2026-08-01", calendarInitialDate(august(today = "")))
}
@Test
fun `counts describe the month`() {
val calendar = august(
days = listOf(
GatewayCalendarDay(date = "2026-08-12", day = 12, items = listOf(episode("a", "A"), episode("b", "B"))),
GatewayCalendarDay(date = "2026-08-13", day = 13, items = listOf(episode("c", "C"))),
),
)
assertEquals(3, calendarEpisodeCount(calendar))
assertEquals("3 episodes", calendarEpisodeCountLabel(3))
assertEquals("1 episode", calendarEpisodeCountLabel(1))
assertEquals("Nothing scheduled", calendarEpisodeCountLabel(0))
}
// The weekday is counted off the grid's own offset rather than from a date library, for
// the same reason the grid is built from firstWeekday at all.
@Test
fun `the day heading names the weekday`() {
assertEquals("Wednesday 12 August", calendarDayHeading(august(), "2026-08-12"))
assertEquals("Saturday 1 August", calendarDayHeading(august(), "2026-08-01"))
assertEquals("Monday 31 August", calendarDayHeading(august(), "2026-08-31"))
}
@Test
fun `a heading is refused for a day outside the month`() {
assertEquals("", calendarDayHeading(august(), ""))
assertEquals("", calendarDayHeading(august(), "2026-08-45"))
}
// Every date on this page is compared for equality with every other, so a month the
// gateway sent as something unreadable has to produce *nothing* rather than a set of
// plausible-looking strings that agree with none of the days the episodes are in.
@Test
fun `a month that is not a month yields no dates`() {
assertEquals("", calendarDate("", 12))
assertEquals("", calendarDate("2026", 12))
assertEquals("", calendarDate("not-a-month", 12))
assertEquals("", calendarDate("20xx-08", 12))
assertEquals("", calendarDate("2026/08", 12))
assertEquals("2026-08-12", calendarDate("2026-08", 12))
assertEquals("2026-08-01", calendarDate("2026-08", 1))
}
@Test
fun `no cell is dated when the month is unreadable`() {
val broken = august().copy(month = "nonsense", today = "")
assertTrue(calendarWeeks(broken).flatten().none { it.date.isNotEmpty() })
assertEquals("", calendarInitialDate(broken))
}
// Selection is by date string, so a blank one has to match nothing at all. Matching the
// first day that also failed to produce a date would list one arbitrary day's episodes
// under every cell in the grid.
@Test
fun `a blank selection lists nothing`() {
val state = CalendarUiState(
calendar = august(
today = "",
days = listOf(
GatewayCalendarDay(date = "2026-08-14", day = 14, items = listOf(episode("a", "A"))),
),
),
selectedDate = "",
isLoading = false,
)
assertTrue(state.selectedItems.isEmpty())
assertEquals(
listOf("a"),
state.copy(selectedDate = "2026-08-14").selectedItems.map { it.id },
)
}
}
@@ -0,0 +1,72 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* The keyed lazy lists up and down this app are keyed by ids that came off a wire, and a
* repeated key is a crash that takes the whole screen with it. These pin the rule that
* stands between a duplicate and that crash — see [distinctForKeys].
*/
class ListKeysTest {
private fun item(id: String, name: String = id) = BaseItem(id = id, name = name)
private fun row(id: String, vararg items: BaseItem) =
HomeRow(id = id, title = id, kind = "latest", items = items.toList())
@Test
fun `a list already distinct is handed back untouched`() {
val items = listOf(item("a"), item("b"))
assertSame(items, items.distinctForKeys(BaseItem::id))
}
// The first wins, deliberately: on the launcher that is the copy already drawn, and
// pulling a card out from under somebody mid-scroll is the thing being avoided.
@Test
fun `a duplicate key keeps the first occurrence`() {
val first = item("a", name = "Original")
val items = listOf(first, item("b"), item("a", name = "Repeat"))
val distinct = items.distinctForKeys(BaseItem::id)
assertEquals(listOf("a", "b"), distinct.map(BaseItem::id))
assertEquals("Original", distinct.first().name)
}
@Test
fun `an empty list is safe`() {
assertEquals(emptyList<BaseItem>(), emptyList<BaseItem>().distinctForKeys(BaseItem::id))
}
// Two rows under one id crash the launcher's own LazyColumn — which the recommendation
// refresh could produce by appending a freshly built row beside the one it was meant to
// replace.
@Test
fun `rows are made unique by row id`() {
val rows = listOf(
row("continue", item("a")),
row("latest", item("b")),
row("continue", item("c")),
)
assertEquals(listOf("continue", "latest"), rows.sanitisedRows().map(HomeRow::id))
}
// And two cards under one id crash whichever row holds them.
@Test
fun `cards within a row are made unique by item id`() {
val sanitised = row("continue", item("a"), item("b"), item("a")).let {
listOf(it).sanitisedRows()
}
assertEquals(listOf("a", "b"), sanitised.single().items.map(BaseItem::id))
}
// Sanitising runs on every home response, so the ordinary case must not rebuild the
// whole payload: an untouched row is the same instance it arrived as.
@Test
fun `a clean payload is not copied`() {
val clean = row("continue", item("a"), item("b"))
assertSame(clean, listOf(clean).sanitisedRows().single())
}
}
@@ -18,16 +18,31 @@ class MediaBadgesTest {
videoRangeType = "DOVI",
title = "Dolby Vision HEVC",
),
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"),
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos", channels = 8),
),
)
assertEquals(
listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"),
listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"),
mediaBadges(item),
)
}
@Test
fun `includes surround and non-surround sound profiles`() {
fun badgesFor(channels: Int) = mediaBadges(
BaseItem(
id = "movie-$channels",
mediaStreams = listOf(MediaStream(type = "Audio", channels = channels)),
),
)
assertEquals(listOf("MONO"), badgesFor(1))
assertEquals(listOf("STEREO"), badgesFor(2))
assertEquals(listOf("5.1"), badgesFor(6))
assertEquals(listOf("7.1"), badgesFor(8))
}
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
@Test
fun `names HDR10+ rather than collapsing it to HDR`() {
@@ -0,0 +1,169 @@
package com.ponzischeme89.memby.ui.calendar
import androidx.compose.runtime.Composable
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayCalendarDay
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The TV calendar as it actually sits on a television, to `build/screenshots/tv-calendar/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*CalendarScreenshotTest"
* ```
*
* A unit test can check that thirty-one days lay out as six weeks; it cannot check whether
* a cell two centimetres across can carry a day number, two show titles and a "+2 more"
* without any of them becoming unreadable, which is the whole question this layout has. So
* the busy month is the one worth looking at: a household that follows twenty shows is what
* this page is for, and an empty February proves nothing about it.
*
* Poster artwork is injected as absent rather than stubbed. There is no network here, and a
* row that reads correctly with no picture in it is the state a title Sonarr has no cover
* for is drawn in anyway.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class CalendarScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/** A month with something on most weeks, opened on today — the ordinary case. */
@Test
fun `a busy month`() {
capture("calendar_busy-month") { CalendarPage(busyMonth()) }
}
/** The day panel filled past what one cell could ever show. */
@Test
fun `a crowded day`() {
capture("calendar_crowded-day") {
CalendarPage(busyMonth(), selectedDate = "2026-08-12")
}
}
/** Nothing scheduled: the grid still reads as a month rather than as a failure. */
@Test
fun `a quiet month`() {
capture("calendar_quiet-month") {
CalendarPage(
CalendarUiState(
calendar = august(days = emptyList()),
selectedDate = "2026-08-11",
isLoading = false,
),
)
}
}
/** No gateway, or no Sonarr behind it. The page says so instead of drawing a month. */
@Test
fun `no calendar available`() {
capture("calendar_unavailable") {
CalendarPage(CalendarUiState(calendar = GatewayCalendar(), isLoading = false))
}
}
@Composable
private fun CalendarPage(state: CalendarUiState, selectedDate: String = state.selectedDate) {
CalendarContent(
state = state.copy(selectedDate = selectedDate),
navigationFocusRequester = FocusRequester(),
contentFocusRequester = FocusRequester(),
onShowMonth = {},
onSelectDate = {},
onRetry = {},
onItemFocused = {},
onItemSelected = {},
onExit = {},
posterUrlFor = { null },
)
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent(content)
compose.onRoot().captureRoboImage("build/screenshots/tv-calendar/$name.png")
}
private fun busyMonth() = CalendarUiState(
calendar = august(
days = listOf(
day(3, episode("Northbound", "S02E04", "The Crossing", "Monday: 8:30 PM")),
day(
12,
episode("Northbound", "S02E05", "Slack Water", "Wednesday: 8:30 PM", "Season finale"),
episode("Harbour Lights", "S01E02", "Low Tide", "Wednesday: 9:00 PM"),
episode("The Long Paddock", "S04E11", "Shearing", "Wednesday: 9:30 PM"),
episode("Deep Water", "S03E01", "First Light", "Wednesday: 10:00 PM", "Season premiere"),
episode("Kererū", "S01E06", "The Nesting", "Wednesday: 10:30 PM"),
),
day(
18,
episode("Harbour Lights", "S01E03", "Spring Tide", "Tuesday: 9:00 PM"),
episode("Deep Water", "S03E02", "The Drop", "Tuesday: 10:00 PM"),
),
day(26, episode("The Long Paddock", "S04E12", "Muster", "Wednesday: 9:30 PM")),
),
),
selectedDate = "2026-08-11",
isLoading = false,
)
// August 2026: 31 days, beginning on a Saturday.
private fun august(days: List<GatewayCalendarDay>) = GatewayCalendar(
available = true,
month = "2026-08",
label = "August 2026",
previous = "2026-07",
next = "2026-09",
today = "2026-08-11",
firstWeekday = 6,
dayCount = 31,
days = days,
)
private fun day(number: Int, vararg items: BaseItem) = GatewayCalendarDay(
date = "2026-08-" + if (number < 10) "0$number" else "$number",
day = number,
items = items.toList(),
)
private fun episode(
series: String,
code: String,
title: String,
airs: String,
event: String? = null,
) = BaseItem(
id = "sonarr:$series:$code",
name = series,
type = "MembySonarrEpisode",
membySource = "sonarr",
membyEpisodeCode = code,
membyEpisodeTitle = title,
membyAirLabel = airs,
membyEpisodeEvent = event,
membyAvailabilityText = "Upcoming",
membyPlayable = false,
)
}
@@ -0,0 +1,129 @@
package com.ponzischeme89.memby.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import com.ponzischeme89.memby.ui.PreviewSurface
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class SettingsRailFocusTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `up from about reaches storage`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-appearance").requestFocus()
compose.waitForIdle()
SettingsPage.entries.drop(1).forEach { page ->
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").assertIsFocused()
}
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
// The rail settles onto the page a beat after the focus lands on it; the press
// worth testing is the one made after the About page is actually drawn.
compose.waitUntil {
compose.onAllNodesWithTag("settings-page-about").fetchSemanticsNodes().isNotEmpty()
}
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-storage").assertIsFocused()
}
@Test
fun `up from the top of the about pane leaves the pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
val top = MembyReleaseHistory.first().version
compose.onNodeWithTag("settings-release-$top").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-$top").assertIsNotFocused()
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
}
@Test
fun `up from the top of the playback pane leaves the pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-playback").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-playback").assertIsNotFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-playback").assertIsFocused()
}
/** The pane's own vertical navigation is untouched by the escape above it. */
@Test
fun `down and up move between rows inside a pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
val releases = MembyReleaseHistory
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-${releases[1].version}").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-${releases[0].version}").assertIsFocused()
}
@Composable
private fun Fixture() {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
val firstFocus = remember { FocusRequester() }
PreviewSurface {
SettingsPanelContent(
state = SettingsPanelState(
selectedPage = selectedPage,
installedVersion = "0.1.60",
releaseHistory = MembyReleaseHistory,
),
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
overlay = false,
firstFocusRequester = firstFocus,
)
}
}
}
@@ -0,0 +1,28 @@
package com.ponzischeme89.memby.update
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class RequiredUpdateSignalTest {
@Test
fun `a refusal reported before anybody listens is still delivered`() = runTest {
// The gateway retires a build on whichever request is in flight, which on a cold
// start can be before AppRoot's check loop is waiting. A signal nobody heard would
// leave the television on a sign-in form the gateway is about to refuse.
RequiredUpdateSignal.report("0.2.46")
assertEquals("0.2.46", RequiredUpdateSignal.required.first())
}
@Test
fun `the newest refusal wins and a blank header says nothing`() = runTest {
RequiredUpdateSignal.report("0.2.46")
RequiredUpdateSignal.report(" ")
RequiredUpdateSignal.report(" 0.2.47 ")
assertEquals("0.2.47", RequiredUpdateSignal.required.first())
}
}
@@ -0,0 +1,42 @@
package com.ponzischeme89.memby.update
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RequiredUpdateStateTest {
@Test
fun `the build the gateway asked for satisfies the refusal`() {
assertTrue(requiredUpdateSatisfied("0.2.46", "0.2.46"))
}
@Test
fun `a later build satisfies it too`() {
// The update installs and the app restarts before its first check comes back. The
// refusal is about the APK that was retired, and that APK is gone.
assertTrue(requiredUpdateSatisfied("0.2.47", "0.2.46"))
assertTrue(requiredUpdateSatisfied("0.3.0", "0.2.46"))
}
@Test
fun `the build that was retired does not`() {
assertFalse(requiredUpdateSatisfied("0.2.45", "0.2.46"))
assertFalse(requiredUpdateSatisfied("0.2.9", "0.2.46"))
}
@Test
fun `missing components count as zero`() {
assertTrue(requiredUpdateSatisfied("0.2", "0.2.0"))
assertFalse(requiredUpdateSatisfied("0.2", "0.2.1"))
}
@Test
fun `a suffix is ignored, and an unreadable version keeps the refusal standing`() {
assertTrue(requiredUpdateSatisfied("0.2.46-beta", "0.2.46"))
// Refusing to guess: a version neither end can parse must not be what lets a
// retired television back onto the launcher.
assertFalse(requiredUpdateSatisfied("0.2.46", "latest"))
assertFalse(requiredUpdateSatisfied("", "0.2.46"))
}
}