Release v0.2.34
This commit is contained in:
@@ -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 90–98% of runtime. Two of them carried "Opening Credits" at 0–1%, 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)
|
||||
|
||||
|
||||
+125
-18
@@ -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."))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user