0.2.48 - Tv calender fixes
This commit is contained in:
@@ -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("9–15 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user