Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
@@ -0,0 +1,48 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import org.junit.Assert.assertTrue
import org.junit.Test
class DevicePlaybackCapabilitiesTest {
@Test
fun detailedH264AndHevcDecoderSupportIsReportedToTheGateway() {
val capabilities = DevicePlaybackCapabilities(
h264 = VideoDecoderCapabilities(
supported = true,
profiles = setOf("baseline", "main", "high"),
mainLevel = 52,
maxWidth = 3840,
maxHeight = 2160,
),
hevc = VideoDecoderCapabilities(
supported = true,
profiles = setOf("main", "main10"),
mainLevel = 153,
tenBitLevel = 153,
maxWidth = 3840,
maxHeight = 2160,
hdr10 = true,
),
)
val tokens = capabilities.gatewayCapabilityTokens()
assertTrue("video_h264_profile_high" in tokens)
assertTrue("video_h264_level_52" in tokens)
assertTrue("video_h264_max_3840x2160" in tokens)
assertTrue("video_hevc_decode" in tokens)
assertTrue("video_hevc_profile_main10" in tokens)
assertTrue("video_hevc_main10_level_153" in tokens)
assertTrue("video_hevc_hdr10" in tokens)
}
@Test
fun h264OnlyDeviceDoesNotClaimHevc() {
val tokens = DevicePlaybackCapabilities().gatewayCapabilityTokens()
assertTrue("video_h264_decode" in tokens)
assertTrue("video_hevc_decode" !in tokens)
}
}
@@ -1,10 +1,14 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -19,6 +23,17 @@ import org.junit.Test
* side, this fails before a TV ever sees it.
*/
class GatewayPayloadTest {
@Test
fun `decodes signed in devices without an allowance`() {
val response = json.decodeFromString<GatewayDevices>(
"""{"devices":[{"deviceId":"tv-1","deviceName":"Living room","clientVersion":"0.2.4","lastSeenAt":"2026-08-02T10:00:00Z","current":true}]}""",
)
assertEquals(1, response.devices.size)
assertEquals("Living room", response.devices.single().deviceName)
assertTrue(response.devices.single().current)
}
private val json = Json {
ignoreUnknownKeys = true
@@ -38,6 +53,40 @@ class GatewayPayloadTest {
assertEquals(1, status.serverProtocol)
}
@Test
fun `decodes live feature policy and recovery state`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""",
)
val policy = json.decodeFromString<GatewayFeatures>(
"""{"schemaVersion":1,"revision":7,"safeMode":true,"canRollback":true,"features":[{"key":"sonarr_preroll","name":"Sonarr upcoming preroll","enabled":false,"source":"safe_mode","compatible":true}]}""",
)
assertEquals(7L, status.featureRevision)
assertEquals(false, status.features["sonarr_preroll"])
assertTrue(policy.safeMode)
assertTrue(policy.canRollback)
assertEquals("safe_mode", policy.features.single().source)
}
@Test
fun `decodes recommendation onboarding ratings and emby items`() {
val onboarding = json.decodeFromString<RecommendationOnboarding>(
"""{
"completed":false,
"ratings":{"movie-1":5},
"items":[
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
{"Id":"series-1","Name":"Severance","Type":"Series"}
]
}""",
)
assertEquals(false, onboarding.completed)
assertEquals(5, onboarding.ratings["movie-1"])
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
}
@Test
fun `decodes a home payload with emby-shaped items`() {
val payload = """
@@ -119,12 +168,12 @@ class GatewayPayloadTest {
}
@Test
fun `decodes the informational Sonarr schedule row`() {
fun `decodes the informational TV schedule row`() {
val payload = """
{
"rows": [{
"id":"sonarr-airing-today",
"title":"Shows airing today",
"title":"Shows airing in the next 5 days",
"kind":"schedule",
"items":[{
"Id":"sonarr:7:42",
@@ -134,7 +183,8 @@ class GatewayPayloadTest {
"MembySource":"sonarr",
"MembyEpisodeTitle":"The Crossing",
"MembyEpisodeCode":"S02E04",
"MembyAirLabel":"Airs today at 8:00 PM",
"MembyAirDayLabel":"Tomorrow",
"MembyAirLabel":"Tomorrow: 8:00 PM",
"MembyAvailability":"downloading",
"MembyAvailabilityText":"Downloading",
"MembyPlayable":false
@@ -150,12 +200,48 @@ class GatewayPayloadTest {
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
assertTrue(item.isSonarrSchedule)
assertTrue(item.isTvSchedule)
assertEquals("S02E04", item.membyEpisodeCode)
assertEquals("Tomorrow", item.membyAirDayLabel)
assertEquals("Downloading", item.membyAvailabilityText)
assertEquals(false, item.membyPlayable)
}
@Test
fun `decodes the informational Radarr digital release row`() {
val payload = """
{
"rows":[{
"id":"radarr-upcoming-movies",
"title":"Upcoming Movie releases",
"kind":"movie-schedule",
"items":[{
"Id":"radarr:7",
"Name":"Arrival",
"Type":"MembyRadarrMovie",
"ImageTags":{"Primary":"radarr"},
"MembySource":"radarr",
"MembyAirsAt":"2026-08-01T00:00:00+12:00",
"MembyAirDayLabel":"Saturday",
"MembyAirLabel":"Digital release Saturday",
"MembyAvailability":"upcoming",
"MembyAvailabilityText":"Upcoming digital release",
"MembyPlayable":false
}]
}]
}
""".trimIndent()
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
val item = row.items.single()
assertEquals("movie-schedule", row.kind)
assertTrue(item.isMovieSchedule)
assertTrue(item.isSchedule)
assertEquals("Digital release Saturday", item.membyAirLabel)
assertEquals(false, item.membyPlayable)
}
@Test
fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an
@@ -186,13 +272,26 @@ class GatewayPayloadTest {
@Test
fun `decodes a playback response`() {
val playback = json.decodeFromString<GatewayPlayback>(
"""{"itemId":"9","title":"Severance Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
"""{"itemId":"9","title":"Severance Pilot","overview":"Mark returns to the severed floor.","seriesName":"Severance","episodeCode":"S01E01","runtimeMs":3420000,"prerollEnabled":false,"prerollDurationMs":4000,"url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
)
assertEquals("9", playback.itemId)
assertEquals(42_000L, playback.resumePositionMs)
assertEquals("S01E01", playback.episodeCode)
assertEquals(3_420_000L, playback.runtimeMs)
assertEquals(false, playback.prerollEnabled)
assertEquals(4_000L, playback.prerollDurationMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
@Test
fun `decodes a one-time auto-follow acknowledgement`() {
val response = json.decodeFromString<GatewayPlaybackReportResponse>(
"""{"autoFollowedShowTitle":"Severance"}""",
)
assertEquals("Severance", response.autoFollowedShowTitle)
}
@Test
fun `decodes the next-episode response the player counts down to`() {
val next = json.decodeFromString<GatewayNextEpisode>(
@@ -235,13 +334,4 @@ class GatewayPayloadTest {
assertNull(next.item.episodeCode)
}
@Test
fun `device allowance response becomes a specific sign-in error`() {
val error = parseDeviceLimit(
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
)
assertEquals(3, error?.activeClients)
assertEquals(3, error?.maxClients)
}
}
@@ -30,12 +30,14 @@ class GatewayUpdateTest {
@Test
fun `an optional verdict is dismissable`() {
val update = json.decodeFromString<GatewayUpdate>(
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk","sha256":"abc","sizeBytes":123}""",
)
assertTrue(update.isOptional)
assertFalse(update.isMandatory)
assertTrue(update.isActionable)
assertEquals("abc", update.sha256)
assertEquals(123L, update.sizeBytes)
}
@Test
@@ -0,0 +1,51 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PlaybackDeliveryTest {
@Test
fun directPlayUsesOriginalOnlyWhenServerMarksItSupported() {
val delivery = selectPlaybackDelivery(
MediaSourceInfo(
supportsDirectPlay = true,
directStreamUrl = "/Videos/item/stream.mkv",
transcodingUrl = "/Videos/item/master.m3u8",
),
)
assertNull(delivery.url)
assertEquals("DirectPlay", delivery.playMethod)
}
@Test
fun negotiatedDirectStreamIsUsedForContainerCompatibility() {
val delivery = selectPlaybackDelivery(
MediaSourceInfo(
supportsDirectPlay = false,
supportsDirectStream = true,
directStreamUrl = "/Videos/item/stream.mp4",
transcodingUrl = "/Videos/item/master.m3u8",
),
)
assertEquals("/Videos/item/stream.mp4", delivery.url)
assertEquals("DirectStream", delivery.playMethod)
}
@Test
fun forcedFallbackUsesTranscodeEvenWhenDirectPlayWasAdvertised() {
val delivery = selectPlaybackDelivery(
MediaSourceInfo(
supportsDirectPlay = true,
transcodingUrl = "/Videos/item/master.m3u8",
),
forceTranscode = true,
)
assertEquals("/Videos/item/master.m3u8", delivery.url)
assertEquals("Transcode", delivery.playMethod)
}
}
@@ -1,7 +1,11 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackReportMathTest {
@@ -26,4 +30,45 @@ class PlaybackReportMathTest {
assertEquals("Encode", profiles["pgssub"])
assertEquals("Encode", profiles["dvdsub"])
}
@Test
fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() {
val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single()
val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single()
assertEquals("h264", baseline.videoCodec)
assertEquals("h264,hevc", capable.videoCodec)
assertEquals("aac,mp3", capable.audioCodec)
assertFalse(capable.videoCodec.contains("av1"))
assertFalse(capable.audioCodec.contains("eac3"))
}
@Test
fun detailedProfileAllowsVideoStreamCopyWithinDecoderLimits() {
val profile = DeviceProfile.embyAndroidTv(
DevicePlaybackCapabilities(
h264 = VideoDecoderCapabilities(
supported = true,
profiles = setOf("baseline", "main", "high"),
mainLevel = 52,
maxWidth = 3840,
maxHeight = 2160,
),
hevc = VideoDecoderCapabilities(
supported = true,
profiles = setOf("main", "main10"),
mainLevel = 153,
tenBitLevel = 153,
maxWidth = 3840,
maxHeight = 2160,
),
),
)
assertEquals("h264,hevc", profile.directPlayProfiles.single().videoCodec)
assertEquals("h264,hevc", profile.transcodingProfiles.single().videoCodec)
assertTrue(profile.codecProfiles.any { it.codec == "h264" && it.conditions.any { c -> c.value == "52" } })
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.value == "153" } })
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.property == "Width" } })
}
}
@@ -53,4 +53,17 @@ class ProfileSettingsTest {
assertEquals(30, family.forYouMinutes)
assertEquals(false, family.hasOpenedForYou)
}
@Test
fun `welcome quote style belongs to each profile`() {
val matt = profile.copy(welcomeQuoteStyle = "homicidal")
val family = profile.copy(
id = "server::family",
userId = "family",
welcomeQuoteStyle = "positive",
)
assertEquals("homicidal", matt.welcomeQuoteStyle)
assertEquals("positive", family.welcomeQuoteStyle)
}
}
@@ -73,6 +73,19 @@ class RowAnalyticsTest {
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
}
@Test
fun `visible posters receive deduplicated item impressions`() {
val collector = analytics(FakeClock())
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
val itemImpressions = collector.drain().filter {
it.event == RowAnalytics.EVENT_IMPRESSION && it.itemId.isNotEmpty()
}
assertEquals(listOf("one", "two"), itemImpressions.map { it.itemId })
}
@Test
fun `focusing a row that was never reported still records the impression`() {
val collector = analytics(FakeClock())
@@ -85,5 +85,28 @@ class ServiceAlertTest {
assertEquals("sonarr:7:42:aired", alert.id)
assertEquals("sonarr:7:42", alert.itemId)
assertEquals("sonarr", alert.imageTag)
// A gateway that predates server-worded banners sends no label at all.
assertEquals("", alert.label)
}
@Test
fun `status decodes a radarr import alert with its own wording`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""
{"maintenance":false,"message":"","alerts":[{
"id":"radarr:412:file:9001","kind":"radarr-import","label":"NEW MOVIE ADDED",
"title":"Mr. Smith Goes to Washington (1939)",
"message":"Mr. Smith Goes to Washington will be available in Emby shortly.",
"itemId":"radarr:412","imageTag":"radarr","airedAt":"2026-07-31T19:04:00Z"
}]}
""".trimIndent(),
)
val alert = status.alerts.single()
assertEquals("radarr:412:file:9001", alert.id)
assertEquals("NEW MOVIE ADDED", alert.label)
// The image proxy serves Radarr covers under this pair, so the banner has a
// poster before Emby has scanned the film in.
assertEquals("radarr:412", alert.itemId)
assertEquals("radarr", alert.imageTag)
}
}
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.data
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
class SessionValidationTest {
@Test
fun `server restart preserves saved session`() {
assertTrue(shouldPreserveSessionAfterValidationFailure(IOException("offline")))
}
@Test
fun `maintenance response preserves saved session`() {
assertTrue(shouldPreserveSessionAfterValidationFailure(httpFailure(503)))
}
@Test
fun `explicit unauthorized response may invalidate saved session`() {
assertFalse(shouldPreserveSessionAfterValidationFailure(httpFailure(401)))
}
private fun httpFailure(code: Int): HttpException =
HttpException(Response.error<Any>(code, "error".toResponseBody()))
}
@@ -0,0 +1,46 @@
package com.ponzischeme89.memby.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SettingsStoreProfileRemovalTest {
@Test
fun `ten minute reminder preference is persisted`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SettingsStore(context)
store.setShowTenMinuteReminder(false)
assertFalse(store.snapshot().showTenMinuteReminder)
}
@Test
fun `removing profiles preserves other users and clears only an active session`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SettingsStore(context)
store.saveSession("https://example.test", "token-a", "user-a", "Alex", "server")
val alex = store.snapshot().profiles.single()
store.saveSession("https://example.test", "token-b", "user-b", "Bailey", "server")
val bailey = store.snapshot().profiles.single { it.userId == "user-b" }
store.removeProfile(alex.id)
val afterInactiveRemoval = store.snapshot()
assertEquals(listOf(bailey.id), afterInactiveRemoval.profiles.map { it.id })
assertTrue(afterInactiveRemoval.isSignedIn)
assertEquals("user-b", afterInactiveRemoval.userId)
store.removeProfile(bailey.id)
val afterActiveRemoval = store.snapshot()
assertTrue(afterActiveRemoval.profiles.isEmpty())
assertFalse(afterActiveRemoval.isSignedIn)
}
}
@@ -4,12 +4,13 @@ import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringTodayTagsTest {
@Test
fun `tags matching recommended series from today's Sonarr schedule`() {
fun `tags matching recommended series from today's TV schedule`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
@@ -22,6 +23,7 @@ class AiringTodayTagsTest {
name = "The Bear",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
),
),
@@ -56,6 +58,7 @@ class AiringTodayTagsTest {
name = "Marvel's DAREDEVIL",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
),
),
@@ -85,6 +88,7 @@ class AiringTodayTagsTest {
name = "Fargo",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
),
),
@@ -98,4 +102,80 @@ class AiringTodayTagsTest {
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
}
@Test
fun `only today's scheduled show is propagated to other rows`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing in the next 5 days",
kind = "schedule",
items = listOf(
BaseItem(
id = "sonarr:1:1",
name = "Today Show",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
BaseItem(
id = "sonarr:2:1",
name = "Tomorrow Show",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Tomorrow",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
items = listOf(
BaseItem(id = "series-1", name = "Today Show", type = "Series"),
BaseItem(id = "series-2", name = "Tomorrow Show", type = "Series"),
),
),
),
)
val recommendations = home.withAiringTodayTags().rows.last().items
assertTrue(recommendations[0].membyAiringToday)
assertFalse(recommendations[1].membyAiringToday)
}
@Test
fun `schedule poster badges use each server-authored day`() {
fun scheduled(day: String) = BaseItem(
id = day,
name = "Show",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = day,
membyAiringToday = true,
)
assertEquals("TODAY", airingBadgeLabel(scheduled("Today")))
assertEquals("TOMORROW", airingBadgeLabel(scheduled("Tomorrow")))
assertEquals("FRIDAY", airingBadgeLabel(scheduled("Friday")))
}
@Test
fun `Radarr movie schedule uses its server-authored release day badge`() {
val movie = BaseItem(
id = "radarr:7",
name = "Arrival",
type = "MembyRadarrMovie",
membySource = "radarr",
membyAirDayLabel = "Saturday",
)
assertEquals("SATURDAY", airingBadgeLabel(movie))
}
@Test
fun `upcoming schedule status does not claim the show airs today`() {
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
}
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.detail.franchiseStart
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DetailFranchiseTest {
private fun movie(id: String, year: Int, collection: String? = "The Saga") = BaseItem(
id = id,
name = "Movie $id",
type = "Movie",
productionYear = year,
collectionName = collection,
)
@Test
fun `earliest movie in the same collection starts the franchise`() {
val current = movie("three", 2022)
val start = franchiseStart(
current,
listOf(movie("two", 2018), movie("one", 2014), movie("other", 2001, "Other")),
)
assertEquals("The Saga", start?.name)
assertEquals("one", start?.firstMovie?.id)
}
@Test
fun `a collection name without a sibling is not presented as a franchise`() {
assertNull(franchiseStart(movie("only", 2020), emptyList()))
}
}
@@ -0,0 +1,121 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.detail.DetailPosition
import com.ponzischeme89.memby.ui.detail.DetailPositionStore
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The two rules the detail pages depend on and cannot check on a device: the tab strip is
* decided by what the item *is*, and a page's position survives being closed.
*/
class DetailNavigationTest {
@Test
fun `a movie always offers the same three tabs`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = false),
)
}
@Test
fun `a series always offers episodes`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = true),
)
}
/**
* The regression this replaced: the strip used to be built from what had loaded, so a
* movie opened with one tab and grew two more when its metadata arrived — moving the
* strip under whatever the viewer was already pressing.
*/
@Test
fun `the strip does not depend on loaded metadata`() {
assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false))
assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true))
}
@Test
fun `an episodes key remembered from a series falls back on a movie`() {
assertEquals(
DetailTab.OVERVIEW,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)),
)
assertEquals(
DetailTab.EPISODES,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)),
)
}
@Test
fun `an unknown key falls back to overview`() {
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true)))
}
@Test
fun `a page reopens where it was left`() {
val store = DetailPositionStore()
store.update("show-1") {
it.copy(tabKey = DetailTab.EPISODES.key, season = 3, zone = DetailZone.CONTENT)
}
store.update("show-1") { it.copy(episodeIndex = 6) }
assertEquals(
DetailPosition(
tabKey = DetailTab.EPISODES.key,
season = 3,
zone = DetailZone.CONTENT,
episodeIndex = 6,
),
store.get("show-1"),
)
}
@Test
fun `an unvisited page opens on overview with focus on play`() {
val position = DetailPositionStore().get("never-opened")
assertEquals(DetailTab.OVERVIEW.key, position.tabKey)
assertEquals(DetailZone.PLAY, position.zone)
assertEquals(null, position.season)
}
@Test
fun `play focus pins the complete hero while lower zones release scrolling`() {
assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY))
assertEquals(null, detailHeroScrollTarget(DetailZone.TABS))
assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT))
assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED))
}
@Test
fun `the store is capped and keeps the most recently used`() {
val store = DetailPositionStore(maxEntries = 3)
listOf("a", "b", "c").forEach { id ->
store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
}
// Touching "a" makes it the newest, so "b" is what falls out.
store.get("a")
store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) }
assertEquals(3, store.size())
assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey)
assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey)
assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey)
}
@Test
fun `a blank item id is never stored`() {
val store = DetailPositionStore()
store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
assertEquals(0, store.size())
}
}
@@ -0,0 +1,359 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaStream
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
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
/**
* Renders the movie and series detail pages to PNGs under `build/screenshots/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
* ```
*
* There is no network here, so every artwork URL resolves to null and the pages render on
* their own scrims. That is the point: it is the check that the poster column, the fact row
* and the episode strip hold their shape when Emby has no backdrop and no poster to give —
* the worst case, and the one a real library hits often enough to matter.
*
* Like [ServiceAlertBannerScreenshotTest], this is a `*ScreenshotTest.kt` file and is
* allowed the Robolectric dependency; what these pages *say* lives in pure functions in
* `ui/detail/DetailFacts.kt` and is covered by [SeriesDetailsTest] in plain JUnit.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class DetailPageScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
// The hero asks the repository for its artwork URLs; without a token it answers
// null, which is exactly the fallback case being captured.
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `movie page`() {
capture("df_detail-movie") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
@Test
fun `movie page part watched`() {
capture("df_detail-movie-resumable") {
MediaDetailContent(
item = movie.copy(
userData = UserItemData(playbackPositionTicks = 47L * 600_000_000L),
),
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
@Test
fun `series page`() {
capture("df_detail-series") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
)
}
}
/** The first frame, before the episode request lands. The header must already be whole. */
@Test
fun `series page loading`() {
capture("df_detail-series-loading") {
SeriesDetailContent(
item = series,
episodes = null,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = true,
onToggleMyShow = { _, _ -> },
)
}
}
/**
* The other panes. Clicking a tab is the same path a remote takes — the strip selects
* on focus — so this also proves the panes swap without disturbing the header.
*/
@Test
fun `series episodes tab`() {
captureTab("df_detail-series-episodes", "Episodes") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
)
}
}
@Test
fun `series cast tab`() {
captureTab("df_detail-series-cast-details", "Cast & Details") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
)
}
}
@Test
fun `movie details tab`() {
captureTab("df_detail-movie-more-like-this", "More Like This") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
/**
* The tab panes at the geometry the scaffold gives them on a 540dp TV — the slot the
* audit found clipping every technical spec off the bottom of Cast & Details, which is
* the whole reason that tab exists. A tab does not scroll, so everything it has to say
* has to be inside this box.
*/
@Test
fun `cast and details pane at its real slot geometry`() {
capturePane("df_detail-pane-cast-details") {
DetailCastAndDetailsPane(
item = movie,
credits = creditRows(movie),
specs = technicalSpecs(movie),
detailsLoaded = true,
focusRequester = FocusRequester(),
)
}
}
@Test
fun `overview pane at its real slot geometry`() {
capturePane("df_detail-pane-overview") {
DetailOverviewPane(
item = series,
credits = creditRows(series),
focusRequester = FocusRequester(),
supportingText = "Up next S1 E3 · The Long Count",
)
}
}
private fun capturePane(name: String, pane: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = DetailSideGutter, vertical = 16.dp)
.height(detailPaneHeight(540.dp)),
) { pane() }
}
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onNodeWithText(tab).performClick()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
/**
* What the gateway returns for a warm profile: the reason strip above the tabs and the
* carousel under the page. Both are the point of these captures now — they are the two
* bands that decide whether the page still fits on one screen.
*/
private val related = RelatedContent(
reasons = listOf(
"Because you watch Thriller",
"You've watched Aria Vance before",
"Well rated (8.4)",
),
items = List(8) { index ->
BaseItem(
id = "related-$index",
name = listOf(
"The Quiet Meridian",
"Harbour Lights",
"Nine Days of Rain",
"The Cartographer's Wife",
"Winterline",
"A Country of Small Rivers",
"The Last Broadcast",
"Northbound",
)[index],
type = "Movie",
productionYear = 2019 + index % 5,
)
},
)
private val cast = listOf(
person("Aria Vance", "Detective Iris Kell"),
person("Marcus Oyelaran", "Samuel Reed"),
person("Nina Kowalczyk", "Dr. Halvorsen"),
person("Tomas Brandt", "The Cartographer"),
person("Ines Ferreira", "Captain Ruiz"),
person("Daniel Cho", "Weatherman"),
)
private val streams = listOf(
MediaStream(
type = "Video",
codec = "hevc",
width = 3840,
height = 2160,
videoRange = "HDR",
videoRangeType = "HDR10",
),
MediaStream(type = "Audio", codec = "eac3", channels = 6, language = "eng", title = "Dolby Atmos"),
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
MediaStream(type = "Subtitle", codec = "subrip", language = "fra"),
)
private val movie = BaseItem(
id = "movie-1",
name = "The Longest Northbound Winter",
type = "Movie",
overview = "A cartographer chasing a river that no longer exists finds the last " +
"village on the map still waiting for him, and has to decide whether telling " +
"them the truth is a kindness. Shot over four winters in a valley that floods " +
"every spring, and assembled from what survived.",
taglines = listOf("Some maps are promises."),
productionYear = 2024,
officialRating = "PG-13",
communityRating = 8.4,
genres = listOf("Drama", "Adventure", "Mystery"),
runTimeTicks = 134L * 600_000_000L,
studios = listOf(Studio(name = "Northlight Pictures"), Studio(name = "Kestrel")),
mediaStreams = streams,
people = cast,
)
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.",
taglines = listOf("Listen closely."),
productionYear = 2022,
officialRating = "TV-MA",
communityRating = 8.9,
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
mediaStreams = streams,
people = cast,
)
private val episodes = listOf(
episode(1, 1, "Carrier Wave", played = true),
episode(1, 2, "Dead Air", played = true),
episode(1, 3, "The Long Count", position = 12L * 600_000_000L),
episode(1, 4, "Nightingale"),
episode(1, 5, "Six Weeks Out"),
episode(2, 1, "Landfall"),
)
private fun episode(
season: Int,
number: Int,
title: String,
played: Boolean = false,
position: Long = 0L,
) = BaseItem(
id = "s${season}e$number",
name = title,
type = "Episode",
seriesName = "Signal Hill",
parentIndexNumber = season,
indexNumber = number,
runTimeTicks = 48L * 600_000_000L,
overview = "The night shift picks up a voice reading tomorrow's shipping forecast, " +
"and the log book from 1974 says the same thing happened before.",
userData = UserItemData(played = played, playbackPositionTicks = position),
)
private fun person(name: String, role: String) = EmbyPerson(
id = name.filter(Char::isLetter),
name = name,
role = role,
type = "Actor",
)
}
@@ -0,0 +1,191 @@
package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.test.core.app.ApplicationProvider
import androidx.tv.material3.Text
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class HomeMovieHeroScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `home hero with popular and new releases`() {
capture("df_home-movie-hero", movies)
}
/**
* The case the audit reproduced: with a title long enough to wrap, the card's content
* column overflowed its fixed height and the green Play chip — the last thing in the
* column — was clipped away entirely. It has to survive here.
*/
@Test
fun `home hero with a wrapping title`() {
capture(
"df_home-movie-hero-long-title",
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",
),
) + movies.drop(1),
)
}
private fun capture(name: String, movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
val railFocus = FocusRequester()
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
selected = BrowseDestination.HOME,
expanded = false,
navigationFocusRequester = railFocus,
onRailFocusChanged = {},
onDestinationSelected = {},
)
Column(Modifier.weight(1f).fillMaxHeight()) {
HomeMovieHero(
movies = movies,
navigationFocusRequester = railFocus,
onItemFocused = {},
onItemSelected = {},
modifier = Modifier.height(homeHeaderHeight(540.dp, showHero = true)),
previewArtwork = artwork,
)
Text(
"Recently added movies",
color = Color(0xFFF1F3F4),
fontSize = 18.sp,
modifier = Modifier.padding(start = 36.dp, top = 6.dp, bottom = 9.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(5) { index ->
Column(Modifier.weight(1f)) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(7.dp))
.background(
listOf(
Color(0xFF27343D), Color(0xFF28302D),
Color(0xFF352C37), Color(0xFF392F28), Color(0xFF25343A),
)[index],
),
)
Text(
movies[index % movies.size].item.name,
color = Color.White,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
"2026 • 2h 4m",
color = Color(0xFF8F9AA3),
fontSize = 11.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
}
}
}
}
compose.onNodeWithText("Play").fetchSemanticsNode()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
private val movies = listOf(
HomeHeroPick(
movie(
"The Last Horizon",
2026,
"Beyond the mapped worlds, one explorer finds an ocean that remembers every visitor.",
8.7,
),
"NEW RELEASE",
),
HomeHeroPick(
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
"POPULAR",
),
HomeHeroPick(
movie("Northstar Run", 2025, "The final supply ship takes an impossible route.", 7.9),
"NEW RELEASE",
),
HomeHeroPick(
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
"FROM YOUR LIBRARY",
),
)
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
id = name.lowercase().replace(' ', '-'),
name = name,
type = "Movie",
overview = overview,
productionYear = year,
officialRating = "M",
communityRating = rating,
runTimeTicks = 124L * 600_000_000L,
)
}
@@ -0,0 +1,75 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeMovieHeroTest {
@Test
fun `home hero gives way to focused row metadata`() {
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = null))
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = HOME_HERO_ROW_ID))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, focusedRowId = "continue"))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, focusedRowId = null))
}
@Test
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
assertEquals(248.4f, heroHeight.value, 0.01f)
assertTrue(540.dp - heroHeight >= 288.dp)
}
@Test
fun `hero alternates new releases and popular movies`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
assertEquals(
listOf("new-1", "popular-1", "new-2", "popular-2"),
selectHomeHeroMovies(rows).map { it.item.id },
)
}
/**
* The caption used to be the card's slot, so the third card was always "TRENDING"
* whatever it was. It now names the row the title was actually drawn from.
*/
@Test
fun `hero labels each pick by the row it came from`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1"),
row("popular", "Popular Movies", "popular-1"),
row("comedy", "Comedy", "other-1"),
)
assertEquals(
listOf("NEW RELEASE", "POPULAR", "FROM YOUR LIBRARY"),
selectHomeHeroMovies(rows).map { it.label },
)
}
@Test
fun `hero removes repeated movies across source rows`() {
val rows = listOf(
row("latest", "New releases", "shared", "new-2"),
row("recommended", "Recommended", "shared", "popular-2", "popular-3"),
)
assertEquals(4, selectHomeHeroMovies(rows).size)
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
}
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
id = id,
title = title,
items = ids.map { BaseItem(id = it, name = it, type = "Movie") },
kind = MediaRowKind.MOVIES,
emptyMessage = "",
)
}
@@ -28,6 +28,33 @@ class MediaBadgesTest {
)
}
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
@Test
fun `names HDR10+ rather than collapsing it to HDR`() {
val item = BaseItem(
id = "movie",
mediaStreams = listOf(
MediaStream(type = "Video", width = 3840, videoRangeType = "HDR10+"),
),
)
assertEquals(listOf("4K", "HDR10+"), mediaBadges(item))
}
/**
* The badge and the spec row's `(4K)` suffix used to disagree — 3800 against 3400 —
* so a 3600-wide file was 4K on the detail page and not on the card.
*/
@Test
fun `uses the same 4K threshold as the technical spec row`() {
fun badgesAt(width: Int) = mediaBadges(
BaseItem(id = "m", mediaStreams = listOf(MediaStream(type = "Video", width = width))),
)
assertEquals(emptyList<String>(), badgesAt(3_600))
assertEquals(listOf("4K"), badgesAt(3_840))
}
@Test
fun `returns no badges when stream metadata is unavailable`() {
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.MyShow
import org.junit.Assert.assertEquals
import org.junit.Test
class MyShowsTest {
@Test
fun missingNextEpisodeHasFriendlyCopy() {
assertEquals("Not announced", formatMyShowDate(null))
assertEquals("Not announced", formatMyShowDate("not-a-date"))
}
@Test
fun cardSubtitlePrioritisesUsefulShowState() {
assertEquals(
"Cancelled",
myShowCardSubtitle(MyShow(itemId = "1", title = "Ended", lifecycle = "Cancelled")),
)
assertEquals(
"Not monitored",
myShowCardSubtitle(MyShow(itemId = "2", title = "Paused", sonarrStatus = "Not monitored")),
)
assertEquals(
"Saved show",
myShowCardSubtitle(MyShow(itemId = "3", title = "Unknown")),
)
}
}
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class QuickActionsNavigationTest {
@Test
fun `quick actions require an intentional hold`() {
assertTrue(QuickActionsHoldDurationMillis >= 600L)
}
@Test
fun `down reaches every action and stops at the final action`() {
assertEquals(1, quickActionNextIndex(0, 4, QuickActionDirection.DOWN))
assertEquals(3, quickActionNextIndex(3, 4, QuickActionDirection.DOWN))
}
@Test
fun `up returns toward the safe first action and stops there`() {
assertEquals(2, quickActionNextIndex(3, 4, QuickActionDirection.UP))
assertEquals(0, quickActionNextIndex(0, 4, QuickActionDirection.UP))
}
@Test
fun `empty menus remain bounded`() {
assertEquals(0, quickActionNextIndex(5, 0, QuickActionDirection.DOWN))
}
}
@@ -0,0 +1,54 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ResponsiveRowSizingTest {
@Test
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
assertEquals(720f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
}
@Test
fun standardPosterRowFitsSevenCompleteCards() {
val width = responsiveRowCardWidth(
availableWidth = 1280.dp,
preferredCardsAcross = 7,
minWidth = 102.dp,
maxWidth = 218.dp,
)
val occupiedWidth = width * 7 + 16.dp * 6 + 36.dp * 2
assertEquals(1280f, occupiedWidth.value, 0.01f)
}
@Test
fun narrowRowReducesCardCountInsteadOfClippingCards() {
val width = responsiveRowCardWidth(
availableWidth = 640.dp,
preferredCardsAcross = 7,
minWidth = 102.dp,
maxWidth = 218.dp,
)
// Four 130dp cards, three gaps and both insets fill this viewport exactly.
assertEquals(130f, width.value, 0.01f)
assertEquals(640f, (width * 4 + 16.dp * 3 + 36.dp * 2).value, 0.01f)
}
@Test
fun wideRowAddsCardsRatherThanExceedingMaximumWidth() {
val width = responsiveRowCardWidth(
availableWidth = 2560.dp,
preferredCardsAcross = 4,
minWidth = 164.dp,
maxWidth = 360.dp,
)
assertTrue(width <= 360.dp)
assertEquals(341.71f, width.value, 0.01f)
}
}
@@ -0,0 +1,73 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class RowNavigationTest {
@Test
fun `vertical movement skips rows without focusable cards`() {
val counts = listOf(6, 0, 0, 4)
assertEquals(
3,
adjacentFocusableRowIndex(counts, 0, RowFocusDirection.DOWN),
)
assertEquals(
0,
adjacentFocusableRowIndex(counts, 3, RowFocusDirection.UP),
)
}
@Test
fun `vertical movement stops at the page boundary`() {
val counts = listOf(3, 2)
assertNull(adjacentFocusableRowIndex(counts, 0, RowFocusDirection.UP))
assertNull(adjacentFocusableRowIndex(counts, 1, RowFocusDirection.DOWN))
}
@Test
fun `vertical movement can traverse beyond the second row`() {
val counts = listOf(6, 6, 6, 6, 6)
val visited = mutableListOf(0)
var current = 0
while (true) {
current = adjacentFocusableRowIndex(
counts,
current,
RowFocusDirection.DOWN,
) ?: break
visited += current
}
assertEquals(listOf(0, 1, 2, 3, 4), visited)
}
@Test
fun `first visit keeps horizontal position and clamps to a shorter row`() {
assertEquals(4, rowEntryItemIndex(4, destinationItemCount = 8, null))
assertEquals(2, rowEntryItemIndex(7, destinationItemCount = 3, null))
}
@Test
fun `return visit restores the destination row position`() {
assertEquals(
1,
rowEntryItemIndex(
sourceIndex = 5,
destinationItemCount = 8,
rememberedDestinationIndex = 1,
),
)
assertEquals(
2,
rowEntryItemIndex(
sourceIndex = 1,
destinationItemCount = 3,
rememberedDestinationIndex = 20,
),
)
}
}
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.seasonLabel
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -55,9 +59,8 @@ class SeriesDetailsTest {
}
@Test
fun `detail tabs default safely to episodes`() {
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
fun `season zero is labelled specials`() {
assertEquals("Specials", seasonLabel(0))
assertEquals("Season 3", seasonLabel(3))
}
}
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -15,6 +16,28 @@ import org.junit.Test
* the server invented, and surviving a cold start from cache.
*/
class ServerHomeRowsTest {
@Test
fun `profile row preferences hide pin and order server rows`() {
val serverRows = listOf(
HomeRow("continue", "Continue", "continue", emptyList()),
HomeRow("recommended", "Recommended", "recommendation", emptyList()),
HomeRow("latest-movies", "Latest", "latest", emptyList()),
HomeRow("seasonal", "Seasonal", "recommendation", emptyList()),
)
val settings = Settings(
homeRowOrder = "seasonal\nrecommended\ncontinue",
homePinnedRows = "recommended",
homeHiddenRows = "latest-movies",
)
val result = serverHomeRows(
HomeUiState(rows = serverRows, loading = emptySet()),
settings,
)
assertEquals(listOf("recommended", "seasonal", "continue"), result.map { it.id })
}
private val json = Json { ignoreUnknownKeys = true }
@@ -73,10 +96,7 @@ class ServerHomeRowsTest {
@Test
fun `successful login welcome uses authenticated username`() {
assertEquals(
"You're now logged in as Matt. Welcome to Memby!",
loginWelcomeMessage(" Matt "),
)
assertTrue(loginWelcomeMessage(" Matt ").startsWith("Welcome to Memby, Matt. "))
}
@Test
@@ -189,7 +209,61 @@ class ServerHomeRowsTest {
}
@Test
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
fun `home and library destinations have distinct shelves`() {
val state = HomeUiState(
rows = serverRows + listOf(
row("curated:drama-shows", "shows", "show"),
row("curated:movies:genre:drama", "movies", "movie"),
),
loading = emptySet(),
)
val home = homeRowsFor(BrowseDestination.HOME, state, Settings())
val shows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
val movies = homeRowsFor(BrowseDestination.MOVIES, state, Settings())
assertTrue(home.none { it.id.startsWith("curated:") })
assertTrue(shows.any { it.id == "curated:drama-shows" })
assertTrue(movies.any { it.id == "curated:movies:genre:drama" })
}
@Test
fun `discovery shelves do not repeat a continued series or played title`() {
val continued = BaseItem(id = "episode", type = "Episode", seriesId = "hard-man")
val repeatedSeries = BaseItem(id = "hard-man", type = "Series")
val playedMovie = BaseItem(
id = "played",
type = "Movie",
userData = UserItemData(played = true),
)
val fresh = (1..4).map { BaseItem(id = "fresh-$it", type = "Series") }
val rows = deduplicateBrowseRows(
listOf(
HomeBrowseRow(
id = "continue",
title = "Continue",
items = listOf(continued),
kind = MediaRowKind.CONTINUE,
emptyMessage = "empty",
),
HomeBrowseRow(
id = "curated:drama-shows",
title = "Drama",
items = listOf(repeatedSeries, playedMovie) + fresh,
kind = MediaRowKind.SHOWS,
emptyMessage = "empty",
),
),
)
assertEquals(
fresh.map { it.id },
rows.last().items.map { it.id },
)
}
@Test
fun `TV schedule rows use show cards and cannot be hidden by old preferences`() {
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
val rows = serverHomeRows(
@@ -199,7 +273,24 @@ class ServerHomeRowsTest {
assertEquals(1, rows.size)
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage)
}
@Test
fun `Radarr schedule rows use movie cards and explain an empty digital window`() {
val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7")
val rows = serverHomeRows(
HomeUiState(rows = listOf(schedule), loading = emptySet()),
Settings(homeSections = ""),
)
assertEquals(1, rows.size)
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
assertEquals(
"No monitored movies have a digital release in the next 5 days",
rows.single().emptyMessage,
)
}
@Test
@@ -1,7 +1,14 @@
package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
@@ -25,8 +32,8 @@ import org.robolectric.annotation.GraphicsMode
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
*
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
* own background, flush to the top edge exactly as `MainActivity` places it.
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn over a realistic
* cinematic playback still, flush to the top edge exactly as `PlayerActivity` places it.
*
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
@@ -49,7 +56,6 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
@@ -62,7 +68,54 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:8:43:aired",
title = "The Long Dark",
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
posterUrl = null,
),
)
}
/**
* A Radarr import, which is the case with a server-supplied eyebrow: the longest
* label the bar is expected to carry has to leave the countdown ring room.
*/
@Test
fun `new movie added`() {
capture(
"alert-banner-movie-added",
ServiceAlert(
id = "radarr:412:file:9001",
title = "Mr. Smith Goes to Washington (1939)",
message = "Mr. Smith Goes to Washington will be available in Emby shortly.",
label = "NEW MOVIE ADDED",
),
)
}
/** The service itself talking: a finished refresh, with nothing to illustrate it. */
@Test
fun `library updated`() {
capture(
"alert-banner-library-updated",
ServiceAlert(
id = "library:1785012345",
title = "24 titles added or updated",
message = "Memby has finished refreshing — it is on the home screen now.",
label = "LIBRARY UPDATED",
),
)
}
/**
* The alert most likely to be read over a film, and the one whose wording has to work
* with the picture already stalled behind it.
*/
@Test
fun `server not responding`() {
capture(
"alert-banner-server-down",
ServiceAlert(
id = "emby:down:1785012345",
title = "Emby has stopped communicating",
message = "Playback may stop until it is back. Memby will say when it returns.",
label = "SERVER NOT RESPONDING",
),
)
}
@@ -78,7 +131,6 @@ class ServiceAlertBannerScreenshotTest {
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"And Then Some More Happens After That aired at 10:30 PM and is " +
"downloading now.",
posterUrl = null,
),
)
}
@@ -92,7 +144,6 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:3:12:aired",
title = "Dune",
message = "S01E01 aired and will be in Emby soon.",
posterUrl = null,
),
)
}
@@ -110,7 +161,6 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
@@ -125,7 +175,16 @@ class ServiceAlertBannerScreenshotTest {
@Composable
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
PreviewSurface(alignment = Alignment.TopCenter) {
val playbackStill = requireNotNull(
javaClass.getResourceAsStream("/playback_alert_preview_still.png"),
).use(BitmapFactory::decodeStream).asImageBitmap()
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Image(
bitmap = playbackStill,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
AlertBanner(alert)
}
}
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class UserSwitcherNavigationTest {
@Test
fun `active profile receives initial focus`() {
assertEquals(
1,
userSwitcherInitialIndex(listOf("one", "two", "three"), "two"),
)
}
@Test
fun `three profiles lead to pinned manage action`() {
val profileCount = 3
assertEquals(
3,
userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN),
)
assertEquals(
3,
userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN),
)
}
@Test
fun `up and down navigation stay inside the switcher`() {
assertEquals(0, userSwitcherNextIndex(0, 3, UserSwitcherDirection.UP))
assertEquals(2, userSwitcherNextIndex(3, 3, UserSwitcherDirection.UP))
}
@Test
fun `manage remains reachable when no profiles exist`() {
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
}
}
@@ -0,0 +1,101 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.test.core.app.ApplicationProvider
import androidx.tv.material3.Text
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class WatchedVisibilityScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `browse cards hide watched movies and count watched episodes`() {
val visible = applyWatchedVisibility(listOf(row), enabled = true).single()
compose.setContent {
PreviewSurface {
Column(Modifier.fillMaxSize().padding(48.dp)) {
Text("Because you like great stories", color = Color.White, fontSize = 22.sp)
Row(
modifier = Modifier.padding(top = 18.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
visible.items.forEach { item ->
PortraitMediaCard(
item = item,
availableWidth = 820.dp,
showSecondaryMetadata = true,
onFocused = {},
onClick = {},
onLongClick = {},
density = "large",
showWatchedEpisodeCount = visible.showWatchedEpisodeCount,
)
}
}
}
}
}
compose.onRoot().captureRoboImage("build/screenshots/watched-visibility-row.png")
}
private val row = HomeBrowseRow(
id = "stories",
title = "Stories",
kind = MediaRowKind.MOVIES,
emptyMessage = "Nothing here",
items = listOf(
BaseItem(
id = "seen",
name = "Already Seen",
type = "Movie",
userData = UserItemData(played = true),
),
BaseItem(
id = "signal",
name = "Signal House",
type = "Series",
recursiveItemCount = 10,
userData = UserItemData(unplayedItemCount = 2),
),
BaseItem(
id = "winter",
name = "A Long Winter",
type = "Series",
recursiveItemCount = 24,
userData = UserItemData(unplayedItemCount = 19),
),
BaseItem(id = "new", name = "Not Watched Yet", type = "Movie", productionYear = 2026),
),
)
}
@@ -0,0 +1,66 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WatchedVisibilityTest {
@Test
fun `preference is disabled by default`() {
assertFalse(com.ponzischeme89.memby.data.Settings().hideWatchedMovies)
}
@Test
fun `enabled preference hides only completed movies`() {
val watchedMovie = item("movie", "Movie", played = true)
val unwatchedMovie = item("new-movie", "Movie")
val watchedSeries = item("series", "Series", played = true)
val row = row(watchedMovie, unwatchedMovie, watchedSeries)
val filtered = applyWatchedVisibility(listOf(row), enabled = true).single()
assertEquals(listOf("new-movie", "series"), filtered.items.map(BaseItem::id))
assertTrue(filtered.showWatchedEpisodeCount)
}
@Test
fun `disabled preference preserves row content`() {
val row = row(item("movie", "Movie", played = true))
val unchanged = applyWatchedVisibility(listOf(row), enabled = false).single()
assertEquals(listOf("movie"), unchanged.items.map(BaseItem::id))
assertFalse(unchanged.showWatchedEpisodeCount)
}
@Test
fun `series progress uses aggregate episode counts`() {
val series = BaseItem(
id = "series",
type = "Series",
recursiveItemCount = 10,
userData = UserItemData(unplayedItemCount = 2),
)
assertEquals("8 of 10 watched", watchedEpisodeCountLabel(series))
assertNull(watchedEpisodeCountLabel(item("movie", "Movie")))
}
private fun item(id: String, type: String, played: Boolean = false) = BaseItem(
id = id,
type = type,
userData = UserItemData(played = played),
)
private fun row(vararg items: BaseItem) = HomeBrowseRow(
id = "row",
title = "Row",
items = items.toList(),
kind = MediaRowKind.MOVIES,
emptyMessage = "Empty",
)
}
@@ -0,0 +1,31 @@
package com.ponzischeme89.memby.ui
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WelcomeQuotesTest {
@Test
fun `unknown styles safely fall back to neutral`() {
assertEquals(
randomWelcomeQuote("neutral", Random(7)),
randomWelcomeQuote("something-new", Random(7)),
)
}
@Test
fun `each style supplies a welcome line`() {
WelcomeQuoteStyle.entries.forEach { style ->
assertTrue(randomWelcomeQuote(style.value, Random(3)).isNotBlank())
}
}
@Test
fun `login greeting trims the authenticated username`() {
assertTrue(
loginWelcomeMessage(" Matt ", "positive", Random(1))
.startsWith("Welcome to Memby, Matt. "),
)
}
}
@@ -0,0 +1,92 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
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.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class PlaybackIdentityScreenshotTest {
@Test
fun `colour artwork keeps its original treatment`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val colourLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(82, 181, 75))
}
assertFalse(
makeLogoVisibleOnDarkBackground(
ImageView(activity),
BitmapDrawable(activity.resources, colourLogo),
),
)
}
@Test
fun `black artwork is lightened for dark player surfaces`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val blackLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.BLACK)
}
assertTrue(
makeLogoVisibleOnDarkBackground(
ImageView(activity),
BitmapDrawable(activity.resources, blackLogo),
),
)
}
@Test
fun `plain title and emby mark appear over playback for five seconds`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
val backdrop = ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream),
)
}
root.addView(
backdrop,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
val identity = LayoutInflater.from(activity)
.inflate(R.layout.player_playback_identity, root, false)
.apply {
visibility = View.VISIBLE
alpha = 1f
}
identity.findViewById<TextView>(R.id.player_playback_identity_title).text = "Dark Matter"
root.addView(identity)
activity.setContentView(root)
assertEquals(5_000L, PlayerActivity.PLAYBACK_IDENTITY_VISIBLE_MS)
root.captureRoboImage("build/screenshots/player-playback-identity.png")
}
}
@@ -19,13 +19,14 @@ class PlaybackRecoveryTest {
}
@Test
fun decoderFailuresRequireViewerAction() {
fun decoderFailuresAutomaticallyRequestCompatibleStream() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
)
assertEquals("Video format not supported", failure.title)
assertFalse(failure.canAutoRetry)
assertTrue(failure.canAutoRetry)
assertTrue(failure.requiresTranscode)
}
@Test
@@ -0,0 +1,90 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.BitmapFactory
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class PlayerPauseOverlayScreenshotTest {
@Test
fun `paused movie shows focused poster synopsis and resume controls`() {
val (activity, root, controls) = playerSurface()
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.VISIBLE
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
controls.findViewById<TextView>(R.id.player_pause_title).text = "The Last Horizon"
controls.findViewById<TextView>(R.id.player_pause_overview).text =
"A cartographer follows a signal beyond the edge of the known world, " +
"where an abandoned observatory may hold the way home."
controls.findViewById<ImageView>(R.id.player_pause_poster).setImageBitmap(previewArtwork())
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
root.captureRoboImage("build/screenshots/player-paused-movie-overlay.png")
}
@Test
fun `playing OSD keeps video visible behind controls without black container`() {
val (_, root, controls) = playerSurface()
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.GONE
controls.findViewById<View>(R.id.player_title_logo).visibility = View.GONE
controls.findViewById<TextView>(R.id.player_title).apply {
text = "The Last Horizon"
visibility = View.VISIBLE
}
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
root.captureRoboImage("build/screenshots/player-osd-no-black-container.png")
}
private fun playerSurface(): Triple<Activity, FrameLayout, View> {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
root.addView(
ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(previewArtwork())
},
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
val controls = FrameLayout(activity)
root.addView(
controls,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
LayoutInflater.from(activity).inflate(R.layout.memby_player_controls, controls, true)
activity.setContentView(root)
return Triple(activity, root, controls)
}
private fun previewArtwork() =
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream)
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PlayerTimingTest {
@@ -11,4 +12,50 @@ class PlayerTimingTest {
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
}
@Test
fun timeRemainingCueStartsStrictlyBelowTenMinutes() {
assertNull(PlayerActivity.timeRemainingCueMinutes(10L * 60_000L))
assertEquals(10, PlayerActivity.timeRemainingCueMinutes(10L * 60_000L - 1L))
assertEquals(2, PlayerActivity.timeRemainingCueMinutes(61_000L))
assertEquals(1, PlayerActivity.timeRemainingCueMinutes(1L))
assertNull(PlayerActivity.timeRemainingCueMinutes(0L))
}
@Test
fun playbackStartCueWaitsForTenSecondsOfPlaying() {
assertEquals(false, PlayerActivity.playbackStartCueReady(9_999L))
assertEquals(true, PlayerActivity.playbackStartCueReady(10_000L))
assertEquals("1 min", PlayerActivity.formatCueDuration(1L))
assertEquals("42 mins", PlayerActivity.formatCueDuration(42L * 60_000L))
}
@Test
fun resumedPlaybackShowsTimeLeftAfterTwoSecondsOfPlaying() {
assertEquals(false, PlayerActivity.playbackStartCueReady(1_999L, resumePositionMs = 1L))
assertEquals(true, PlayerActivity.playbackStartCueReady(2_000L, resumePositionMs = 1L))
}
@Test
fun prerollRuntimeUsesCompactEpisodeFacts() {
assertNull(PlayerActivity.formatPrerollRuntime(0L))
assertEquals("48 mins", PlayerActivity.formatPrerollRuntime(48L * 60_000L))
assertEquals("1h 42m", PlayerActivity.formatPrerollRuntime(102L * 60_000L))
}
@Test
fun completedPlaybackReturnsHomeUnlessAutoplayCanAdvance() {
assertEquals(
PlaybackCompletionAction.RETURN_HOME,
playbackCompletionAction(hasNextEpisode = false, nextUpDismissed = false),
)
assertEquals(
PlaybackCompletionAction.RETURN_HOME,
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = true),
)
assertEquals(
PlaybackCompletionAction.PLAY_NEXT,
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false),
)
}
}
@@ -3,8 +3,11 @@ package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.view.LayoutInflater
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
@@ -20,13 +23,26 @@ class PrerollLayoutTest {
val context = ApplicationProvider.getApplicationContext<Context>()
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_preroll,
FrameLayout(context),
false,
),
val preroll = LayoutInflater.from(context).inflate(
R.layout.player_preroll,
FrameLayout(context),
false,
)
val videoHost = preroll.findViewById<FrameLayout>(R.id.player_preroll_video_host)
assertNotNull(videoHost)
assertEquals(
(398 * context.resources.displayMetrics.density).toInt(),
videoHost.layoutParams.width,
)
assertNotNull(preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown))
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_brand))
assertEquals(
"Starting in 7 seconds…",
context.getString(R.string.player_preroll_countdown_initial),
)
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_frame))
assertNotNull(preroll.findViewById<GridLayout>(R.id.player_preroll_calendar))
assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS)
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_cast_overlay,
@@ -0,0 +1,106 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.GridLayout
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class PrerollScreenshotTest {
@Test
fun `sonarr preroll uses hero video and artwork stack`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
val preroll = LayoutInflater.from(activity).inflate(R.layout.player_preroll, root, false)
preroll.visibility = View.VISIBLE
root.addView(
preroll,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
val artwork = javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream)
preroll.findViewById<FrameLayout>(R.id.player_preroll_video_host).addView(
ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(artwork)
},
0,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
val samples = listOf(
Triple("TODAY · 8:30 PM", "Northbound", "S02E04 · The Crossing"),
Triple("TODAY · 9:00 PM", "Harbour", "S03E01 · Home Water"),
Triple("TODAY · 9:30 PM", "The Bear", "S04E03 · Bridges"),
Triple("TODAY · 10:00 PM", "Slow Horses", "S05E02 · Signals"),
Triple("WED · 7:30 PM", "Foundation", "S03E06 · The Mule"),
Triple("THU · 8:00 PM", "Severance", "S03E01 · Cold Harbour"),
Triple("FRI · 8:30 PM", "Silo", "S03E04 · Legacy"),
Triple("SAT · 7:00 PM", "North Shore", "S01E07 · The Dive"),
)
preroll.findViewById<TextView>(R.id.player_preroll_now_title).text =
"Northbound The Crossing"
preroll.findViewById<TextView>(R.id.player_preroll_now_metadata).text =
"EPISODE · S02E04 · 48 mins"
preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text =
"The crew follows a signal beyond the last charted crossing and discovers " +
"a settlement that has been waiting for their arrival."
val calendar = preroll.findViewById<GridLayout>(R.id.player_preroll_calendar)
samples.forEachIndexed { index, (label, title, detail) ->
val card = LayoutInflater.from(activity)
.inflate(R.layout.player_preroll_schedule_card, calendar, false)
card.findViewById<ImageView>(R.id.player_preroll_card_artwork).setImageBitmap(artwork)
card.findViewById<TextView>(R.id.player_preroll_card_label).text = label
card.findViewById<TextView>(R.id.player_preroll_card_title).text = title
card.findViewById<TextView>(R.id.player_preroll_card_detail).text = detail
calendar.addView(
card,
GridLayout.LayoutParams().apply {
width = 0
height = dp(activity, 80)
columnSpec = GridLayout.spec(index % 4, 1f)
rowSpec = GridLayout.spec(index / 4)
setMargins(dp(activity, 4), dp(activity, 3), dp(activity, 4), dp(activity, 3))
},
)
}
val countdown = preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown)
countdown.setCountdown(
seconds = 7,
progress = 1f,
description = "Starting in 7 seconds",
)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/player-sonarr-preroll.png")
countdown.setCountdown(seconds = 3, progress = 3f / 6.5f, description = "Starting in 3 seconds")
root.captureRoboImage("build/screenshots/player-sonarr-preroll-mid-countdown.png")
}
private fun dp(activity: Activity, value: Int): Int =
(value * activity.resources.displayMetrics.density).toInt()
}
@@ -6,7 +6,24 @@ import org.junit.Test
class PrerollSequenceTest {
@Test
fun `handoff waits only for the five second gate`() {
fun `fresh playback shows preroll`() {
assertTrue(shouldShowPreroll(0L))
}
@Test
fun `resumed playback skips preroll`() {
assertFalse(shouldShowPreroll(1L))
assertFalse(shouldShowPreroll(42 * 60_000L))
}
@Test
fun `server can disable preroll for fresh playback`() {
assertFalse(shouldShowPreroll(0L, enabled = false))
assertTrue(shouldShowPreroll(0L, enabled = true))
}
@Test
fun `handoff waits for the video countdown gate`() {
assertFalse(prerollCanHandOff(true, false, true))
assertTrue(prerollCanHandOff(true, true, true))
}
@@ -0,0 +1,53 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SeasonFinaleLowerThirdScreenshotTest {
@Test
fun `season finale stacks above playback start cue`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply {
background = GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(
Color.rgb(17, 37, 48),
Color.rgb(12, 20, 27),
Color.rgb(3, 7, 10),
),
)
}
val inflater = LayoutInflater.from(activity)
val timing = inflater.inflate(R.layout.player_time_remaining, root, false).apply {
visibility = View.VISIBLE
findViewById<TextView>(R.id.player_time_remaining_label).text = "FINISHES IN"
findViewById<TextView>(R.id.player_time_remaining_value).text = "52 mins (9:54 PM)"
}
val finale = inflater.inflate(R.layout.player_season_finale, root, false).apply {
visibility = View.VISIBLE
findViewById<TextView>(R.id.player_season_finale_value).text =
"Signal Hill · Season 2 finale"
}
root.addView(timing)
root.addView(finale)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/player-season-finale-lower-third.png")
}
}
@@ -0,0 +1,80 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
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
/**
* Captures the real player XML at TV resolution. The restrained blue-grey field stands
* in for a dark film frame and makes the cue's contrast and content-width easy to judge.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class TimeRemainingCueScreenshotTest {
@Test
fun `compact time remaining cue over playback`() {
captureCue(
name = "player-time-remaining",
label = "TIME REMAINING",
value = "9 mins",
)
}
@Test
fun `compact finishes in cue over playback`() {
captureCue(
name = "player-finishes-in",
label = "FINISHES IN",
value = "42 mins (9:48 PM)",
)
}
@Test
fun `compact resumed time left cue over playback`() {
captureCue(
name = "player-resume-time-left",
label = "TIME LEFT",
value = "36 mins",
)
}
private fun captureCue(name: String, label: String, value: String) {
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 cue = LayoutInflater.from(activity).inflate(
R.layout.player_time_remaining,
root,
false,
)
cue.visibility = View.VISIBLE
cue.findViewById<TextView>(R.id.player_time_remaining_label).text = label
cue.findViewById<TextView>(R.id.player_time_remaining_value).text = value
root.addView(cue)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/$name.png")
}
}
@@ -72,4 +72,19 @@ class SearchRankingTest {
assertTrue(shouldSearch("ab"))
assertTrue(shouldSearch(" the wire "))
}
@Test
fun `gateway scored order can be preserved by callers`() {
val backendOrder = listOf(
BaseItem(id = "personal", name = "Dune Messiah", membyRecommendationScore = 9.0),
BaseItem(id = "exact", name = "Dune", membyRecommendationScore = 8.0),
)
// Scored gateway results are intentionally not passed through rankSearchResults.
val displayed = if (backendOrder.any { it.membyRecommendationScore != null }) {
backendOrder
} else {
rankSearchResults("dune", backendOrder)
}
assertEquals(listOf("Dune Messiah", "Dune"), names(displayed))
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.settings
import com.ponzischeme89.memby.BuildConfig
import org.junit.Assert.assertTrue
import org.junit.Test
class LegalNoticesTest {
@Test
fun `distributed app embeds source and complete GPL notice`() {
assertTrue(BuildConfig.SOURCE_CODE_URL.startsWith("https://"))
assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains("Memby"))
assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains(BuildConfig.SOURCE_CODE_URL))
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("GNU GENERAL PUBLIC LICENSE"))
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("Version 2, June 1991"))
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("END OF TERMS AND CONDITIONS"))
}
}
@@ -9,6 +9,7 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.update.UpdateStatus
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -42,8 +43,27 @@ class SettingsSheetScreenshotTest {
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
}
@Test
fun `playback reminder option`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.PLAYBACK)
}
compose.onRoot().captureRoboImage("build/screenshots/settings-playback.png")
}
@Test
fun `watched movie visibility option`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.HOME)
}
compose.onRoot().captureRoboImage("build/screenshots/settings-home.png")
}
@Composable
private fun SettingsPreviewFixture(overlay: Boolean) {
private fun SettingsPreviewFixture(
overlay: Boolean,
selectedPage: SettingsPage = if (overlay) SettingsPage.UPDATES else SettingsPage.APPEARANCE,
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
@@ -51,12 +71,22 @@ class SettingsSheetScreenshotTest {
state = SettingsPanelState(
showLogo = true,
autoPlayNext = false,
showTenMinuteReminder = false,
ringColor = "52B54B",
homeSections = setOf("continue", "latest"),
cardDensity = "standard",
showCardMetadata = false,
editableServer = true,
baseUrl = "https://mserver.example/releases/latest.json",
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",
),
actions = SettingsPanelActions(),
@@ -0,0 +1,42 @@
package com.ponzischeme89.memby.update
import com.ponzischeme89.memby.data.model.GatewayUpdate
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ServerUpdateServiceTest {
@Test
fun `returns mandatory server decision without a session dependency`() = runTest {
val service = ServerUpdateService.forTest {
GatewayUpdate(
status = GatewayUpdate.STATUS_MANDATORY,
version = "0.3.0",
downloadUrl = "https://memby.test/updates/memby.apk",
)
}
val update = service.check().getOrThrow()
assertEquals("0.3.0", update?.version)
assertTrue(update?.isMandatory == true)
}
@Test
fun `non-actionable response is no update`() = runTest {
val service = ServerUpdateService.forTest {
GatewayUpdate(status = GatewayUpdate.STATUS_NONE)
}
assertNull(service.check().getOrThrow())
}
@Test
fun `network failure is reported without manufacturing a blocking update`() = runTest {
val service = ServerUpdateService.forTest { error("offline") }
assertTrue(service.check().isFailure)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB