Publish current app and server
This commit is contained in:
@@ -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
|
||||
|
||||
+90
@@ -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))
|
||||
}
|
||||
|
||||
+53
@@ -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"))
|
||||
}
|
||||
}
|
||||
+33
-3
@@ -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 |
Reference in New Issue
Block a user