Release v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:25:50 +12:00
parent b1128bcce2
commit fdd9e6cab2
116 changed files with 11418 additions and 879 deletions
@@ -0,0 +1,275 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The credits rule, pinned against the same cases as the gateway's copy (`credits_test.go`).
* The two exist separately because with no gateway there is nobody to ask, and the picture
* must not start shrinking at a different moment depending on whether the container is up —
* so when one of these changes, the other has to change with it.
*
* The cases come from a survey of a real 20,000-item library, and the shape of that survey is
* why the rule looks the way it does. Emby 4.10 wrote **no `CreditsStart` at all** — only
* `Chapter`, `IntroStart` and `IntroEnd` — while 216 items carried a chapter *named* like
* credits at 9098% of runtime. Two of them carried "Opening Credits" at 01%, which is the
* case the position floor exists for and the one worth never regressing.
*/
class CreditsTest {
private fun chapter(seconds: Long, marker: String) = EmbyChapter(
startPositionTicks = seconds * 1_000L * TICKS_PER_MS,
markerType = marker,
name = marker,
)
/**
* An ordinary chapter carrying a name, which is where the coverage actually comes from:
* Emby 4.10 writes no `CreditsStart`, but plenty of media carries "End Credits".
*/
private fun named(seconds: Long, name: String) = EmbyChapter(
startPositionTicks = seconds * 1_000L * TICKS_PER_MS,
markerType = "Chapter",
name = name,
)
@Test
fun `finds the marker on a real episode`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(
chapter(0, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
chapter(1200, "Chapter"),
chapter(1900, "CreditsStart"),
),
RUNTIME_MS,
),
)
}
/**
* A film commonly has the credits and no intro at all. This is the case that would have
* been lost had the two features shared one availability flag.
*/
@Test
fun `finds credits on a title with no intro`() {
assertEquals(
1_850_000L,
creditsStartFrom(
listOf(chapter(1400, "Chapter"), chapter(1850, "CreditsStart")),
RUNTIME_MS,
),
)
}
@Test
fun `refuses a list with no markers`() {
assertNull(
creditsStartFrom(listOf(chapter(0, "Chapter"), chapter(300, "Chapter")), RUNTIME_MS),
)
assertNull(creditsStartFrom(emptyList(), RUNTIME_MS))
}
/** An intro pair is a different feature and must never be read as credits. */
@Test
fun `refuses intro markers`() {
assertNull(
creditsStartFrom(
listOf(chapter(463, "IntroStart"), chapter(583, "IntroEnd")),
RUNTIME_MS,
),
)
}
/**
* A marker at zero says the whole file is credits, which is not something Emby means and
* not something worth shrinking a picture for.
*/
@Test
fun `refuses a marker at the very beginning`() {
assertNull(
creditsStartFrom(
listOf(chapter(0, "CreditsStart"), chapter(300, "Chapter")),
RUNTIME_MS,
),
)
}
/**
* The last marker wins, where [introSegmentFrom] takes the first. Two starts mean the
* markers are untrustworthy, and the two features are damaged in opposite directions: an
* intro skip that fires late throws somebody past the story, so the earlier marker is
* safer there; the credits pane firing early runs the last scene past somebody at double
* speed, so the later marker is safer here.
*/
@Test
fun `the later of two markers wins`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(chapter(1700, "CreditsStart"), chapter(1900, "CreditsStart")),
RUNTIME_MS,
),
)
// Order in the array is not trusted to be sorted.
assertEquals(
1_700_000L,
creditsStartFrom(
listOf(chapter(1900, "CreditsStart"), chapter(1700, "CreditsStart")),
RUNTIME_MS,
),
)
}
/**
* Squid Game, as the library actually holds it: no marker of any kind, one ordinary
* chapter named "Credits" at 90% of runtime. This is where every bit of this feature's
* coverage comes from today.
*/
@Test
fun `finds a chapter named Credits, which is all Emby 4-10 gives`() {
assertEquals(
1_800_000L,
creditsStartFrom(
listOf(
chapter(0, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
named(1800, "Credits"),
),
RUNTIME_MS,
),
)
assertEquals(
1_920_000L,
creditsStartFrom(listOf(named(1920, "End Credits")), RUNTIME_MS),
)
}
/**
* THE case. Belfast carries "Opening Credits" at 1% of runtime and Game of Thrones at 0%.
* Matching a name without testing the position starts the pane in the first minute of a
* film and runs its opening at double speed — the worst thing this feature could do, and
* the reason the position floor exists.
*/
@Test
fun `Opening Credits at the start of a film is never the credit roll`() {
assertNull(
creditsStartFrom(
listOf(named(20, "Opening Credits"), chapter(600, "Chapter")),
RUNTIME_MS,
),
)
// Belfast in full: the opening one is rejected and the closing one found.
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(
named(20, "Opening Credits"),
chapter(600, "Chapter"),
named(1900, "End Credits"),
),
RUNTIME_MS,
),
)
}
/**
* "The Pitt" carries both "Credits" and "End Credits" seconds apart, describing one roll.
* The *earliest* qualifying chapter wins here — the opposite of the marker rule — because
* the roll begins at the first of them and taking the last would skip part of it.
*/
@Test
fun `two credits chapters describing one roll take the earlier`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(named(1920, "End Credits"), named(1900, "Credits")),
RUNTIME_MS,
),
)
}
/**
* Anything in the first three quarters is refused however it is worded, and the intro's own
* chapters — named "Intro Start"/"Intro End" in this library — are never a credit roll.
*/
@Test
fun `refuses a credits-named chapter too early to be the roll`() {
assertNull(creditsStartFrom(listOf(named(900, "Credits")), RUNTIME_MS))
assertNull(
creditsStartFrom(
listOf(named(1800, "Intro Start"), named(1900, "Intro End")),
RUNTIME_MS,
),
)
}
/**
* With no runtime an explicit marker is still honoured — it is Emby asserting a position
* rather than this inferring one — but a name is refused, because nothing can then tell an
* opening credit sequence from a closing one.
*/
@Test
fun `an unknown runtime honours a marker and refuses a name`() {
assertEquals(
1_900_000L,
creditsStartFrom(listOf(chapter(1900, "CreditsStart")), runtimeMs = 0L),
)
assertNull(creditsStartFrom(listOf(named(1900, "End Credits")), runtimeMs = 0L))
}
/**
* A marker below the floor is a mis-detection whoever wrote it, so it gives way to a name
* that does qualify rather than being honoured on authority.
*/
@Test
fun `a marker below the floor falls through to a name that qualifies`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(chapter(200, "CreditsStart"), named(1900, "End Credits")),
RUNTIME_MS,
),
)
}
/**
* The duration guard is a second, separate refusal: the rule above says *where* the roll
* is, and this says whether there is enough of it left to be worth moving the picture for.
*/
@Test
fun `refuses a marker too near the end to be worth a transition`() {
val duration = 2_760_000L
assertTrue(creditsWorthShowing(2_700_000L, duration))
// Twenty seconds of credits is the last card of a roll, not something to sit beside a
// panel — the transition would be most of what was left.
assertFalse(creditsWorthShowing(2_740_000L, duration))
}
@Test
fun `refuses a marker past the end of the file`() {
assertFalse(creditsWorthShowing(3_000_000L, 2_760_000L))
assertFalse(creditsWorthShowing(2_760_000L, 2_760_000L))
}
/** An unknown duration is not a reason to guess. */
@Test
fun `refuses when the duration is unknown or the marker absent`() {
assertFalse(creditsWorthShowing(2_700_000L, 0L))
assertFalse(creditsWorthShowing(null, 2_760_000L))
}
private companion object {
const val TICKS_PER_MS = 10_000L
/** A two-thousand-second episode, so a percentage of runtime reads as a round number. */
const val RUNTIME_MS = 2_000_000L
}
}
@@ -0,0 +1,37 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule the infinite scroll stops on. Both halves of it exist because either one alone
* leaves a real case broken: a shelf that stops half way through a genre, or a grid that
* asks forever for pages that will never come.
*/
class GenreBrowseTest {
@Test
fun `keeps paging while the genre has more`() {
assertTrue(hasMoreGenreItems(loaded = 48, total = 412, lastPageSize = 48, pageSize = 48))
}
@Test
fun `stops once everything has been loaded`() {
assertFalse(hasMoreGenreItems(loaded = 412, total = 412, lastPageSize = 44, pageSize = 48))
}
@Test
fun `a short page is the end, whatever the total claims`() {
// A backend that would not count sends a total this side cannot trust. The page
// itself is the evidence, and one short of what was asked for is the last one.
assertFalse(hasMoreGenreItems(loaded = 61, total = 9_999, lastPageSize = 13, pageSize = 48))
}
@Test
fun `an empty genre does not page`() {
assertFalse(hasMoreGenreItems(loaded = 0, total = 0, lastPageSize = 0, pageSize = 48))
// Nor does one whose backend reported nothing at all about how much there is.
assertFalse(hasMoreGenreItems(loaded = 48, total = 0, lastPageSize = 48, pageSize = 48))
}
}
@@ -0,0 +1,38 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Test
class ImageCacheTest {
@Test
fun `an empty cache reads as zero megabytes rather than zero bytes`() {
// "0 B" on a storage page reads as a measurement that failed. The unit the rest of
// the page is counting in is what says the cache is simply empty.
assertEquals("0 MB", formatCacheSize(0L))
assertEquals("0 MB", formatCacheSize(-1L))
}
@Test
fun `sizes carry one unit and at most one decimal`() {
assertEquals("512 B", formatCacheSize(512L))
assertEquals("2 KB", formatCacheSize(2048L))
assertEquals("1 MB", formatCacheSize(1024L * 1024L))
assertEquals("1.5 MB", formatCacheSize(1024L * 1024L * 3L / 2L))
assertEquals("128 MB", formatCacheSize(128L * 1024L * 1024L))
assertEquals("1.2 GB", formatCacheSize((1.23 * 1024 * 1024 * 1024).toLong()))
}
@Test
fun `a whole number keeps no trailing decimal point`() {
assertEquals("2 MB", formatCacheSize(2L * 1024L * 1024L))
assertEquals("1 GB", formatCacheSize(1024L * 1024L * 1024L))
}
@Test
fun `the total is both halves`() {
val size = ImageCacheSize(diskBytes = 90L * 1024L * 1024L, memoryBytes = 38L * 1024L * 1024L)
assertEquals(128L * 1024L * 1024L, size.totalBytes)
assertEquals("128 MB", formatCacheSize(size.totalBytes))
}
}
@@ -12,6 +12,24 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SettingsStoreProfileRemovalTest {
@Test
fun `automatic artwork style persists in the active profile`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SettingsStore(context)
store.saveSession("https://artwork.example.test", "token", "art-user", "Ari", "server")
store.setHomeArtworkStyle("poster")
store.setHomeArtworkStyle("automatic")
val settings = store.snapshot()
assertEquals("automatic", settings.homeArtworkStyle)
assertEquals(
"automatic",
settings.profiles.single { it.userId == "art-user" }.homeArtworkStyle,
)
store.removeProfile(settings.profiles.single { it.userId == "art-user" }.id)
}
@Test
fun `ten minute reminder preference is persisted`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
@@ -0,0 +1,66 @@
package com.ponzischeme89.memby.data
import androidx.compose.ui.graphics.Color
import com.ponzischeme89.memby.data.model.GatewayPalette
import com.ponzischeme89.memby.ui.theme.MembyPalette
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The client's whole share of the theme rule: reading hex, and refusing to.
*
* There is deliberately no parallel to `themes_test.go` here, unlike the subtitle and intro
* tests. Which theme is in force is decided in one place, on the gateway; this end only
* paints what it is handed, and with no gateway it paints the default. A second copy of the
* seasonal calendar would be a television that could disagree with the server about whether
* it is Christmas.
*/
class ThemesTest {
@Test
fun `reads the alpha-first form the gateway writes`() {
assertEquals(Color(0xFF52B54B), parseThemeColor("#FF52B54B"))
assertEquals(Color(0x28FFFFFF), parseThemeColor("#28FFFFFF"))
}
@Test
fun `six digits are opaque`() {
// The form somebody hand-editing a palette reaches for, and the form Emby's own
// artwork colours take. Read as fully opaque rather than fully transparent, which
// is what a naive parse produces and what would draw nothing at all.
assertEquals(Color(0xFF52B54B), parseThemeColor("52B54B"))
assertEquals(Color(0xFF52B54B), parseThemeColor("#52b54b"))
}
@Test
fun `anything unreadable is refused rather than guessed at`() {
// Every one of these has to come back null so the caller keeps the app's own token
// for that slot. A theme one colour wrong is a blemish; a screen drawn transparent
// because of a typo is a television nobody can use.
listOf(null, "", "#", "#FFF", "#GGGGGGGG", "#FF52B54B52", "rgb(1,2,3)", " ")
.forEach { assertNull("expected null for '$it'", parseThemeColor(it)) }
}
@Test
fun `a palette keeps the current colour for anything the server did not send`() {
val current = MembyPalette(accent = Color(0xFFAA0000), quietText = Color(0xFF445566))
// A gateway that has grown a token this build does not send back, or one field with
// a typo in it, must leave the rest of the scheme alone rather than resetting the
// whole palette to the class defaults.
val palette = GatewayPalette(surface = "#FF010203", accent = "not a colour")
.toMembyPalette(fallback = current)
assertEquals(Color(0xFF010203), palette.surface)
assertEquals("an unreadable colour keeps the one in force", current.accent, palette.accent)
assertEquals("an absent colour keeps the one in force", current.quietText, palette.quietText)
}
@Test
fun `an empty palette changes nothing`() {
// What a server predating themes effectively sends. It must be a no-op, not a repaint
// into the defaults over whatever the viewer already had.
val current = MembyPalette(accent = Color(0xFFAA0000))
assertEquals(current, GatewayPalette().toMembyPalette(fallback = current))
}
}
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeGreetingTest {
@Test
fun `time of day selects the expected greeting`() {
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4))
assertEquals(HomeGreetingPeriod.MORNING, homeGreetingPeriod(5))
assertEquals(HomeGreetingPeriod.MORNING, homeGreetingPeriod(11))
assertEquals(HomeGreetingPeriod.AFTERNOON, homeGreetingPeriod(12))
assertEquals(HomeGreetingPeriod.AFTERNOON, homeGreetingPeriod(16))
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(17))
}
@Test
fun `greeting is shown after hero through continue watching`() {
val rows = listOf("featured", "continue", "latest")
assertTrue(shouldShowHomeGreeting(true, "featured", rows))
assertTrue(shouldShowHomeGreeting(true, "continue", rows))
assertFalse(shouldShowHomeGreeting(true, "latest", rows))
assertFalse(shouldShowHomeGreeting(true, null, rows))
assertFalse(shouldShowHomeGreeting(false, "continue", rows))
}
}
@@ -17,6 +17,22 @@ class HomeMovieHeroTest {
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, listAtTop = true))
}
@Test
fun `a card in a shelf stands the hero down without any scrolling`() {
// Continue Watching is the first row, so browsing it moves the list not at all.
assertEquals(
false,
shouldShowHomeMovieHero(hasMovies = true, listAtTop = true, rowFocused = true),
)
// And Up out of that row, which clears the flag, is what brings the hero back.
assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true, rowFocused = false))
// A viewer partway down the launcher still gets no hero for losing row focus.
assertEquals(
false,
shouldShowHomeMovieHero(hasMovies = true, listAtTop = false, rowFocused = false),
)
}
@Test
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
@@ -0,0 +1,422 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.GatewayPalette
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.data.toMembyPalette
import com.ponzischeme89.memby.ui.seasonal.Decorations
import com.ponzischeme89.memby.ui.seasonal.drawSeasonalField
import com.ponzischeme89.memby.ui.settings.SettingsPanelActions
import com.ponzischeme89.memby.ui.settings.SettingsPanelContent
import com.ponzischeme89.memby.ui.settings.SettingsPanelState
import com.ponzischeme89.memby.ui.settings.SettingsPage
import com.ponzischeme89.memby.ui.settings.ChoiceOption
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import org.junit.After
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
/**
* Every colour scheme on a real screen, and the picker that chooses between them, to
* `build/screenshots/themes/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ThemeScreenshotTest"
* ```
*
* This is the feature that needs looking at more than any other, because the only thing a
* unit test can check about a palette is that its hex parses. What it cannot check is whether
* the pale accent on Easter is legible against its own near-black, whether Halloween's quiet
* text survives on an orange-tinted surface, or whether the hairline under the tab strip
* still exists on Forest. Those are single hex digits in `themes.go` and every one of them is
* a judgement — a scheme nobody has looked at is a scheme shipped on faith.
*
* The sweep deliberately renders **one screen** across every theme rather than several
* screens on one theme. A series page is the densest thing in the app for this purpose: it
* puts the surface, the accent (Play), all three text weights and the hairline in one frame,
* so the whole palette can be judged side by side across nine images that differ in nothing
* but colour.
*
* The palettes are duplicated here from the gateway's catalogue as a **fixture**, not as a
* second copy of a rule — nothing in the app derives a colour from this list, and if it
* drifts the cost is a picture of a theme that has changed, which is the ordinary risk every
* screenshot fixture carries. Which theme is actually in force is decided in exactly one
* place, on the server, and is pinned there by `themes_test.go`.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ThemeScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/**
* The palette is process-wide state, which is the whole reason a theme can repaint five
* separate composition roots. It also means a test that left one applied would tint every
* screenshot taken after it in the same JVM, so this reset is load-bearing rather than
* tidiness.
*/
@After
fun untheme() {
applyMembyPalette(MembyPalette())
}
/** Every scheme an operator can hand out, on the same page, in catalogue order. */
@Test
fun `every selectable scheme on a series page`() {
sweep("scheme", Themes.selectable)
}
/**
* The three nobody chooses. Worth its own test rather than folding into the sweep above,
* because these are the only palettes that appear on a television without anybody asking
* for them — so "would I be annoyed to find my launcher like this" is a question that has
* to be answered by looking, before a date makes it everybody's problem at once.
*/
@Test
fun `the seasonal schemes`() {
sweep("season", Themes.seasonal)
}
/** The picker as an unrestricted viewer sees it, on the theme they have chosen. */
@Test
fun `the picker`() {
applyMembyPalette(theme("indigo").palette.toMembyPalette())
capture("picker-open") {
appearancePage(selected = "indigo", options = Themes.selectable)
}
}
/**
* The operator has left this person two schemes. The row is drawn from what the server
* sent, so a withheld theme is absent rather than greyed — which is the thing to check
* here, along with whether two chips look deliberate or look like something failed to
* load.
*/
@Test
fun `a viewer the operator has restricted`() {
applyMembyPalette(theme("graphite").palette.toMembyPalette())
capture("picker-restricted") {
appearancePage(
selected = "graphite",
options = Themes.selectable.filter { it.id in setOf("midnight", "graphite") },
)
}
}
/**
* December. The page is painted Christmas, the viewer's own chip is still the one
* selected, and the line underneath is the server's explanation.
*
* This is the case worth looking at hardest, because it is the only setting in the app
* that can be overruled and it has one job: to not read as broken. A viewer who presses
* a chip and sees nothing happen, with no sentence saying why, files a bug.
*/
@Test
fun `the picker while a season is on`() {
applyMembyPalette(theme("christmas").palette.toMembyPalette())
capture("picker-locked") {
appearancePage(
selected = "plum",
options = Themes.selectable,
locked = true,
notice = "Christmas is on for everyone until it is over.",
)
}
}
/**
* A gateway that predates themes, or the direct path with no gateway at all: nothing was
* offered, so the row is not drawn and Appearance is the page it always was. Compare with
* `picker-open` — the difference must be one missing row and nothing shifted.
*/
@Test
fun `no schemes offered`() {
capture("picker-absent") { appearancePage(selected = "midnight", options = emptyList()) }
}
/**
* The decorations, three frames each, over the palette they belong to.
*
* Three rather than one because the only thing worth checking about an animation in a
* still is that it *is* one — that the field is spread rather than clumped at any given
* moment, and that a bat's wings are somewhere different in each. `drawSeasonalField`
* takes the progress, so this needs no animation clock and cannot be flaky: the same
* number always draws the same picture.
*
* `0.0` and `1.0` are both captured for snow, and they must come out **identical**. That
* is the seamless wrap the whole particle scheme is built around, and it is the one
* property nobody could confirm by watching a television for less than a minute.
*/
@Test
fun `snow`() {
frames("christmas", Decorations.SNOW, listOf(0f, 0.37f, 1f))
}
/** One test each, because a compose rule allows one `setContent` per test. */
@Test
fun `bats`() {
frames("halloween", Decorations.BATS, listOf(0f, 0.37f, 0.74f))
}
@Test
fun `blossom`() {
frames("easter", Decorations.BLOSSOM, listOf(0f, 0.37f, 0.74f))
}
/**
* Snow over a page with content on it — the only view that answers the question that
* actually matters. Decoration competing with the title, the facts and the Play button is
* worse than no decoration, and no amount of looking at particles on plain black would
* ever show it.
*/
@Test
fun `a decoration over a page somebody is reading`() {
val palette = theme("christmas").palette.toMembyPalette()
applyMembyPalette(palette)
capture("decoration-over-content") {
Box {
seriesPage()
Canvas(Modifier.fillMaxSize()) {
drawSeasonalField(Decorations.SNOW, 0.37f, palette.accent, palette.onSurface)
}
}
}
}
// ---------------------------------------------------------------------------
/**
* One field, composed once, captured at several points through its cycle.
*
* The progress is snapshot state read inside the draw lambda — the same arrangement the
* real animation uses, so moving it here invalidates the draw and nothing else. That is
* not incidental to the test: if advancing the frame recomposed anything, this would be
* quietly measuring a different thing from what ships.
*/
private fun frames(themeId: String, decoration: String, progressions: List<Float>) {
val palette = theme(themeId).palette.toMembyPalette()
applyMembyPalette(palette)
val progress = mutableFloatStateOf(progressions.first())
compose.setContent {
Box(Modifier.fillMaxSize().background(palette.surface)) {
Canvas(Modifier.fillMaxSize()) {
drawSeasonalField(decoration, progress.floatValue, palette.accent, palette.onSurface)
}
}
}
progressions.forEach { at ->
progress.floatValue = at
compose.waitForIdle()
compose.onRoot().captureRoboImage(
"build/screenshots/themes/decoration-$decoration-${(at * 100).toInt()}.png",
)
}
}
/**
* One page, composed once, captured under each palette in turn.
*
* Composed once because `setContent` may only be called once per test — but it is also
* the more honest picture. Repainting a live composition is exactly what happens on a
* television when a season begins under somebody who is already looking at the screen,
* and it only works because the tokens are snapshot state: nothing here re-composes the
* page or tells it anything changed.
*/
private fun sweep(prefix: String, themes: List<ThemeFixture>) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { seriesPage() }
}
themes.forEach { theme ->
applyMembyPalette(theme.palette.toMembyPalette())
compose.waitForIdle()
compose.onRoot().captureRoboImage("build/screenshots/themes/$prefix-${theme.id}.png")
}
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/themes/$name.png")
}
@Composable
private fun seriesPage() {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
)
}
@Composable
private fun appearancePage(
selected: String,
options: List<ThemeFixture>,
locked: Boolean = false,
notice: String = "",
) {
SettingsPanelContent(
state = SettingsPanelState(
selectedPage = SettingsPage.APPEARANCE,
themeId = selected,
themeOptions = options.map { theme ->
ChoiceOption(theme.id, theme.name, parseThemeColor(theme.palette.accent))
},
themeLocked = locked,
themeNotice = notice,
installedVersion = "0.2.28",
),
actions = SettingsPanelActions(),
overlay = false,
)
}
private fun theme(id: String): ThemeFixture =
(Themes.selectable + Themes.seasonal).first { it.id == id }
private val series = BaseItem(
id = "series-1",
name = "Signal Hill",
type = "Series",
overview = "A coastal radio station keeps receiving a broadcast that has not been " +
"transmitted yet. Six weeks before the storm, the night operator starts writing " +
"down what she hears.",
productionYear = 2022,
officialRating = "TV-MA",
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
status = "Ended",
)
private val episodes = (1..8).map { number ->
BaseItem(
id = "s1e$number",
name = EPISODE_TITLES[number - 1],
type = "Episode",
seriesName = "Signal Hill",
parentIndexNumber = 1,
indexNumber = number,
runTimeTicks = 48L * 600_000_000L,
)
}
/** One theme as the gateway describes it. A fixture; see the note at the top. */
internal data class ThemeFixture(
val id: String,
val name: String,
val palette: GatewayPalette,
)
private object Themes {
val selectable = listOf(
fixture(
"midnight", "Midnight",
"#FF090B0D", "#FF101418", "#FF52B54B", "#FFE2E5E8",
"#FFD0D6DB", "#FFAEB7BF", "#28FFFFFF", "#FF20252A",
),
fixture(
"graphite", "Graphite",
"#FF0D0C0A", "#FF181614", "#FFE0A33C", "#FFE9E5DE",
"#FFD8D2C8", "#FFB6AEA1", "#28FFF3DC", "#FF262320",
),
fixture(
"indigo", "Indigo",
"#FF07090F", "#FF111726", "#FF5C8DFF", "#FFE1E6F0",
"#FFCBD4E4", "#FFA5B0C6", "#28C7D8FF", "#FF1D2435",
),
fixture(
"ember", "Ember",
"#FF0C0808", "#FF181111", "#FFE05B4A", "#FFEDE3E1",
"#FFDACECB", "#FFB8A7A3", "#28FFD5CE", "#FF261B1A",
),
fixture(
"forest", "Forest",
"#FF080B09", "#FF111713", "#FF7FC08A", "#FFE3E8E3",
"#FFCFD8CF", "#FFA9B5AA", "#28D2F0D6", "#FF1E2620",
),
fixture(
"plum", "Plum",
"#FF0B080D", "#FF171020", "#FFB37FE0", "#FFE7E2EC",
"#FFD5CCDD", "#FFB0A4BC", "#28E4D2FF", "#FF241B2D",
),
)
val seasonal = listOf(
fixture(
"halloween", "Halloween",
"#FF0A0704", "#FF17100A", "#FFFF8A1F", "#FFF2E7DA",
"#FFE2D2BE", "#FFBBA48C", "#28FFB870", "#FF26190E",
),
fixture(
"christmas", "Christmas",
"#FF060A07", "#FF0E1710", "#FFE0403F", "#FFEAF0E9",
"#FFD6E0D5", "#FFAEBCAE", "#28CFE8CF", "#FF19261B",
),
fixture(
"easter", "Easter",
"#FF0A0910", "#FF15131F", "#FF9BD3F0", "#FFEDE9F2",
"#FFDCD6E4", "#FFB6AEC4", "#28D8E9F7", "#FF211E2E",
),
)
@Suppress("LongParameterList")
private fun fixture(
id: String,
name: String,
surface: String,
surfaceRaised: String,
accent: String,
onSurface: String,
mutedText: String,
quietText: String,
hairline: String,
ratingsSurface: String,
) = ThemeFixture(
id, name,
GatewayPalette(
surface = surface, surfaceRaised = surfaceRaised, accent = accent,
onSurface = onSurface, mutedText = mutedText, quietText = quietText,
hairline = hairline, ratingsSurface = ratingsSurface,
),
)
}
private companion object {
val EPISODE_TITLES = listOf(
"Carrier Wave", "Dead Air", "The Long Count", "Nightingale",
"Six Weeks Out", "Landfall", "The Shipping Forecast", "Quiet Hours",
)
}
}
@@ -0,0 +1,123 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The credits speed ramp and its climbdown.
*
* The fallback is the half worth pinning. Doubling the speed doubles the bitrate pulled from
* Emby over HTTP, so the ceiling has to be able to fall — and it must never climb back inside
* one credit roll, or a marginal stream would oscillate between stuttering and recovering for
* the length of the roll.
*/
class CreditsSpeedTest {
@Test
fun `the ramp starts at normal speed and ends at the ceiling`() {
assertEquals(CREDITS_NORMAL_SPEED, creditsSpeedAt(0L, CREDITS_TARGET_SPEED), TOLERANCE)
assertEquals(
CREDITS_TARGET_SPEED,
creditsSpeedAt(CREDITS_RAMP_MS, CREDITS_TARGET_SPEED),
TOLERANCE,
)
// And stays there rather than overshooting once the ramp is past.
assertEquals(
CREDITS_TARGET_SPEED,
creditsSpeedAt(CREDITS_RAMP_MS * 10, CREDITS_TARGET_SPEED),
TOLERANCE,
)
}
@Test
fun `the ramp only ever moves forwards`() {
var previous = CREDITS_NORMAL_SPEED
var elapsed = 0L
while (elapsed <= CREDITS_RAMP_MS) {
val speed = creditsSpeedAt(elapsed, CREDITS_TARGET_SPEED)
assertTrue(
"speed went backwards at ${elapsed}ms: $previous then $speed",
speed >= previous - TOLERANCE,
)
previous = speed
elapsed += 60L
}
}
/**
* Eased out, the same curve every other transition in this package uses: most of the
* change happens immediately, so the effect reads as the picture being let go rather than
* as a slow drift nobody attributes to anything.
*/
@Test
fun `the ramp is past halfway by the time it is half over`() {
val halfway = creditsSpeedAt(CREDITS_RAMP_MS / 2, CREDITS_TARGET_SPEED)
val midpoint = (CREDITS_NORMAL_SPEED + CREDITS_TARGET_SPEED) / 2f
assertTrue("halfway speed was $halfway, wanted past $midpoint", halfway > midpoint)
}
/** A ramp to nowhere must not produce a curve. */
@Test
fun `a ceiling of normal speed produces normal speed throughout`() {
listOf(0L, 100L, CREDITS_RAMP_MS, CREDITS_RAMP_MS * 4).forEach { elapsed ->
assertEquals(
CREDITS_NORMAL_SPEED,
creditsSpeedAt(elapsed, CREDITS_NORMAL_SPEED),
TOLERANCE,
)
}
}
@Test
fun `the ceiling falls one step at a time and stops at normal speed`() {
val first = creditsCeilingAfterStall(CREDITS_TARGET_SPEED)
assertEquals(1.5f, first, TOLERANCE)
val second = creditsCeilingAfterStall(first)
assertEquals(CREDITS_NORMAL_SPEED, second, TOLERANCE)
// The floor is terminal: there is nowhere below it and nothing left to give up.
assertEquals(CREDITS_NORMAL_SPEED, creditsCeilingAfterStall(second), TOLERANCE)
}
/**
* The one property that must hold for a marginal stream: a stall never leaves the ceiling
* where it was, so a title that cannot hold 2× cannot be asked to hold it again.
*/
@Test
fun `a stall always gives up speed`() {
var ceiling = CREDITS_TARGET_SPEED
repeat(6) {
val next = creditsCeilingAfterStall(ceiling)
assertTrue("ceiling rose from $ceiling to $next", next <= ceiling + TOLERANCE)
ceiling = next
}
assertEquals(CREDITS_NORMAL_SPEED, ceiling, TOLERANCE)
}
@Test
fun `only a ceiling above normal speed counts as active`() {
assertTrue(creditsSpeedIsActive(CREDITS_TARGET_SPEED))
assertTrue(creditsSpeedIsActive(1.5f))
assertFalse(creditsSpeedIsActive(CREDITS_NORMAL_SPEED))
assertFalse(creditsSpeedIsActive(0.5f))
}
/**
* The chip is built from whole tenths rather than by formatting the float: a quantised
* 1.5 is not exactly 1.5 in binary, so `toString` on it prints "1.5000001", and
* `String.format` would print a comma for the point on a set configured in half of Europe.
*/
@Test
fun `the label reads as a person would write it`() {
assertEquals("2×", creditsSpeedLabel(CREDITS_TARGET_SPEED))
assertEquals("1.5×", creditsSpeedLabel(1.5f))
assertEquals("1×", creditsSpeedLabel(CREDITS_NORMAL_SPEED))
// And off the ramp, where the speed is whatever the curve produced.
assertEquals("1.8×", creditsSpeedLabel(1.7996f))
}
private companion object {
const val TOLERANCE = 0.03f
}
}
@@ -0,0 +1,197 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The credits pane over a stand-in for a running credit roll, captured from the real player
* XML at TV resolution → `build/screenshots/end-credits/`.
*
* The point of capturing it is the *split*, which no assertion can check: the panel is
* measured against a picture the activity scales to half width, so the two halves have to
* balance, and the only way to know they do is to look. The scaled picture here is drawn
* rather than played, at exactly the transform `shrinkVideoForCredits` applies.
*
* There is no scrim under this panel, so the frame behind it is deliberately bright: a
* capture over black would prove nothing about legibility — the same reason
* `SkipIntroScreenshotTest` uses a bright scene.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class EndCreditsScreenshotTest {
/** The ordinary case: minutes of credits left, so no countdown yet. */
@Test
fun `an episode rolling its credits`() {
capture(
name = "end-credits",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = CREDITS_TARGET_SPEED,
countdown = null,
)
}
/**
* Inside the last minute, where the countdown appears. It lives in the pane rather than
* in the next-up banner, which is suppressed while this is up — two of them would fight
* over the same transform and print the episode twice.
*/
@Test
fun `the last minute, counting down`() {
capture(
name = "end-credits-countdown",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = CREDITS_TARGET_SPEED,
countdown = "Starting in 28s",
)
}
/**
* The stream could not hold 2×, so the ceiling fell. Worth a capture because the chip is
* the only thing on screen that ever says so, and "1.5× speed" has to sit in the same
* plate as "2× speed" without changing the layout around it.
*/
@Test
fun `a stream that could only manage one and a half`() {
capture(
name = "end-credits-reduced-speed",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = 1.5f,
countdown = null,
)
}
/**
* A long title with no episode code — the shape a series whose next entry is named but
* unnumbered takes. The meta line is gone rather than blank, so the title has to sit
* correctly against the artwork above it with nothing between them.
*/
@Test
fun `a long title and no episode metadata`() {
capture(
name = "end-credits-long-title",
title = "The Cartographer of the Lower Reaches",
meta = "",
speed = CREDITS_TARGET_SPEED,
countdown = null,
)
}
private fun capture(
name: String,
title: String,
meta: String,
speed: Float,
countdown: String?,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply { setBackgroundColor(Color.BLACK) }
// The picture, where the activity's transform puts it. Scaling a view that is not
// laid out yet does nothing, so this stands in for the PlayerView at the finished
// scale and offset rather than trying to animate one.
val picture = ImageView(activity).apply {
setImageBitmap(creditRollFrame())
scaleType = ImageView.ScaleType.FIT_CENTER
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
scaleX = CREDITS_VIDEO_SCALE
scaleY = CREDITS_VIDEO_SCALE
// The shift matters as much as the scale: without it the picture stays centred
// and the panel is drawn over it, which is exactly the failure this capture is
// here to catch.
translationX = -SCREEN_WIDTH_PX * CREDITS_VIDEO_SHIFT_X
}
root.addView(picture)
val pane = LayoutInflater.from(activity)
.inflate(R.layout.player_end_credits, root, false)
pane.visibility = View.VISIBLE
pane.findViewById<TextView>(R.id.player_end_credits_title).text = title
pane.findViewById<TextView>(R.id.player_end_credits_meta).apply {
text = meta
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
}
pane.findViewById<TextView>(R.id.player_end_credits_speed).text =
activity.getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
pane.findViewById<TextView>(R.id.player_end_credits_countdown).apply {
text = countdown.orEmpty()
visibility = if (countdown == null) View.GONE else View.VISIBLE
}
pane.findViewById<ImageView>(R.id.player_end_credits_image)
.setImageBitmap(nextEpisodeArtwork())
// The remote lands on Play, which is what the pane does on opening — so the capture
// shows the focus state a viewer actually sees.
root.addView(pane)
activity.setContentView(root)
pane.findViewById<View>(R.id.player_end_credits_play).requestFocus()
root.captureRoboImage("build/screenshots/end-credits/$name.png")
}
/**
* Stands in for a credit roll. Deliberately bright at the top: there is no scrim between
* the picture and the panel, and the point of the capture is that they stay separable.
*/
private fun creditRollFrame(): Bitmap = gradient(
width = 640,
height = 360,
colours = intArrayOf(
Color.rgb(226, 222, 210),
Color.rgb(120, 118, 112),
Color.rgb(18, 18, 20),
),
)
private fun nextEpisodeArtwork(): Bitmap = gradient(
width = 480,
height = 270,
colours = intArrayOf(
Color.rgb(58, 92, 104),
Color.rgb(30, 44, 58),
Color.rgb(12, 16, 22),
),
)
private fun gradient(width: Int, height: Int, colours: IntArray): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawPaint(
Paint().apply {
shader = LinearGradient(
0f, 0f, width.toFloat(), height.toFloat(),
colours, null, Shader.TileMode.CLAMP,
)
},
)
return bitmap
}
private companion object {
/** The width the `w960dp-…-xhdpi` qualifier gives, in pixels. */
const val SCREEN_WIDTH_PX = 1920f
}
}
@@ -104,7 +104,7 @@ class SubtitleMenuScreenshotTest {
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
downloads = SubtitleDownloadState(
available = true,
entries = listOf(entry("Search for subtitles…")),
entries = listOf(entry("Search for subtitles…", prominent = true)),
),
)
}
@@ -119,7 +119,9 @@ class SubtitleMenuScreenshotTest {
available = true,
status = "Looking for subtitles. This can take a moment.",
expanded = true,
entries = listOf(SubtitleMenuEntry("Search for subtitles…", false, enabled = false)),
entries = listOf(
SubtitleMenuEntry("Search for subtitles…", false, enabled = false, prominent = true),
),
),
)
}
@@ -137,7 +139,7 @@ class SubtitleMenuScreenshotTest {
available = true,
expanded = true,
entries = listOf(
entry("Search again"),
entry("Search again", prominent = true),
entry("English · 98% match"),
entry("English · Hearing impaired · 96% match"),
entry("English · Forced · 91% match"),
@@ -158,12 +160,47 @@ class SubtitleMenuScreenshotTest {
available = true,
status = "No subtitles were found for this release.",
expanded = true,
entries = listOf(entry("Search for subtitles…")),
entries = listOf(entry("Search for subtitles…", prominent = true)),
),
)
}
private fun entry(label: String, selected: Boolean = false) = SubtitleMenuEntry(label, selected)
/**
* A title with no subtitles at all, which is the case the whole feature exists for and
* the one a capture is worth most on: the track list holds nothing but Off, and whether
* that reads as "this film has none" or as "the menu is broken" is entirely down to the
* notice under the heading and where the focus ring landed.
*/
@Test
fun `title has no subtitles`() {
capture(
name = "subtitles-menu-none-downloadable",
tracks = listOf(entry("Off", selected = true)),
tracksState = SubtitleTracksState(empty = true, downloadable = true),
downloads = SubtitleDownloadState(
available = true,
entries = listOf(entry("Find subtitles for this title…", prominent = true)),
),
focusedDownload = 0,
)
}
/** The same title on a backend with no provider: there is nothing to offer, and the
* panel says so once rather than leaving somebody looking for an option. */
@Test
fun `title has no subtitles and none can be fetched`() {
capture(
name = "subtitles-menu-none-unavailable",
tracks = listOf(entry("Off", selected = true)),
tracksState = SubtitleTracksState(empty = true, downloadable = false),
)
}
private fun entry(
label: String,
selected: Boolean = false,
prominent: Boolean = false,
) = SubtitleMenuEntry(label, selected, prominent = prominent)
private fun capture(
name: String,
@@ -172,6 +209,7 @@ class SubtitleMenuScreenshotTest {
focusedSize: Int? = null,
focusedDownload: Int? = null,
downloads: SubtitleDownloadState = SubtitleDownloadState(),
tracksState: SubtitleTracksState = SubtitleTracksState(),
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
@@ -200,6 +238,7 @@ class SubtitleMenuScreenshotTest {
tracks = tracks,
sizes = listOf(entry("Small"), entry("Medium", selected = true), entry("Large")),
downloads = downloads,
tracksState = tracksState,
)
activity.setContentView(root)
@@ -15,9 +15,10 @@ import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.update.UpdateStatus
import kotlinx.coroutines.delay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -68,12 +69,17 @@ class SettingsSheetScreenshotTest {
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-home.png")
}
/**
* The welcome tone lives on Appearance now, under the colour and logo rows. The capture
* is of the whole page rather than the chip row, because the thing worth looking at is
* whether a fourth question fits on it.
*/
@Test
fun `welcome tone options`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.WELCOME)
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.APPEARANCE)
}
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-welcome.png")
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-appearance.png")
}
@Test
@@ -85,11 +91,11 @@ class SettingsSheetScreenshotTest {
}
@Test
fun `updates page`() {
fun `stored artwork page`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.UPDATES)
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.STORAGE)
}
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-updates.png")
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-storage.png")
}
@Test
@@ -104,6 +110,49 @@ class SettingsSheetScreenshotTest {
}
}
// The other half of the rail — selection by *focus*, through SETTINGS_PAGE_SETTLE_MS —
// is deliberately not tested here. Robolectric's host view never takes window focus, so
// a semantics requestFocus on a rail item does not reach onFocusChanged and the settle
// never starts; a test written against it would pass or fail for reasons that have
// nothing to do with the rail. It is exercised on a television.
/**
* Every switch on every page turns on. The pages are walked by the rail, because a
* control that cannot be reached is as good as one that does not work.
*/
@Test
fun `every toggle on every page can be turned on`() {
val turnedOn = mutableSetOf<String>()
compose.setContent { InteractiveSettingsFixture(onToggled = { turnedOn += it }) }
compose.waitForIdle()
val togglesByPage = mapOf(
SettingsPage.APPEARANCE to listOf("Show logos"),
SettingsPage.PLAYBACK to listOf(
"Ten minutes left",
"Play the next episode",
"Closing credits",
),
SettingsPage.HOME to listOf(
"Continue watching",
"Favourites",
"Latest movies",
"Hide films you have seen",
"Text under cards",
"Ratings",
),
)
togglesByPage.forEach { (page, titles) ->
compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").performClick()
compose.waitForIdle()
titles.forEach { title ->
compose.onNodeWithText(title).performScrollTo().performClick()
compose.waitForIdle()
}
}
assertEquals(togglesByPage.values.flatten().toSet(), turnedOn)
}
@Test
fun `about lists the release history and opens one release`() {
compose.setContent { InteractiveSettingsFixture() }
@@ -128,21 +177,82 @@ class SettingsSheetScreenshotTest {
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
}
/**
* The panel wired to real state, so a press changes something a test can read. Every
* switch starts off: the assertion worth making is that each one can be turned *on*,
* which a fixture holding the defaults could not tell apart from a press doing nothing.
*/
@Composable
private fun InteractiveSettingsFixture() {
private fun InteractiveSettingsFixture(onToggled: (String) -> Unit = {}) {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
var showLogo by remember { mutableStateOf(false) }
var autoPlayNext by remember { mutableStateOf(false) }
var tenMinutes by remember { mutableStateOf(false) }
var speedUpCredits by remember { mutableStateOf(false) }
var hideWatched by remember { mutableStateOf(false) }
var cardMetadata by remember { mutableStateOf(false) }
var ratings by remember { mutableStateOf(false) }
var homeSections by remember { mutableStateOf(emptySet<String>()) }
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(170)
firstFocus.requestFocus()
}
// Only an on counts. A recorded off would let a row that reported the wrong
// direction pass the test that exists to catch exactly that.
fun record(title: String, enabled: Boolean) {
if (enabled) onToggled(title)
}
PreviewSurface {
SettingsPanelContent(
state = SettingsPanelState(
showLogo = showLogo,
autoPlayNext = autoPlayNext,
showTenMinuteReminder = tenMinutes,
speedUpCredits = speedUpCredits,
hideWatchedMovies = hideWatched,
showCardMetadata = cardMetadata,
showRatingsStrip = ratings,
homeSections = homeSections,
selectedPage = selectedPage,
installedVersion = "0.1.60",
),
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
actions = SettingsPanelActions(
onPageSelected = { selectedPage = it },
onShowLogoChanged = { showLogo = it; record("Show logos", it) },
onAutoPlayNextChanged = {
autoPlayNext = it
record("Play the next episode", it)
},
onShowTenMinuteReminderChanged = {
tenMinutes = it
record("Ten minutes left", it)
},
onSpeedUpCreditsChanged = {
speedUpCredits = it
record("Closing credits", it)
},
onHideWatchedMoviesChanged = {
hideWatched = it
record("Hide films you have seen", it)
},
onShowCardMetadataChanged = {
cardMetadata = it
record("Text under cards", it)
},
onShowRatingsStripChanged = { ratings = it; record("Ratings", it) },
onHomeSectionChanged = { key, enabled ->
homeSections = if (enabled) homeSections + key else homeSections - key
record(
when (key) {
"continue" -> "Continue watching"
"favorites" -> "Favourites"
else -> "Latest movies"
},
enabled,
)
},
),
overlay = false,
firstFocusRequester = firstFocus,
)
@@ -152,7 +262,7 @@ class SettingsSheetScreenshotTest {
@Composable
private fun SettingsPreviewFixture(
overlay: Boolean,
selectedPage: SettingsPage = if (overlay) SettingsPage.UPDATES else SettingsPage.APPEARANCE,
selectedPage: SettingsPage = if (overlay) SettingsPage.DEVICES else SettingsPage.APPEARANCE,
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
@@ -168,16 +278,13 @@ class SettingsSheetScreenshotTest {
showCardMetadata = false,
welcomeQuoteStyle = "homicidal",
selectedPage = selectedPage,
updateStatus = if (overlay) {
UpdateStatus.Available(
version = "0.1.61",
apkUrl = "https://example.invalid/memby.apk",
notes = "Faster startup and a better settings rail.",
)
} else {
null
},
installedVersion = "0.1.60",
// A measured cache rather than the null the page opens on: the capture
// worth looking at is the one with figures in it.
imageCacheSize = ImageCacheSize(
diskBytes = 96L * 1024L * 1024L,
memoryBytes = 21L * 1024L * 1024L,
),
),
actions = SettingsPanelActions(),
overlay = overlay,
@@ -62,12 +62,6 @@ class WhatsNewTest {
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
}
@Test
fun `known labels become their own chip`() {
assertEquals(ChangeLine("FIXED", "A thing."), changeLine("Fixed: A thing."))
assertEquals(ChangeLine("ADDED", "Another thing."), changeLine("Added: Another thing."))
}
@Test
fun `the bullet count is cut to what the screen can hold`() {
// A 720p television, which is the small case this exists for.
@@ -77,13 +71,4 @@ class WhatsNewTest {
// Never zero: a panel with a heading and nothing under it says nothing at all.
assertEquals(1, maxChangesFor(200))
}
@Test
fun `a sentence that merely contains a colon is left whole`() {
assertEquals(
ChangeLine(null, "Rename a TV in Settings: Devices."),
changeLine("Rename a TV in Settings: Devices."),
)
assertEquals(ChangeLine(null, "No label here."), changeLine("No label here."))
}
}