App v0.2.27 and gateway 0.1.23
Skip Intro from Emby's own chapter markers, trickplay seek previews from BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and review scores, and My Alerts as its own page behind the user picker. Related titles now degrade at every step instead of returning empty, and the "+" is back on Manage users so a second viewer can be added from the launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4a4df7a73c
commit
80c304d86b
@@ -10,6 +10,8 @@ import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||
import com.ponzischeme89.memby.data.model.GatewayPreferences
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayIntro
|
||||
import com.ponzischeme89.memby.data.model.GatewayTrickplay
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -63,6 +65,49 @@ class GatewayPayloadTest {
|
||||
assertTrue(bare.membyRatings.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero row's caption and reason. Both are the gateway's wording, so this only has
|
||||
* to carry them through — and both must be absent-safe, because the direct path picks
|
||||
* the hero itself and every home cache written before the feature has neither.
|
||||
*/
|
||||
@Test
|
||||
fun `decodes the hero row's caption and reason and defaults them when absent`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows":[{
|
||||
"id":"hero",
|
||||
"title":"Featured",
|
||||
"kind":"hero",
|
||||
"items":[{
|
||||
"Id":"emby-9",
|
||||
"Name":"The Return",
|
||||
"Type":"Series",
|
||||
"MembyHeroLabel":"SERIES PREMIERE",
|
||||
"MembyHeroReason":"A new series premiered yesterday"
|
||||
}]
|
||||
}],
|
||||
"continueWatching":[],
|
||||
"nextUp":[],
|
||||
"favorites":[],
|
||||
"latestMovies":[],
|
||||
"partial":false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
|
||||
assertEquals("hero", row.kind)
|
||||
val item = row.items.single()
|
||||
assertEquals("SERIES PREMIERE", item.membyHeroLabel)
|
||||
assertEquals("A new series premiered yesterday", item.membyHeroReason)
|
||||
// A hero card is playable by construction; the field is defaulted so a payload
|
||||
// that omits it is not mistaken for a schedule card the viewer cannot press.
|
||||
assertTrue(item.membyPlayable)
|
||||
|
||||
val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
|
||||
assertEquals(null, bare.membyHeroLabel)
|
||||
assertEquals(null, bare.membyHeroReason)
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
@@ -424,6 +469,57 @@ class GatewayPayloadTest {
|
||||
assertEquals(false, playback.prerollEnabled)
|
||||
assertEquals(4_000L, playback.prerollDurationMs)
|
||||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||||
// Absent on a gateway that predates seek previews, and the default must stay false:
|
||||
// a missing field must never conjure a request the backend would answer with 404.
|
||||
assertEquals(false, playback.trickplayAvailable)
|
||||
// And the same for the skip button, for the same reason.
|
||||
assertEquals(false, playback.skipIntroAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes an intro segment`() {
|
||||
val intro = json.decodeFromString<GatewayIntro>(
|
||||
"""{"available":true,"startMs":463000,"endMs":583000}""",
|
||||
)
|
||||
|
||||
assertTrue(intro.available)
|
||||
assertEquals(463_000L, intro.startMs)
|
||||
assertEquals(583_000L, intro.endMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no intro markers decodes as unavailable`() {
|
||||
// An episode Emby has not analysed, a film, and a feature the operator has turned
|
||||
// off are all the same empty object — and all three mean no button.
|
||||
val intro = json.decodeFromString<GatewayIntro>("{}")
|
||||
|
||||
assertEquals(false, intro.available)
|
||||
assertEquals(0L, intro.startMs)
|
||||
assertEquals(0L, intro.endMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a seek preview layout`() {
|
||||
val trickplay = json.decodeFromString<GatewayTrickplay>(
|
||||
"""{"available":true,"intervalMs":10000,"count":817,"width":320,"height":172}""",
|
||||
)
|
||||
|
||||
assertTrue(trickplay.available)
|
||||
assertEquals(10_000L, trickplay.intervalMs)
|
||||
assertEquals(817, trickplay.count)
|
||||
assertEquals(320, trickplay.width)
|
||||
assertEquals(172, trickplay.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no seek previews decodes as unavailable`() {
|
||||
// The gateway answers a title with no thumbnails, a feature the operator has turned
|
||||
// off and an Emby that would not say with the same empty object. All three mean the
|
||||
// same thing to a television, and none of them is an error it should render.
|
||||
val trickplay = json.decodeFromString<GatewayTrickplay>("""{"available":false}""")
|
||||
|
||||
assertEquals(false, trickplay.available)
|
||||
assertEquals(0, trickplay.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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 intro rule, pinned against the same cases as the gateway's copy (`intro_test.go`).
|
||||
*
|
||||
* The two implementations exist separately because with no gateway there is nobody to ask,
|
||||
* and a skip must not land somewhere different depending on whether the container is up —
|
||||
* so when one of these changes, the other has to change with it.
|
||||
*
|
||||
* The cases are taken from what Emby actually writes for FROM: intro markers arrive as
|
||||
* ordinary chapters carrying a marker type, in playback order beside the real ones.
|
||||
*/
|
||||
class IntroTest {
|
||||
|
||||
private fun chapter(seconds: Long, marker: String) = EmbyChapter(
|
||||
startPositionTicks = seconds * 1_000L * 10_000L,
|
||||
markerType = marker,
|
||||
name = marker,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `finds the segment in a real episode`() {
|
||||
val segment = introSegmentFrom(
|
||||
listOf(
|
||||
chapter(0, "Chapter"),
|
||||
chapter(300, "Chapter"),
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
chapter(600, "Chapter"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(IntroSegment(startMs = 463_000L, endMs = 583_000L), segment)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an intro can begin at the very start of the file`() {
|
||||
val segment = introSegmentFrom(
|
||||
listOf(chapter(0, "IntroStart"), chapter(95, "IntroEnd")),
|
||||
)
|
||||
|
||||
// Which is exactly why availability is never inferred from a zero start.
|
||||
assertEquals(IntroSegment(startMs = 0L, endMs = 95_000L), segment)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode with no markers has no segment`() {
|
||||
assertNull(introSegmentFrom(listOf(chapter(0, "Chapter"), chapter(300, "Chapter"))))
|
||||
assertNull(introSegmentFrom(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `half a pair is not a segment`() {
|
||||
// There is no honest end to skip to, and guessing one is how a viewer lands in the
|
||||
// middle of a scene.
|
||||
assertNull(introSegmentFrom(listOf(chapter(112, "IntroStart"), chapter(300, "Chapter"))))
|
||||
assertNull(introSegmentFrom(listOf(chapter(0, "Chapter"), chapter(246, "IntroEnd"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pair in the wrong order is refused`() {
|
||||
assertNull(introSegmentFrom(listOf(chapter(300, "IntroStart"), chapter(120, "IntroEnd"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a segment too short to notice is refused`() {
|
||||
// A button that moves the picture imperceptibly reads as a broken button, so there
|
||||
// is deliberately no button at all.
|
||||
assertNull(introSegmentFrom(listOf(chapter(100, "IntroStart"), chapter(103, "IntroEnd"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a segment too long to be an intro is refused`() {
|
||||
// Far more likely two unrelated markers read as a range than a ten-minute title
|
||||
// sequence, and honouring it would throw the viewer past the story.
|
||||
assertNull(introSegmentFrom(listOf(chapter(60, "IntroStart"), chapter(660, "IntroEnd"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first start wins`() {
|
||||
// Two starts mean the markers are already untrustworthy, and taking the later one
|
||||
// would pick the larger, more damaging skip of the two.
|
||||
val segment = introSegmentFrom(
|
||||
listOf(
|
||||
chapter(100, "IntroStart"),
|
||||
chapter(160, "IntroStart"),
|
||||
chapter(220, "IntroEnd"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(IntroSegment(startMs = 100_000L, endMs = 220_000L), segment)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later pair is ignored once one has been found`() {
|
||||
val segment = introSegmentFrom(
|
||||
listOf(
|
||||
chapter(100, "IntroStart"),
|
||||
chapter(220, "IntroEnd"),
|
||||
chapter(1_800, "IntroStart"),
|
||||
chapter(1_900, "IntroEnd"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(IntroSegment(startMs = 100_000L, endMs = 220_000L), segment)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credit markers are not intros`() {
|
||||
assertNull(
|
||||
introSegmentFrom(
|
||||
listOf(chapter(2_800, "CreditsStart"), chapter(2_900, "CreditsEnd")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window holds the playhead only while the titles are running`() {
|
||||
val segment = IntroSegment(startMs = 60_000L, endMs = 180_000L)
|
||||
|
||||
assertFalse(segment.contains(59_999L))
|
||||
assertTrue(segment.contains(60_000L))
|
||||
assertTrue(segment.contains(179_999L))
|
||||
assertFalse(segment.contains(180_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tail keeps the offer off the last moment of the sequence`() {
|
||||
val segment = IntroSegment(startMs = 60_000L, endMs = 180_000L)
|
||||
|
||||
// Offering a two-second skip is offering nothing; the button has to disappear
|
||||
// before it becomes indistinguishable from doing nothing.
|
||||
assertTrue(segment.contains(177_000L, lead = 2_000L))
|
||||
assertFalse(segment.contains(178_500L, lead = 2_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown mode falls back to the button rather than to a silent skip`() {
|
||||
// The gateway's catalogue may grow a mode before every set in the house has the
|
||||
// release that understands it. Costing that set its button is recoverable; jumping
|
||||
// through somebody's episode on a value this build cannot read is not.
|
||||
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode("immediately"))
|
||||
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode(null))
|
||||
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode(""))
|
||||
assertEquals(SKIP_INTRO_AUTO, normalizeSkipIntroMode("auto"))
|
||||
// Case and whitespace are the operator's, not the viewer's problem.
|
||||
assertEquals(SKIP_INTRO_OFF, normalizeSkipIntroMode(" OFF "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The direct path's copy of the gateway's BIF reader. It exists so the two agree — the
|
||||
* matching Go tests are in `server/internal/trickplay/bif_test.go`, and the cases here are
|
||||
* deliberately the same ones. With no gateway there is nobody to ask, and a viewer must
|
||||
* not get different seek previews depending on whether the container is up.
|
||||
*/
|
||||
class TrickplayTest {
|
||||
|
||||
/**
|
||||
* Assembles a file in the shape Emby writes one, so the tests exercise the arithmetic
|
||||
* the real parser will meet rather than a convenient fiction.
|
||||
*/
|
||||
private fun buildBif(
|
||||
count: Int,
|
||||
multiplier: Long = 10_000L,
|
||||
frameSizes: List<Int> = emptyList(),
|
||||
): ByteArray {
|
||||
val length = bifIndexLength(count)
|
||||
val file = ByteArray(length)
|
||||
byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a).copyInto(file)
|
||||
writeLittleEndian(file, 12, count.toLong())
|
||||
writeLittleEndian(file, 16, multiplier)
|
||||
|
||||
var offset = length
|
||||
val body = ArrayList<Byte>()
|
||||
for (entry in 0 until count) {
|
||||
val at = BIF_HEADER_SIZE + entry * 8
|
||||
writeLittleEndian(file, at, entry.toLong())
|
||||
writeLittleEndian(file, at + 4, offset.toLong())
|
||||
val size = frameSizes.getOrElse(entry) { 100 }
|
||||
offset += size
|
||||
repeat(size) { body.add(0) }
|
||||
}
|
||||
val terminator = BIF_HEADER_SIZE + count * 8
|
||||
writeLittleEndian(file, terminator, 0xFFFFFFFFL)
|
||||
writeLittleEndian(file, terminator + 4, offset.toLong())
|
||||
return file + body.toByteArray()
|
||||
}
|
||||
|
||||
private fun writeLittleEndian(bytes: ByteArray, at: Int, value: Long) {
|
||||
for (i in 0..3) bytes[at + i] = ((value shr (8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private fun parsed(bytes: ByteArray): BifIndex =
|
||||
(parseBifIndex(bytes) as BifParse.Parsed).index
|
||||
|
||||
@Test
|
||||
fun `reads the layout Emby writes`() {
|
||||
// Emby 4.10 writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the
|
||||
// interval is ten seconds, and reading the multiplier as the interval would be
|
||||
// right only by accident. The multiplication is the part worth pinning.
|
||||
val index = parsed(buildBif(count = 3, frameSizes = listOf(500, 600, 700)))
|
||||
|
||||
assertEquals(3, index.count)
|
||||
assertEquals(10_000L, index.intervalMs)
|
||||
val frame = index.frame(1)!!
|
||||
assertEquals((bifIndexLength(3) + 500).toLong(), frame.first)
|
||||
assertEquals(600L, frame.last - frame.first + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no thumbnails is an answer, not a failure`() {
|
||||
// This is what Emby serves for a title it has not generated previews for: a
|
||||
// well-formed 72-byte header with a count of zero. It must read as "this title has
|
||||
// none", never as a broken file, or every such title looks like a fault.
|
||||
val index = parsed(buildBif(count = 0))
|
||||
|
||||
assertEquals(0, index.count)
|
||||
assertEquals(null, index.frame(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses what is not a BIF`() {
|
||||
// Emby's error pages come back on this route with a 200, so "not a BIF" is a real
|
||||
// answer the reader has to give rather than a theoretical one.
|
||||
assertEquals(
|
||||
BifParse.NotBif,
|
||||
parseBifIndex(("<html><body>Item not found</body></html>" + " ".repeat(64)).toByteArray()),
|
||||
)
|
||||
|
||||
val mangled = buildBif(count = 2)
|
||||
mangled[3] = 'X'.code.toByte()
|
||||
assertEquals(BifParse.NotBif, parseBifIndex(mangled))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses frames that point back into the index`() {
|
||||
// An offset inside the index would have the player decode a slice of the index
|
||||
// itself as a JPEG. Refuse the file rather than draw nonsense over somebody's film.
|
||||
val file = buildBif(count = 2)
|
||||
writeLittleEndian(file, BIF_HEADER_SIZE + 4, 8L)
|
||||
|
||||
assertEquals(BifParse.NotBif, parseBifIndex(file))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks for more rather than failing on a short read`() {
|
||||
// The index is read as a fixed window off the front of the file, so a long title
|
||||
// legitimately arrives cut short. That must be answerable — read this much and try
|
||||
// again — rather than looking like a bad file.
|
||||
val file = buildBif(count = 40)
|
||||
|
||||
val short = parseBifIndex(file.copyOfRange(0, BIF_HEADER_SIZE + 16))
|
||||
assertTrue(short is BifParse.NeedMore)
|
||||
assertEquals(bifIndexLength(40), (short as BifParse.NeedMore).bytes)
|
||||
|
||||
val header = parseBifIndex(file.copyOfRange(0, 20))
|
||||
assertEquals(BifParse.NeedMore(BIF_HEADER_SIZE), header)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frameAt clamps rather than refusing`() {
|
||||
// The preview is drawn while somebody is still moving a seek target about, so a
|
||||
// position past the last frame must show the last frame. Nothing is worse here than
|
||||
// the thumbnail blanking at exactly the end of a film.
|
||||
val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
|
||||
|
||||
assertEquals(0, track.frameAt(-5_000L))
|
||||
assertEquals(0, track.frameAt(0L))
|
||||
assertEquals(0, track.frameAt(9_999L))
|
||||
assertEquals(1, track.frameAt(10_000L))
|
||||
assertEquals(2, track.frameAt(25_000L))
|
||||
assertEquals(2, track.frameAt(9_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refuses a frame index out of range`() {
|
||||
val index = parsed(buildBif(count = 2))
|
||||
|
||||
for (n in listOf(-1, 2, 99)) {
|
||||
assertEquals("frame $n was served", null, index.frame(n))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to a sensible shape until the frames are measured`() {
|
||||
// The gateway measures the frames and says so; the direct path does not, and the
|
||||
// chip still has to be laid out at about the right size on the very first press.
|
||||
val unmeasured = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
|
||||
assertEquals(213, unmeasured.widthFor(120))
|
||||
|
||||
val measured = unmeasured.copy(width = 320, height = 172)
|
||||
assertEquals(120 * 320 / 172, measured.widthFor(120))
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class UserPreferencesTest {
|
||||
autoPlayNextEpisode = false,
|
||||
showTenMinuteReminder = false,
|
||||
seekIntervalSeconds = 30,
|
||||
skipIntroMode = SKIP_INTRO_AUTO,
|
||||
forYouMinutes = 60,
|
||||
homeRowOrder = listOf("recommended", "latest"),
|
||||
homePinnedRows = listOf("continue"),
|
||||
@@ -86,6 +87,27 @@ class UserPreferencesTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule for the intro mode, and the stake is higher: an unreadable value that
|
||||
* fell through to "auto" would jump through somebody's episode on the strength of a
|
||||
* string this build cannot parse. It falls back to the button instead.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown intro mode falls back to the button`() {
|
||||
assertEquals(
|
||||
DEFAULT_SKIP_INTRO_MODE,
|
||||
decodeUserPreferences(json("""{"skipIntroMode":"immediately"}""")).skipIntroMode,
|
||||
)
|
||||
assertEquals(
|
||||
SKIP_INTRO_AUTO,
|
||||
decodeUserPreferences(json("""{"skipIntroMode":"auto"}""")).skipIntroMode,
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_SKIP_INTRO_MODE,
|
||||
Settings(skipIntroMode = "sometimes").toUserPreferences().skipIntroMode,
|
||||
)
|
||||
}
|
||||
|
||||
/** An empty row selection would be a launcher with nothing on it. */
|
||||
@Test
|
||||
fun `an empty section list falls back rather than emptying the launcher`() {
|
||||
|
||||
@@ -79,6 +79,67 @@ class HomeMovieHeroScreenshotTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero the gateway composes. Two things are only checkable by looking: the reason
|
||||
* takes the synopsis's place rather than adding a line above the Play chip, and a
|
||||
* series premiere leading the launcher has to read as deliberate rather than as a TV
|
||||
* show that wandered into the movie hero.
|
||||
*/
|
||||
@Test
|
||||
fun `home hero composed by the gateway`() {
|
||||
capture(
|
||||
"df_home-movie-hero-server",
|
||||
listOf(
|
||||
HomeHeroPick(
|
||||
series(
|
||||
"The Quiet Coast",
|
||||
2026,
|
||||
"A harbour town's constable is the only one who noticed the tide change.",
|
||||
8.6,
|
||||
),
|
||||
"SERIES PREMIERE",
|
||||
"A new series premiered yesterday",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
|
||||
"NEW RELEASE",
|
||||
"Well reviewed, released on Monday",
|
||||
),
|
||||
HomeHeroPick(
|
||||
series("Harbour Lights", 2024, "The fourth season opens on an empty pier.", 8.1),
|
||||
"NEW SEASON",
|
||||
"A new season started today",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
|
||||
"HIGHLY RATED",
|
||||
"One of the best-reviewed titles in your library",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The reason must not be what breaks the layout the wrapping-title case pins. */
|
||||
@Test
|
||||
fun `home hero with a wrapping title and a reason`() {
|
||||
capture(
|
||||
"df_home-movie-hero-long-title-reason",
|
||||
listOf(
|
||||
HomeHeroPick(
|
||||
movie(
|
||||
"The Longest Northbound Winter",
|
||||
2026,
|
||||
"A cartographer chasing a river that no longer exists finds the last " +
|
||||
"village on the map still waiting for him.",
|
||||
8.4,
|
||||
),
|
||||
"NEW RELEASE",
|
||||
"Well reviewed, released yesterday",
|
||||
),
|
||||
) + movies.drop(1),
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(name: String, movies: List<HomeHeroPick>) {
|
||||
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
|
||||
.use(BitmapFactory::decodeStream)
|
||||
@@ -178,6 +239,9 @@ class HomeMovieHeroScreenshotTest {
|
||||
),
|
||||
)
|
||||
|
||||
private fun series(name: String, year: Int, overview: String, rating: Double) =
|
||||
movie(name, year, overview, rating).copy(type = "Series")
|
||||
|
||||
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
|
||||
id = name.lowercase().replace(' ', '-'),
|
||||
name = name,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.ponzischeme89.memby.data.localEpochDay
|
||||
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -66,6 +67,117 @@ class HomeMovieHeroTest {
|
||||
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
|
||||
}
|
||||
|
||||
// --- The gateway's hero ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The server can see Radarr's digital release dates, Sonarr's premieres and the
|
||||
* review scores; the television can see which shelf a title came off. So where the
|
||||
* gateway has composed a hero, its order wins outright — reordering it here could
|
||||
* only ever throw that evidence away.
|
||||
*/
|
||||
@Test
|
||||
fun `the server's hero row wins over the local rule`() {
|
||||
val browseRows = listOf(row("latest-movies", "Recently Added Movies", "local-1", "local-2"))
|
||||
val serverRows = listOf(
|
||||
heroRow(
|
||||
heroItem("server-1", "SERIES PREMIERE", "A new series premiered yesterday"),
|
||||
heroItem("server-2", "NEW RELEASE", "Well reviewed, released on Monday"),
|
||||
),
|
||||
)
|
||||
|
||||
val picks = selectHomeHeroMovies(browseRows, day = 5, serverRows = serverRows)
|
||||
|
||||
assertEquals(listOf("server-1", "server-2"), picks.map { it.item.id })
|
||||
assertEquals(listOf("SERIES PREMIERE", "NEW RELEASE"), picks.map { it.label })
|
||||
assertEquals("A new series premiered yesterday", picks.first().reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* No day rotation on the server's hero. The facts behind it already change daily, and
|
||||
* rotating a merit ranking is exactly how the best-reviewed release of the week ends
|
||||
* up in the fourth slot.
|
||||
*/
|
||||
@Test
|
||||
fun `the server's hero keeps its ranking on every day`() {
|
||||
val serverRows = listOf(
|
||||
heroRow(
|
||||
heroItem("first", "NEW RELEASE", null),
|
||||
heroItem("second", "NEW RELEASE", null),
|
||||
heroItem("third", "HIGHLY RATED", null),
|
||||
),
|
||||
)
|
||||
|
||||
(0L..7L).forEach { day ->
|
||||
assertEquals(
|
||||
"day $day",
|
||||
listOf("first", "second", "third"),
|
||||
selectHomeHeroMovies(emptyList(), day, serverRows).map { it.item.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The direct path has no gateway to ask, and an older one sends no hero row. */
|
||||
@Test
|
||||
fun `the local rule is the fallback when no hero row arrives`() {
|
||||
val browseRows = listOf(row("latest-movies", "Recently Added Movies", "new-1", "new-2"))
|
||||
|
||||
assertEquals(
|
||||
selectHomeHeroMovies(browseRows, day = 0),
|
||||
selectHomeHeroMovies(browseRows, day = 0, serverRows = listOf(heroRow())),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("new-1", "new-2"),
|
||||
selectHomeHeroMovies(browseRows, day = 0, serverRows = emptyList())
|
||||
.map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/** A card that cannot be pressed is news for the schedule row, not a hero. */
|
||||
@Test
|
||||
fun `the server's hero drops anything that is not playable`() {
|
||||
val serverRows = listOf(
|
||||
heroRow(
|
||||
heroItem("upcoming", "NEW RELEASE", null).copy(membyPlayable = false),
|
||||
heroItem("playable", "NEW RELEASE", null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("playable"),
|
||||
selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A caption is the gateway's wording, so this build must survive one it has never
|
||||
* heard of — and a card that somehow arrives with none falls back to the neutral
|
||||
* caption rather than claiming something nobody said.
|
||||
*/
|
||||
@Test
|
||||
fun `an unfamiliar or missing caption still draws a card`() {
|
||||
val serverRows = listOf(
|
||||
heroRow(
|
||||
heroItem("future", "STAFF PICK OF THE WEEK", null),
|
||||
heroItem("bare", null, null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("STAFF PICK OF THE WEEK", "FROM YOUR LIBRARY"),
|
||||
selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.label },
|
||||
)
|
||||
}
|
||||
|
||||
/** Four cards is what the hero draws, however many the row carries. */
|
||||
@Test
|
||||
fun `the server's hero is capped at four cards`() {
|
||||
val serverRows = listOf(
|
||||
heroRow(*(1..8).map { heroItem("item-$it", "NEW RELEASE", null) }.toTypedArray()),
|
||||
)
|
||||
|
||||
assertEquals(4, selectHomeHeroMovies(emptyList(), 0, serverRows).size)
|
||||
}
|
||||
|
||||
// --- Daily variants ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -180,6 +292,21 @@ class HomeMovieHeroTest {
|
||||
const val DAY_MS = 24L * HOUR_MS
|
||||
}
|
||||
|
||||
private fun heroItem(id: String, label: String?, reason: String?) = BaseItem(
|
||||
id = id,
|
||||
name = id,
|
||||
type = "Movie",
|
||||
membyHeroLabel = label,
|
||||
membyHeroReason = reason,
|
||||
)
|
||||
|
||||
private fun heroRow(vararg items: BaseItem) = HomeRow(
|
||||
id = "hero",
|
||||
title = "Featured",
|
||||
kind = SERVER_HERO_ROW_KIND,
|
||||
items = items.toList(),
|
||||
)
|
||||
|
||||
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
|
||||
id = id,
|
||||
title = title,
|
||||
|
||||
@@ -361,6 +361,27 @@ class ServerHomeRowsTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero row is the four featured cards above the shelves. HomeMovieHero consumes
|
||||
* it, so letting it through here would print the same four titles a second time as an
|
||||
* unnamed row of posters directly beneath the hero they are already in.
|
||||
*/
|
||||
@Test
|
||||
fun `the hero row is consumed by the hero, never drawn as a shelf`() {
|
||||
val rows = serverHomeRows(
|
||||
HomeUiState(
|
||||
rows = listOf(
|
||||
row("hero", SERVER_HERO_ROW_KIND, "featured"),
|
||||
row("latest-movies", "latest", "d"),
|
||||
),
|
||||
loading = emptySet(),
|
||||
),
|
||||
Settings(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("latest-movies"), rows.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown row kind from a newer server still renders`() {
|
||||
val rows = serverHomeRows(
|
||||
|
||||
@@ -37,4 +37,22 @@ class UserSwitcherNavigationTest {
|
||||
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
|
||||
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both pinned actions are reachable below the profiles`() {
|
||||
val profileCount = 3
|
||||
|
||||
// …the last profile, then My Alerts, then Manage users, and no further.
|
||||
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(3, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.UP, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned actions stay reachable with a single profile`() {
|
||||
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(2, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ponzischeme89.memby.ui.alerts
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AlertsFormatTest {
|
||||
@Test
|
||||
fun `no alerts wears no badge`() {
|
||||
assertNull(alertBadgeLabel(0))
|
||||
assertNull(alertBadgeLabel(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a small count is drawn as itself`() {
|
||||
assertEquals("1", alertBadgeLabel(1))
|
||||
assertEquals("9", alertBadgeLabel(9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a large count stops counting rather than widening the pill`() {
|
||||
assertEquals("9+", alertBadgeLabel(10))
|
||||
assertEquals("9+", alertBadgeLabel(240))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the summary counts alerts, and names the new ones only when there are some`() {
|
||||
assertEquals("Nothing waiting for you", alertsSummary(total = 0, unread = 0))
|
||||
assertEquals("1 alert", alertsSummary(total = 1, unread = 0))
|
||||
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0))
|
||||
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2))
|
||||
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.ponzischeme89.memby.ui.alerts
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
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
|
||||
|
||||
/**
|
||||
* Renders My Alerts to PNGs under `build/screenshots/my-alerts/`, so the page can be looked
|
||||
* at without deploying to a TV.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*AlertsPageScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* The empty case is the one worth keeping. This is a page whose whole job is emptying
|
||||
* itself, so what it looks like with nothing on it is the state a viewer reaches most often
|
||||
* and the only one a unit test cannot describe.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class AlertsPageScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `alerts waiting`() {
|
||||
capture("my-alerts-populated", sampleAlerts)
|
||||
}
|
||||
|
||||
/** Nothing new: the "NEW" flags are gone and the rows read as a list, not as news. */
|
||||
@Test
|
||||
fun `everything already read`() {
|
||||
capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing waiting`() {
|
||||
capture("my-alerts-empty", emptyList())
|
||||
}
|
||||
|
||||
/** Alerts switched off has its own empty wording — and both toggles read as off. */
|
||||
@Test
|
||||
fun `alerts switched off`() {
|
||||
capture(
|
||||
"my-alerts-disabled",
|
||||
emptyList(),
|
||||
NotificationPreferences(enabled = false, showReturnAlerts = false),
|
||||
)
|
||||
}
|
||||
|
||||
/** A long title and a two-line message: the row's own wrapping case. */
|
||||
@Test
|
||||
fun `long wording`() {
|
||||
capture(
|
||||
"my-alerts-long-wording",
|
||||
listOf(
|
||||
UserNotification(
|
||||
id = 1,
|
||||
kind = "series_return",
|
||||
title = "A Very Long Programme Title That Will Not Fit On One Line",
|
||||
message = "Season 4 of this show returns on Thursday, and the first two " +
|
||||
"episodes will be in Emby that morning if the download lands.",
|
||||
eventAt = "2026-08-13T08:30:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The way in: the user menu in the user picker, with the badge that replaced the bell
|
||||
* on the launcher. Captured here rather than beside the profile switcher's own tests
|
||||
* because the badge and the page are one feature — if they disagree about what counts,
|
||||
* this is the pair that shows it.
|
||||
*/
|
||||
@Test
|
||||
fun `user menu carrying the badge`() {
|
||||
capturePicker("my-alerts-user-menu", alertCount = 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user menu with nothing waiting`() {
|
||||
capturePicker("my-alerts-user-menu-quiet", alertCount = 0)
|
||||
}
|
||||
|
||||
/** Past the cap the badge stops counting rather than widening the row. */
|
||||
@Test
|
||||
fun `user menu with a great many alerts`() {
|
||||
capturePicker("my-alerts-user-menu-many", alertCount = 42)
|
||||
}
|
||||
|
||||
private fun capturePicker(name: String, alertCount: Int) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
UserSwitcherOverlay(
|
||||
profiles = listOf(
|
||||
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
|
||||
EmbyProfile("b", "https://memby.example", "t", "u2", "Charlotte"),
|
||||
),
|
||||
activeProfileId = "a",
|
||||
onProfileSelected = {},
|
||||
onManageProfiles = {},
|
||||
onDismiss = {},
|
||||
alertCount = alertCount,
|
||||
onOpenAlerts = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
|
||||
}
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
notifications: List<UserNotification>,
|
||||
preferences: NotificationPreferences = NotificationPreferences(),
|
||||
) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
MyAlertsPage(
|
||||
notifications = notifications,
|
||||
preferences = preferences,
|
||||
onToggleEnabled = {},
|
||||
onToggleShowReturns = {},
|
||||
onRead = {},
|
||||
onDismiss = {},
|
||||
onDismissAll = {},
|
||||
onClose = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
|
||||
}
|
||||
|
||||
private val sampleAlerts = listOf(
|
||||
UserNotification(
|
||||
id = 1,
|
||||
kind = "series_return",
|
||||
title = "Northbound returns on Thursday",
|
||||
message = "Season 3 starts on 13 August. Memby will add it as soon as it lands.",
|
||||
eventAt = "2026-08-13T20:30:00Z",
|
||||
),
|
||||
UserNotification(
|
||||
id = 2,
|
||||
kind = "series_return",
|
||||
title = "The Long Dark is back",
|
||||
message = "Season 2 started yesterday and the first episode is in Emby now.",
|
||||
eventAt = "2026-08-06T20:00:00Z",
|
||||
),
|
||||
UserNotification(
|
||||
id = 3,
|
||||
kind = "library",
|
||||
title = "24 titles added",
|
||||
message = "Memby has finished refreshing your library.",
|
||||
readAt = "2026-08-05T11:00:00Z",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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.graphics.drawable.GradientDrawable
|
||||
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 com.ponzischeme89.memby.data.Trickplay
|
||||
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 seek chip with and without a preview frame, captured from the real player XML at TV
|
||||
* resolution → `build/screenshots/seek-indicator/`.
|
||||
*
|
||||
* The case worth having is the *empty* one. A preview is a decoration on an indicator that
|
||||
* has always worked without it — a title with no thumbnails, a gateway that will not answer
|
||||
* and the moment before the first frame arrives all have to leave the chip looking exactly
|
||||
* as it did before this existed, and that is a property no assertion can check.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SeekIndicatorScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `skipping forward with a preview frame`() {
|
||||
capture(
|
||||
name = "seek-forward-with-preview",
|
||||
amount = "Forward 1 min 30 secs",
|
||||
position = "1:12:40 / 2:04:00",
|
||||
forward = true,
|
||||
withPreview = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipping back with a preview frame`() {
|
||||
capture(
|
||||
name = "seek-back-with-preview",
|
||||
amount = "Back 30 seconds",
|
||||
position = "0:41:10 / 2:04:00",
|
||||
forward = false,
|
||||
withPreview = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no previews keeps the chip it always had`() {
|
||||
capture(
|
||||
name = "seek-forward-no-preview",
|
||||
amount = "Forward 30 seconds",
|
||||
position = "1:12:40 / 2:04:00",
|
||||
forward = true,
|
||||
withPreview = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
amount: String,
|
||||
position: String,
|
||||
forward: Boolean,
|
||||
withPreview: Boolean,
|
||||
) {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity).apply {
|
||||
background = GradientDrawable(
|
||||
GradientDrawable.Orientation.TL_BR,
|
||||
intArrayOf(Color.rgb(42, 57, 70), Color.rgb(16, 25, 33), Color.rgb(4, 8, 12)),
|
||||
)
|
||||
}
|
||||
val chip = LayoutInflater.from(activity).inflate(R.layout.player_seek_indicator, root, false)
|
||||
chip.visibility = View.VISIBLE
|
||||
chip.findViewById<ImageView>(R.id.player_seek_glyph).setImageResource(
|
||||
if (forward) R.drawable.ic_player_forward else R.drawable.ic_player_rewind,
|
||||
)
|
||||
chip.findViewById<TextView>(R.id.player_seek_amount).text = amount
|
||||
chip.findViewById<TextView>(R.id.player_seek_position).text = position
|
||||
|
||||
if (withPreview) {
|
||||
val preview = chip.findViewById<ImageView>(R.id.player_seek_preview)
|
||||
// Sized the way the real one is: from the frames' own shape rather than the
|
||||
// 16:9 placeholder the layout carries, which is what stops a 320x172 thumbnail
|
||||
// being letterboxed inside its own box.
|
||||
val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 700, width = 320, height = 172)
|
||||
preview.layoutParams = preview.layoutParams.apply {
|
||||
width = track.widthFor(height)
|
||||
}
|
||||
preview.setImageBitmap(thumbnail())
|
||||
preview.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
root.addView(chip)
|
||||
activity.setContentView(root)
|
||||
root.captureRoboImage("build/screenshots/seek-indicator/$name.png")
|
||||
}
|
||||
|
||||
/** Stands in for a frame of a film: the point is the chip around it, not the picture. */
|
||||
private fun thumbnail(): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(320, 172, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bitmap).drawPaint(
|
||||
Paint().apply {
|
||||
shader = LinearGradient(
|
||||
0f, 0f, 320f, 172f,
|
||||
intArrayOf(Color.rgb(96, 84, 62), Color.rgb(38, 44, 58), Color.rgb(10, 12, 18)),
|
||||
null,
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The figure inside the countdown ring.
|
||||
*
|
||||
* Pure, and worth pinning separately from the view that draws it: the ring is about 30dp
|
||||
* across, so the difference between "118" and "1:58" is the difference between a figure
|
||||
* that fits and one that prints over its own arc.
|
||||
*/
|
||||
class SkipIntroCountdownTest {
|
||||
|
||||
@Test
|
||||
fun `seconds under a minute, minutes and seconds over it`() {
|
||||
assertEquals("59", formatRemaining(59_000L))
|
||||
assertEquals("1:00", formatRemaining(60_000L))
|
||||
assertEquals("1:58", formatRemaining(118_000L))
|
||||
assertEquals("2:13", formatRemaining(133_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a part second still counts as a second until it is gone`() {
|
||||
// Rounded up, so the ring never shows a figure the viewer has already spent. "0"
|
||||
// belongs to the moment the offer lapses and to nothing before it.
|
||||
assertEquals("7", formatRemaining(6_400L))
|
||||
assertEquals("1", formatRemaining(1L))
|
||||
assertEquals("0", formatRemaining(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a position past the end never draws a negative figure`() {
|
||||
// The playhead legitimately overshoots between ticks, and a ring reading "-1"
|
||||
// would be the last thing a viewer saw of this feature.
|
||||
assertEquals("0", formatRemaining(-500L))
|
||||
assertEquals("0", formatRemaining(-90_000L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
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 skip-intro offer over a stand-in for a film, captured from the real player XML at TV
|
||||
* resolution → `build/screenshots/skip-intro/`.
|
||||
*
|
||||
* The background is fake on purpose and its only job is to be *bright*: this button sits on
|
||||
* somebody's episode with no scrim and no panel behind it, so the thing worth looking at is
|
||||
* whether it still reads against a lit scene. A capture over black would prove nothing —
|
||||
* everything reads against black.
|
||||
*
|
||||
* Both focus states are captured because the button is focusable and takes focus as it
|
||||
* appears, and the pill inverts to white when it does — which the ring inside it has to
|
||||
* follow, or it draws white on white. That inversion is the case a unit test cannot see.
|
||||
* The countdown is captured at both ends of an opening: a two-minute figure, which is the
|
||||
* ordinary case for a title sequence and the one that has to fit inside the ring, and a
|
||||
* few seconds left, where the arc is nearly gone.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SkipIntroScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the offer as a viewer sees it`() {
|
||||
capture(
|
||||
name = "skip-intro-focused",
|
||||
label = R.string.player_skip_intro,
|
||||
focused = true,
|
||||
remainingMs = 118_000L,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the offer the moment it lands`() {
|
||||
capture(
|
||||
name = "skip-intro-idle",
|
||||
label = R.string.player_skip_intro,
|
||||
focused = false,
|
||||
remainingMs = 118_000L,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last seconds of the offer`() {
|
||||
// The arc is nearly spent and the figure has dropped to a single digit. It reaches
|
||||
// zero exactly as the button goes, which is the whole reason the ring counts the
|
||||
// offer rather than the title sequence.
|
||||
capture(
|
||||
name = "skip-intro-running-out",
|
||||
label = R.string.player_skip_intro,
|
||||
focused = true,
|
||||
remainingMs = 7_000L,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an automatic skip says what it did`() {
|
||||
// The same view in the same corner, wearing the timing cues' quiet plate instead of
|
||||
// the green pill, with no ring and never focused. This is the case worth looking at:
|
||||
// a notice that still reads as a button is a viewer pressing it to find out what it
|
||||
// does, and a ring on it would be a countdown to nothing.
|
||||
capture(
|
||||
name = "skip-intro-automatic-notice",
|
||||
label = R.string.player_skip_intro_skipped,
|
||||
focused = false,
|
||||
remainingMs = 0L,
|
||||
asNotice = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
label: Int,
|
||||
focused: Boolean,
|
||||
remainingMs: Long,
|
||||
asNotice: Boolean = false,
|
||||
) {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity).apply { background = fakeScene() }
|
||||
val offer = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.player_skip_intro, root, false)
|
||||
offer.visibility = View.VISIBLE
|
||||
|
||||
offer.findViewById<TextView>(R.id.player_skip_intro_label).apply {
|
||||
setText(label)
|
||||
if (asNotice) setTextColor(Color.WHITE)
|
||||
}
|
||||
offer.findViewById<SkipIntroCountdownView>(R.id.player_skip_intro_countdown).apply {
|
||||
isVisible = !asNotice
|
||||
// A whole FROM opening, so the arc and the figure are at the size they run at.
|
||||
setRemaining(remainingMs, OFFER_LENGTH_MS)
|
||||
}
|
||||
// The same two lines PlayerActivity's dressSkipIntro applies, so the capture is of
|
||||
// the real notice rather than of a button with different words in it.
|
||||
offer.findViewById<View>(R.id.player_skip_intro_button).apply {
|
||||
if (asNotice) setBackgroundResource(R.drawable.time_remaining_cue_background)
|
||||
isFocusable = !asNotice
|
||||
isFocusableInTouchMode = !asNotice
|
||||
if (focused) requestFocus()
|
||||
}
|
||||
|
||||
root.addView(offer)
|
||||
activity.setContentView(root)
|
||||
root.captureRoboImage("build/screenshots/skip-intro/$name.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a frame of an episode: a lit corner where the button sits, so the
|
||||
* capture answers "is this still legible over picture" rather than over a flat colour.
|
||||
*/
|
||||
private fun fakeScene(): Drawable = object : GradientDrawable(
|
||||
Orientation.TL_BR,
|
||||
intArrayOf(Color.rgb(28, 46, 66), Color.rgb(74, 62, 44), Color.rgb(150, 132, 96)),
|
||||
) {
|
||||
override fun draw(canvas: Canvas) {
|
||||
super.draw(canvas)
|
||||
val width = bounds.width().toFloat()
|
||||
val height = bounds.height().toFloat()
|
||||
// A bright pool of light behind the bottom-end corner — the hardest case for a
|
||||
// button with no scrim under it.
|
||||
canvas.drawPaint(
|
||||
Paint().apply {
|
||||
shader = RadialGradient(
|
||||
width * 0.78f, height * 0.74f, width * 0.42f,
|
||||
intArrayOf(Color.argb(210, 255, 238, 205), Color.TRANSPARENT),
|
||||
null,
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
// And a soft horizon, so the frame reads as a scene rather than as a swatch.
|
||||
canvas.drawRect(
|
||||
0f, height * 0.62f, width, height,
|
||||
Paint().apply {
|
||||
shader = LinearGradient(
|
||||
0f, height * 0.62f, 0f, height,
|
||||
Color.argb(120, 12, 16, 22), Color.argb(220, 6, 8, 12),
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** An episode of FROM: a two-minute opening, less the tail the offer stops short of. */
|
||||
const val OFFER_LENGTH_MS = 118_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user