App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,105 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Deliberately parallel to the gateway's `continue_watching_test.go`. Continue Watching
|
||||
* must hold the same cards in the same order whether or not the container is up.
|
||||
*/
|
||||
class ContinueWatchingTest {
|
||||
private fun resume(id: String, seriesId: String? = null, playedAt: String? = null) =
|
||||
BaseItem(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
userData = UserItemData(lastPlayedDate = playedAt),
|
||||
)
|
||||
|
||||
private fun upNext(id: String, seriesId: String) =
|
||||
BaseItem(id = id, seriesId = seriesId, userData = UserItemData())
|
||||
|
||||
/**
|
||||
* The case the merged row exists for: an episode was just finished, so it has left the
|
||||
* resume list, and the next one must be the first card rather than sitting in a second
|
||||
* row further down the launcher.
|
||||
*/
|
||||
@Test
|
||||
fun leadsWithTheJustFinishedShow() {
|
||||
val merged = mergeContinueWatching(
|
||||
resume = listOf(resume("film", playedAt = "2026-08-01T20:00:00.0000000Z")),
|
||||
nextUp = listOf(upNext("s2e5", "severance")),
|
||||
seriesLastPlayed = mapOf("severance" to "2026-08-06T21:00:00.0000000Z"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("s2e5", "film"), merged.map(BaseItem::id))
|
||||
}
|
||||
|
||||
/** A show being watched right now appears once, as the episode it is part-way through. */
|
||||
@Test
|
||||
fun prefersTheResumeEpisodeOfASeries() {
|
||||
val merged = mergeContinueWatching(
|
||||
resume = listOf(resume("s1e3", "show", "2026-08-06T19:00:00Z")),
|
||||
nextUp = listOf(upNext("s1e4", "show"), upNext("other-e1", "other")),
|
||||
seriesLastPlayed = mapOf(
|
||||
"show" to "2026-08-06T19:00:00Z",
|
||||
"other" to "2026-08-02T19:00:00Z",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("s1e3", "other-e1"), merged.map(BaseItem::id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Each source keeps its own order. Emby's ordering within a list is the useful part of
|
||||
* both, so this is a merge of two sorted lists and never a sort of their union.
|
||||
*/
|
||||
@Test
|
||||
fun keepsEachSourceOrder() {
|
||||
val merged = mergeContinueWatching(
|
||||
resume = listOf(
|
||||
resume("r1", playedAt = "2026-08-06T10:00:00Z"),
|
||||
resume("r2", playedAt = "2026-08-04T10:00:00Z"),
|
||||
resume("r3", playedAt = "2026-08-01T10:00:00Z"),
|
||||
),
|
||||
nextUp = listOf(upNext("n1", "alpha"), upNext("n2", "beta")),
|
||||
seriesLastPlayed = mapOf(
|
||||
"alpha" to "2026-08-05T10:00:00Z",
|
||||
"beta" to "2026-08-03T10:00:00Z",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("r1", "n1", "r2", "n2", "r3"), merged.map(BaseItem::id))
|
||||
}
|
||||
|
||||
/**
|
||||
* With no play dates to go on — the lookup failed, or Emby wrote something
|
||||
* unparseable — the row is the resume list followed by Next Up, which is the order the
|
||||
* launcher had when they were two rows. Nothing is dropped.
|
||||
*/
|
||||
@Test
|
||||
fun fallsBackToResumeFirst() {
|
||||
val merged = mergeContinueWatching(
|
||||
resume = listOf(resume("r1", playedAt = "not a date"), resume("r2")),
|
||||
nextUp = listOf(upNext("n1", "alpha"), upNext("n2", "beta")),
|
||||
seriesLastPlayed = emptyMap(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("r1", "r2", "n1", "n2"), merged.map(BaseItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizesEmbyTimestampsAndRejectsEverythingElse() {
|
||||
assertEquals("2026-08-06T21:04:05", normalizePlayedAt("2026-08-06T21:04:05.1234567Z"))
|
||||
assertEquals("2026-08-06T21:04:05", normalizePlayedAt("2026-08-06T21:04:05Z"))
|
||||
assertEquals("2026-08-06T21:04:05", normalizePlayedAt(" 2026-08-06T21:04:05 "))
|
||||
assertNull(normalizePlayedAt(null))
|
||||
assertNull(normalizePlayedAt(""))
|
||||
assertNull(normalizePlayedAt("2026-08-06"))
|
||||
assertNull(normalizePlayedAt("yesterday afternoon"))
|
||||
// Emby's "never played" sentinel is not a date to order a card by.
|
||||
assertNull(normalizePlayedAt("0001-01-01T00:00:00.0000000Z"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The device id is the television's identity in Emby's devices list and in
|
||||
* Settings → Devices. What these pin is that reinstalling the app reproduces it — that is
|
||||
* the whole reason it is derived rather than random — and that the platform values which
|
||||
* cannot identify a set do not silently make two televisions into one.
|
||||
*/
|
||||
class DeviceIdTest {
|
||||
|
||||
@Test
|
||||
fun `same android id yields the same device id`() {
|
||||
assertEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor("a1b2c3d4e5f60718"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different android ids yield different device ids`() {
|
||||
assertNotEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor("18f7e6f5d4c3b2a1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device id never carries the platform identifier`() {
|
||||
val id = deviceIdFor("a1b2c3d4e5f60718")
|
||||
assertTrue(id.startsWith("memby-"))
|
||||
assertTrue(!id.contains("a1b2c3d4e5f60718"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surrounding whitespace does not change the identity`() {
|
||||
assertEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor(" a1b2c3d4e5f60718 "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusable android ids fall back to a unique id`() {
|
||||
// A null, blank, short or all-zero value identifies nothing, and the id a batch of
|
||||
// early devices shared would make every one of them the same television. Two calls
|
||||
// must not agree: one duplicate entry is better than two sets sharing a session.
|
||||
for (value in listOf(null, "", " ", "9774d56d682e549c", "0000000000000000", "abc")) {
|
||||
assertNotEquals("android id $value", deviceIdFor(value), deviceIdFor(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayEmbyHealth
|
||||
import com.ponzischeme89.memby.ui.retryLabel
|
||||
import com.ponzischeme89.memby.ui.secondsUntil
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class EmbyOutageTest {
|
||||
|
||||
private val down = GatewayEmbyHealth(monitored = true, reachable = false, retrySeconds = 60)
|
||||
private val up = GatewayEmbyHealth(monitored = true, reachable = true, retrySeconds = 60)
|
||||
|
||||
/**
|
||||
* With the probe switched off nothing updates `reachable`, so trusting it would mean a
|
||||
* permanent red bar on a server that is working perfectly well.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmonitored server never raises the bar`() {
|
||||
val unmonitored = GatewayEmbyHealth(monitored = false, reachable = false)
|
||||
assertNull(nextOutageState(unmonitored, existing = null, nowMillis = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reachable server clears the bar`() {
|
||||
val existing = EmbyOutage(nextAttemptAtMillis = 60_000L, retryIntervalSeconds = 60)
|
||||
assertNull(nextOutageState(up, existing, nowMillis = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an outage arms a countdown from the retry interval`() {
|
||||
val outage = nextOutageState(down, existing = null, nowMillis = 5_000L)
|
||||
assertNotNull(outage)
|
||||
assertEquals(65_000L, outage!!.nextAttemptAtMillis)
|
||||
assertEquals(60, outage.retryIntervalSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* The status poll runs six times per retry. If each one re-armed the deadline the
|
||||
* counter would reset every ten seconds and never reach zero, which reads as an app
|
||||
* that is stuck rather than a server that is being retried.
|
||||
*/
|
||||
@Test
|
||||
fun `a live countdown survives the polls in the middle of it`() {
|
||||
val armed = nextOutageState(down, existing = null, nowMillis = 0L)!!
|
||||
var current = armed
|
||||
listOf(10_000L, 20_000L, 30_000L, 40_000L, 50_000L).forEach { now ->
|
||||
current = nextOutageState(down, current, now)!!
|
||||
}
|
||||
assertSame(armed, current)
|
||||
}
|
||||
|
||||
/** Once the attempt is due, the next one is a fresh interval away. */
|
||||
@Test
|
||||
fun `an expired countdown is re-armed`() {
|
||||
val armed = nextOutageState(down, existing = null, nowMillis = 0L)!!
|
||||
val next = nextOutageState(down, armed, nowMillis = 60_000L)!!
|
||||
assertEquals(120_000L, next.nextAttemptAtMillis)
|
||||
}
|
||||
|
||||
/** A server too old to send an interval still has to produce a sane countdown. */
|
||||
@Test
|
||||
fun `a missing retry interval falls back to the shared default`() {
|
||||
val outage = nextOutageState(
|
||||
GatewayEmbyHealth(monitored = true, reachable = false),
|
||||
existing = null,
|
||||
nowMillis = 0L,
|
||||
)!!
|
||||
assertEquals(MaintenanceMonitor.DEFAULT_RETRY_SECONDS, outage.retryIntervalSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the countdown never runs negative`() {
|
||||
assertEquals(0, secondsUntil(deadlineMillis = 1_000L, nowMillis = 9_000L))
|
||||
assertEquals(9, secondsUntil(deadlineMillis = 9_000L, nowMillis = 0L))
|
||||
// A part-second still has time left in it, so it rounds up rather than to zero.
|
||||
assertEquals(1, secondsUntil(deadlineMillis = 400L, nowMillis = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the countdown says what is about to happen`() {
|
||||
assertEquals("Retrying now…", retryLabel(0))
|
||||
assertEquals("Retrying now…", retryLabel(-3))
|
||||
assertEquals("Retrying in 1s", retryLabel(1))
|
||||
assertEquals("Retrying in 42s", retryLabel(42))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
||||
@@ -7,6 +8,7 @@ import com.ponzischeme89.memby.data.model.GatewayMovieRatings
|
||||
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.GatewayPreferences
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
@@ -46,6 +48,21 @@ class GatewayPayloadTest {
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `decodes ratings carried on a row item and defaults them when absent`() {
|
||||
val carried = json.decodeFromString<BaseItem>(
|
||||
"""{"Id":"7","Name":"Arrival","Type":"Movie","MembyRatings":[{"source":"imdb","name":"IMDb","score":"7.9","scale":"/10"}]}""",
|
||||
)
|
||||
assertEquals(listOf("IMDb"), carried.membyRatings.map { it.name })
|
||||
assertEquals("7.9", carried.membyRatings.single().score)
|
||||
|
||||
// A gateway that has never looked the title up, an older build, and every cached
|
||||
// home payload written before this field existed all decode to no ratings rather
|
||||
// than failing — the card then falls back to the dedicated request on focus.
|
||||
val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
|
||||
assertTrue(bare.membyRatings.isEmpty())
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
@@ -80,22 +97,92 @@ class GatewayPayloadTest {
|
||||
assertEquals("safe_mode", policy.features.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the live emby reachability the outage bar renders`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"emby":{"monitored":true,"reachable":false,"since":"2026-08-04T19:04:00Z","checkedAt":"2026-08-04T19:05:00Z","retrySeconds":60}}""",
|
||||
)
|
||||
|
||||
assertTrue(status.emby.isOutage)
|
||||
assertEquals(60, status.emby.retrySeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* A server predating the field must not produce a red bar. The default is a healthy,
|
||||
* unmonitored reading precisely so an app that cannot tell says nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a status payload without emby health reports no outage`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>("""{"maintenance":false}""")
|
||||
|
||||
assertEquals(false, status.emby.isOutage)
|
||||
assertEquals(0L, status.preferencesRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the settings revision an operator push arrives as`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"preferencesRevision":12}""",
|
||||
)
|
||||
|
||||
assertEquals(12L, status.preferencesRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
* The document stays a JsonObject on the wire so a server that has learned a setting
|
||||
* this build has never heard of is still decodable — the unknown key is simply carried
|
||||
* past by [decodeUserPreferences].
|
||||
*/
|
||||
@Test
|
||||
fun `decodes a settings document containing an unknown setting`() {
|
||||
val payload = json.decodeFromString<GatewayPreferences>(
|
||||
"""{"schemaVersion":1,"revision":4,"source":"admin","preferences":{"homeCardDensity":"large","hideWatchedMovies":true,"somethingNewer":"value"}}""",
|
||||
)
|
||||
val decoded = decodeUserPreferences(payload.preferences)
|
||||
|
||||
assertEquals(4L, payload.revision)
|
||||
assertEquals("admin", payload.source)
|
||||
assertEquals("large", decoded.homeCardDensity)
|
||||
assertTrue(decoded.hideWatchedMovies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes recommendation onboarding ratings and emby items`() {
|
||||
val onboarding = json.decodeFromString<RecommendationOnboarding>(
|
||||
"""{
|
||||
"completed":false,
|
||||
"prompted":true,
|
||||
"ratings":{"movie-1":5},
|
||||
"items":[
|
||||
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
|
||||
{"Id":"series-1","Name":"Severance","Type":"Series"}
|
||||
]
|
||||
],
|
||||
"movies":[{"Id":"movie-1","Name":"Arrival","Type":"Movie"}],
|
||||
"shows":[{"Id":"series-1","Name":"Severance","Type":"Series"}],
|
||||
"actors":[{"id":"actor-1","name":"Jeremy Renner","imageTag":"portrait-1"}],
|
||||
"actresses":[{"id":"actor-2","name":"Amy Adams","imageTag":"portrait-2"}],
|
||||
"directors":[{"id":"director-1","name":"Denis Villeneuve"}]
|
||||
}""",
|
||||
)
|
||||
|
||||
assertEquals(false, onboarding.completed)
|
||||
assertTrue(onboarding.prompted)
|
||||
assertEquals(5, onboarding.ratings["movie-1"])
|
||||
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
|
||||
assertEquals(listOf("Arrival"), onboarding.movies.map { it.name })
|
||||
assertEquals(listOf("Severance"), onboarding.shows.map { it.name })
|
||||
assertEquals("Jeremy Renner", onboarding.actors.single().name)
|
||||
assertEquals("Amy Adams", onboarding.actresses.single().name)
|
||||
assertEquals("Denis Villeneuve", onboarding.directors.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy onboarding response remains promptable`() {
|
||||
val onboarding = json.decodeFromString<RecommendationOnboarding>(
|
||||
"""{"completed":false,"items":[]}""",
|
||||
)
|
||||
|
||||
assertTrue(onboarding.prompted)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -198,7 +285,10 @@ class GatewayPayloadTest {
|
||||
"MembyAirLabel":"Tomorrow: 8:00 PM",
|
||||
"MembyAvailability":"downloading",
|
||||
"MembyAvailabilityText":"Downloading",
|
||||
"MembyPlayable":false
|
||||
"MembyLifecycle":"continuing",
|
||||
"MembyLifecycleText":"CONTINUING",
|
||||
"MembyPlayable":false,
|
||||
"MembySeriesItemId":"emby-1041"
|
||||
}]
|
||||
}],
|
||||
"continueWatching":[],
|
||||
@@ -215,7 +305,41 @@ class GatewayPayloadTest {
|
||||
assertEquals("S02E04", item.membyEpisodeCode)
|
||||
assertEquals("Tomorrow", item.membyAirDayLabel)
|
||||
assertEquals("Downloading", item.membyAvailabilityText)
|
||||
// Sonarr's lifecycle for the show, which is a different fact from this episode's
|
||||
// availability and wears its own badge.
|
||||
assertEquals("continuing", item.membyLifecycle)
|
||||
assertEquals("CONTINUING", item.membyLifecycleText)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
// The link that lets an unplayable card open the show's own page.
|
||||
assertEquals("emby-1041", item.membySeriesItemId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a schedule card for a show Emby has not imported carries no series link`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows":[{
|
||||
"id":"sonarr-airing-today",
|
||||
"title":"Shows airing in the next 5 days",
|
||||
"kind":"schedule",
|
||||
"items":[{
|
||||
"Id":"sonarr:9:11",
|
||||
"Name":"Unimported",
|
||||
"Type":"MembySonarrEpisode",
|
||||
"MembySource":"sonarr",
|
||||
"MembyAirDayLabel":"Friday",
|
||||
"MembyAirLabel":"Friday: 8:00 PM",
|
||||
"MembyPlayable":false
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
||||
|
||||
assertTrue(item.isTvSchedule)
|
||||
assertNull(item.membySeriesItemId)
|
||||
assertNull(item.membyLifecycle)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -237,6 +361,8 @@ class GatewayPayloadTest {
|
||||
"MembyAirLabel":"Digital release Saturday",
|
||||
"MembyAvailability":"upcoming",
|
||||
"MembyAvailabilityText":"Upcoming digital release",
|
||||
"MembyLifecycle":"incinemas",
|
||||
"MembyLifecycleText":"IN CINEMAS",
|
||||
"MembyPlayable":false
|
||||
}]
|
||||
}]
|
||||
@@ -250,6 +376,8 @@ class GatewayPayloadTest {
|
||||
assertTrue(item.isMovieSchedule)
|
||||
assertTrue(item.isSchedule)
|
||||
assertEquals("Digital release Saturday", item.membyAirLabel)
|
||||
assertEquals("incinemas", item.membyLifecycle)
|
||||
assertEquals("IN CINEMAS", item.membyLifecycleText)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
}
|
||||
|
||||
@@ -286,6 +414,10 @@ class GatewayPayloadTest {
|
||||
"""{"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)
|
||||
// Absent on an older gateway: subtitles on, nothing chosen, so the television falls
|
||||
// back to its own pick rather than being told to turn them off.
|
||||
assertTrue(playback.subtitlesEnabled)
|
||||
assertEquals("", playback.selectedSubtitleId)
|
||||
assertEquals(42_000L, playback.resumePositionMs)
|
||||
assertEquals("S01E01", playback.episodeCode)
|
||||
assertEquals(3_420_000L, playback.runtimeMs)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlayablePrefetchTest {
|
||||
@Test
|
||||
fun `launch accepts only a recent focus prefetch`() {
|
||||
val now = 100_000L
|
||||
|
||||
assertTrue(isFreshPlayablePrefetch(now - PLAYABLE_PREFETCH_MAX_AGE_MS, now))
|
||||
assertFalse(isFreshPlayablePrefetch(now - PLAYABLE_PREFETCH_MAX_AGE_MS - 1L, now))
|
||||
assertFalse(isFreshPlayablePrefetch(now + 1L, now))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaRating
|
||||
import com.ponzischeme89.memby.data.model.displayable
|
||||
import com.ponzischeme89.memby.data.model.formattedScore
|
||||
import com.ponzischeme89.memby.data.model.ratingDisplayLimit
|
||||
import com.ponzischeme89.memby.data.model.ratingsStripVisible
|
||||
import com.ponzischeme89.memby.data.model.wordmark
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RatingsTest {
|
||||
@Test fun `sources retain familiar compact presentation`() {
|
||||
val imdb = MediaRating("imdb", "IMDb", "8.2", "/10")
|
||||
val rt = MediaRating("tomatoes", "Rotten Tomatoes", "91", "%")
|
||||
val letterboxd = MediaRating("letterboxd", "Letterboxd", "4.1", "/5")
|
||||
assertEquals("IMDb", imdb.wordmark())
|
||||
assertEquals("8.2", imdb.formattedScore())
|
||||
assertEquals("RT", rt.wordmark())
|
||||
assertEquals("91%", rt.formattedScore())
|
||||
assertEquals("4.1", letterboxd.formattedScore())
|
||||
}
|
||||
|
||||
@Test fun `missing invalid and duplicate scores are omitted`() {
|
||||
val ratings = listOf(
|
||||
MediaRating("imdb", "IMDb", "", "/10"),
|
||||
MediaRating("tmdb", "TMDb", "0", "/10"),
|
||||
MediaRating("trakt", "Trakt", "80", "%"),
|
||||
MediaRating("trakt", "Trakt", "81", "%"),
|
||||
)
|
||||
assertEquals(listOf("80"), ratings.displayable().map(MediaRating::score))
|
||||
}
|
||||
|
||||
@Test fun `visibility setting hides the whole strip`() {
|
||||
val ratings = listOf(MediaRating("imdb", "IMDb", "8.2", "/10"))
|
||||
assertTrue(ratingsStripVisible(true, ratings))
|
||||
assertFalse(ratingsStripVisible(false, ratings))
|
||||
assertFalse(ratingsStripVisible(true, emptyList()))
|
||||
}
|
||||
|
||||
@Test fun `narrow layouts truncate progressively`() {
|
||||
assertEquals(1, ratingDisplayLimit(100))
|
||||
assertEquals(2, ratingDisplayLimit(180))
|
||||
assertEquals(3, ratingDisplayLimit(300))
|
||||
assertEquals(Int.MAX_VALUE, ratingDisplayLimit(500))
|
||||
}
|
||||
|
||||
@Test fun `ratings preference belongs to each profile`() {
|
||||
val profile = EmbyProfile("id", "server", "token", "user", "name", showRatingsStrip = false)
|
||||
assertFalse(profile.showRatingsStrip)
|
||||
assertTrue(profile.copy(id = "other", showRatingsStrip = true).showRatingsStrip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* When Memby is willing to guess a finish date, and when it holds its tongue.
|
||||
*
|
||||
* The guards matter more than the arithmetic here: an estimate is a promise made to
|
||||
* somebody about their own evening, so most of these tests are about the cases that must
|
||||
* produce nothing at all rather than about the ones that produce a date.
|
||||
*/
|
||||
class SeriesPaceTest {
|
||||
|
||||
// A fixed "now" so every case reads as a calendar rather than as offsets: midday on
|
||||
// Wednesday 5 August 2026, UTC, with the television in UTC too.
|
||||
private val now = playedAtMillis("2026-08-05T12:00:00Z")!!
|
||||
private val utc = 0
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordinary viewing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an episode a night projects a night per remaining episode`() {
|
||||
val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc)
|
||||
assertNotNull(estimate)
|
||||
assertEquals(5, estimate!!.remainingEpisodes)
|
||||
assertEquals(1.0, estimate.episodesPerDay, 0.001)
|
||||
// Five left at one a day: one tonight and the last four days from now.
|
||||
assertEquals(4, estimate.daysAway)
|
||||
assertEquals("Estimated finish: 9 August", seriesPaceLabel(estimate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a binge across days is measured as a binge`() {
|
||||
// Three on Monday, three on Tuesday: three a day, not six.
|
||||
val episodes = library(
|
||||
watched = listOf(
|
||||
"2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T21:00:00Z",
|
||||
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
|
||||
),
|
||||
remaining = 6,
|
||||
)
|
||||
val estimate = estimateSeriesPace(episodes, now, utc)!!
|
||||
assertEquals(3.0, estimate.episodesPerDay, 0.001)
|
||||
assertEquals(1, estimate.daysAway)
|
||||
assertEquals("You'll likely finish tomorrow", seriesPaceLabel(estimate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fast pace with little left finishes today`() {
|
||||
val episodes = library(
|
||||
watched = listOf(
|
||||
"2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z",
|
||||
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z",
|
||||
),
|
||||
remaining = 2,
|
||||
)
|
||||
assertEquals("You'll likely finish today", seriesPaceLabel(estimateSeriesPace(episodes, now, utc)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slow pace over a long tail is rounded to weeks rather than dated`() {
|
||||
// One a week, forty left: naming a Tuesday nine months out would be a fiction.
|
||||
val episodes = library(
|
||||
watched = listOf("2026-07-15T20:00:00Z", "2026-07-22T20:00:00Z", "2026-07-29T20:00:00Z"),
|
||||
remaining = 8,
|
||||
)
|
||||
val label = seriesPaceLabel(estimateSeriesPace(episodes, now, utc))
|
||||
assertEquals("At your current pace: about 6 weeks remaining", label)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Not enough to go on
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `one episode is never a pace`() {
|
||||
val episodes = library(watched = listOf("2026-08-04T20:00:00Z"), remaining = 6)
|
||||
assertNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the opening of a binge does not project one`() {
|
||||
// Three episodes, one evening. There is no daily rate in a single sitting, and
|
||||
// reading one off it would promise a finish this week.
|
||||
val episodes = library(
|
||||
watched = listOf(
|
||||
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
|
||||
),
|
||||
remaining = 20,
|
||||
)
|
||||
assertNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two episodes on separate days are enough when they are close together`() {
|
||||
val episodes = library(
|
||||
watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z"),
|
||||
remaining = 4,
|
||||
)
|
||||
val estimate = estimateSeriesPace(episodes, now, utc)
|
||||
assertNotNull(estimate)
|
||||
assertEquals(1.0, estimate!!.episodesPerDay, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two episodes a fortnight apart are not a rhythm`() {
|
||||
val episodes = library(
|
||||
watched = listOf("2026-07-24T20:00:00Z", "2026-08-04T20:00:00Z"),
|
||||
remaining = 4,
|
||||
)
|
||||
assertNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing watched at all says nothing`() {
|
||||
assertNull(estimateSeriesPace(library(watched = emptyList(), remaining = 8), now, utc))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breaks and returns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a long silence is not averaged into the current pace`() {
|
||||
// A season watched last summer, then a return this week. Including the gap would
|
||||
// put the finish years out; only the return counts.
|
||||
val episodes = library(
|
||||
watched = listOf(
|
||||
"2025-08-01T20:00:00Z", "2025-08-02T20:00:00Z", "2025-08-03T20:00:00Z",
|
||||
"2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z",
|
||||
),
|
||||
remaining = 4,
|
||||
)
|
||||
val estimate = estimateSeriesPace(episodes, now, utc)!!
|
||||
assertEquals(1.0, estimate.episodesPerDay, 0.001)
|
||||
assertEquals(3, estimate.daysAway)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a viewer who has not come back has no current pace`() {
|
||||
val episodes = library(
|
||||
watched = listOf(
|
||||
"2026-05-01T20:00:00Z", "2026-05-02T20:00:00Z", "2026-05-03T20:00:00Z",
|
||||
),
|
||||
remaining = 6,
|
||||
)
|
||||
assertNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `speeding up moves the date in`() {
|
||||
val steady = library(
|
||||
watched = listOf("2026-08-01T20:00:00Z", "2026-08-02T20:00:00Z", "2026-08-03T20:00:00Z"),
|
||||
remaining = 6,
|
||||
)
|
||||
val quickened = library(
|
||||
watched = listOf(
|
||||
"2026-08-03T18:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T22:00:00Z",
|
||||
"2026-08-04T18:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T22:00:00Z",
|
||||
),
|
||||
remaining = 6,
|
||||
)
|
||||
val before = estimateSeriesPace(steady, now, utc)!!
|
||||
val after = estimateSeriesPace(quickened, now, utc)!!
|
||||
assertTrue(after.episodesPerDay > before.episodesPerDay)
|
||||
assertTrue(after.daysAway < before.daysAway)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nothing worth saying
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a finished series has no estimate`() {
|
||||
assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 0), now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one episode left needs no date`() {
|
||||
assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 1), now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a horizon beyond a year is not offered`() {
|
||||
// An episode every couple of weeks against a very long show. The arithmetic is
|
||||
// sound and the answer — some day in 2031 — is not one to put on a television.
|
||||
val episodes = library(
|
||||
watched = listOf("2026-07-08T20:00:00Z", "2026-07-21T20:00:00Z", "2026-08-03T20:00:00Z"),
|
||||
remaining = 200,
|
||||
)
|
||||
assertNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label is null when the estimate is`() {
|
||||
assertNull(seriesPaceLabel(null))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ongoing shows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a show still in production is caught up with, not finished`() {
|
||||
val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc, ongoing = true)!!
|
||||
assertTrue(estimate.catchUp)
|
||||
assertEquals("You'll catch up around 9 August", seriesPaceLabel(estimate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `catching up tomorrow is worded as catching up`() {
|
||||
val episodes = library(
|
||||
watched = listOf(
|
||||
"2026-08-03T19:00:00Z", "2026-08-03T21:00:00Z",
|
||||
"2026-08-04T19:00:00Z", "2026-08-04T21:00:00Z",
|
||||
),
|
||||
remaining = 4,
|
||||
)
|
||||
assertEquals(
|
||||
"You'll likely catch up tomorrow",
|
||||
seriesPaceLabel(estimateSeriesPace(episodes, now, utc, ongoing = true)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Awkward data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `specials count towards neither the pace nor what is left`() {
|
||||
val episodes = nightly(watched = 4, remaining = 5) +
|
||||
listOf(
|
||||
episode(season = 0, number = 1, playedAt = "2026-08-04T23:00:00Z"),
|
||||
episode(season = 0, number = 2, playedAt = null),
|
||||
episode(season = 0, number = 3, playedAt = null),
|
||||
)
|
||||
val estimate = estimateSeriesPace(episodes, now, utc)!!
|
||||
assertEquals(5, estimate.remainingEpisodes)
|
||||
assertEquals(1.0, estimate.episodesPerDay, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a library of nothing but specials is treated as the show`() {
|
||||
val episodes = listOf(
|
||||
episode(season = 0, number = 1, playedAt = "2026-08-03T20:00:00Z"),
|
||||
episode(season = 0, number = 2, playedAt = "2026-08-04T20:00:00Z"),
|
||||
episode(season = 0, number = 3, playedAt = null),
|
||||
episode(season = 0, number = 4, playedAt = null),
|
||||
)
|
||||
assertNotNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a part-watched episode is remaining and is not a completion`() {
|
||||
val episodes = nightly(watched = 3, remaining = 3).map { item ->
|
||||
if (item.indexNumber == 4) {
|
||||
item.copy(userData = UserItemData(played = false, playbackPositionTicks = 5_000_000_000L))
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
val estimate = estimateSeriesPace(episodes, now, utc)!!
|
||||
// Three unwatched, one of them the episode the viewer is part-way through, and a
|
||||
// pace measured only from the three that were actually completed.
|
||||
assertEquals(3, estimate.remainingEpisodes)
|
||||
assertEquals(1.0, estimate.episodesPerDay, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of order and unstamped history is tolerated`() {
|
||||
// Emby's "never played" sentinel on one watched episode, and the list shuffled.
|
||||
val episodes = listOf(
|
||||
episode(1, 3, "2026-08-04T20:00:00Z"),
|
||||
episode(1, 1, "2026-08-02T20:00:00Z"),
|
||||
episode(1, 5, null),
|
||||
episode(1, 2, "0001-01-01T00:00:00Z", played = true),
|
||||
episode(1, 4, "2026-08-03T20:00:00Z"),
|
||||
episode(1, 6, null),
|
||||
episode(1, 7, null),
|
||||
)
|
||||
val estimate = estimateSeriesPace(episodes, now, utc)!!
|
||||
assertEquals(3, estimate.remainingEpisodes)
|
||||
// Three dated completions across three days.
|
||||
assertEquals(1.0, estimate.episodesPerDay, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timestamp from the future is a clock, not a habit`() {
|
||||
val episodes = library(
|
||||
watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z", "2027-01-01T20:00:00Z"),
|
||||
remaining = 4,
|
||||
)
|
||||
assertNull(estimateSeriesPace(episodes, now, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty library says nothing`() {
|
||||
assertNull(estimateSeriesPace(emptyList(), now, utc))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The viewer's own midnight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `days are counted in the television's zone, not Greenwich`() {
|
||||
// Two episodes at 13:00 UTC on consecutive days. In UTC+13 that is one o'clock the
|
||||
// following morning, still two separate local days — but the *finish* is counted
|
||||
// from the local today, which is already 6 August in Auckland.
|
||||
val nz = 13 * 60 * 60 * 1000
|
||||
val episodes = library(
|
||||
watched = listOf("2026-08-03T13:00:00Z", "2026-08-04T13:00:00Z"),
|
||||
remaining = 3,
|
||||
)
|
||||
val here = estimateSeriesPace(episodes, now, utc)!!
|
||||
val there = estimateSeriesPace(episodes, now, nz)!!
|
||||
assertEquals(here.finishEpochDay + 1, there.finishEpochDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a date is written day first with the month named`() {
|
||||
// 1 March 2028 — a leap year, which is where the calendar arithmetic goes wrong.
|
||||
assertEquals("1 March", formatPaceDate(playedAtMillis("2028-03-01T00:00:00Z")!! / 86_400_000L))
|
||||
assertEquals("29 February", formatPaceDate(playedAtMillis("2028-02-29T00:00:00Z")!! / 86_400_000L))
|
||||
assertEquals("31 December", formatPaceDate(playedAtMillis("2026-12-31T00:00:00Z")!! / 86_400_000L))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** [watched] episodes on consecutive nights ending yesterday, then [remaining] unwatched. */
|
||||
private fun nightly(watched: Int, remaining: Int): List<BaseItem> {
|
||||
val stamps = (0 until watched).map { index ->
|
||||
val day = 4 - (watched - 1 - index)
|
||||
"2026-08-%02dT20:00:00Z".format(day)
|
||||
}
|
||||
return library(stamps, remaining)
|
||||
}
|
||||
|
||||
private fun library(watched: List<String>, remaining: Int): List<BaseItem> =
|
||||
watched.mapIndexed { index, at -> episode(1, index + 1, at) } +
|
||||
(0 until remaining).map { episode(1, watched.size + it + 1, null) }
|
||||
|
||||
private fun episode(
|
||||
season: Int,
|
||||
number: Int,
|
||||
playedAt: String?,
|
||||
played: Boolean = playedAt != null,
|
||||
) = BaseItem(
|
||||
id = "s${season}e$number",
|
||||
name = "Episode $number",
|
||||
type = "Episode",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
userData = UserItemData(played = played, lastPlayedDate = playedAt),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Mirrors the private constant, so a schema bump that forgets these tests fails loudly. */
|
||||
private const val CURRENT_SETTINGS_SCHEMA_FOR_TEST = 2
|
||||
|
||||
class SettingsMigrationTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `migration repairs profiles individually and retains malformed source`() {
|
||||
val valid = EmbyProfile(
|
||||
id = "https://emby::alex",
|
||||
serverUrl = "https://emby",
|
||||
token = "token",
|
||||
userId = "alex",
|
||||
username = "Alex",
|
||||
)
|
||||
val raw = "[${json.encodeToString(valid)},{\"id\":17}]"
|
||||
val source = mutablePreferencesOf(stringPreferencesKey("profiles") to raw)
|
||||
|
||||
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
|
||||
val repaired = json.decodeFromString<List<EmbyProfile>>(
|
||||
migrated[stringPreferencesKey("profiles")]!!,
|
||||
)
|
||||
|
||||
assertEquals(listOf(valid), repaired)
|
||||
assertEquals(raw, migrated[stringPreferencesKey("profiles_recovery_v0")])
|
||||
assertEquals(
|
||||
CURRENT_SETTINGS_SCHEMA_FOR_TEST,
|
||||
migrated[intPreferencesKey("settings_schema_version")],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration recovers a complete profile from legacy flat settings`() {
|
||||
val source = mutablePreferencesOf(
|
||||
stringPreferencesKey("server_url") to "https://emby/",
|
||||
stringPreferencesKey("token") to "token",
|
||||
stringPreferencesKey("user_id") to "alex",
|
||||
intPreferencesKey("for_you_minutes") to 45,
|
||||
booleanPreferencesKey("has_opened_for_you") to true,
|
||||
booleanPreferencesKey("show_ratings_strip") to false,
|
||||
)
|
||||
|
||||
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
|
||||
val profile = json.decodeFromString<List<EmbyProfile>>(
|
||||
migrated[stringPreferencesKey("profiles")]!!,
|
||||
).single()
|
||||
|
||||
assertEquals("https://emby::alex", profile.id)
|
||||
assertEquals("Memby user", profile.username)
|
||||
assertEquals(45, profile.forYouMinutes)
|
||||
assertTrue(profile.hasOpenedForYou)
|
||||
assertFalse(profile.showRatingsStrip)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration accepts profiles written by a newer app version`() {
|
||||
val raw = """[{"id":"server::user","serverUrl":"server","token":"token","userId":"user","username":"User","futureSetting":"kept-compatible"}]"""
|
||||
val migrated = SettingsMigrationLogic.migrateToCurrent(
|
||||
mutablePreferencesOf(stringPreferencesKey("profiles") to raw),
|
||||
)
|
||||
val profiles = json.decodeFromString<List<EmbyProfile>>(
|
||||
migrated[stringPreferencesKey("profiles")]!!,
|
||||
)
|
||||
|
||||
assertEquals("user", profiles.single().userId)
|
||||
assertEquals(null, migrated[stringPreferencesKey("profiles_recovery_v0")])
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this schema step exists for. Before settings moved to the server the
|
||||
* three toggles were device-wide, so an existing install holds them in the flat keys
|
||||
* and in no profile at all. Nothing looks wrong until the viewer switches profile —
|
||||
* `applyProfile` copies profile values into the flat keys, so all three would come
|
||||
* back on, and the sync would then push that up as a deliberate choice.
|
||||
*/
|
||||
@Test
|
||||
fun `device-wide toggles are carried into every stored profile`() {
|
||||
val alex = EmbyProfile(
|
||||
id = "https://emby::alex", serverUrl = "https://emby",
|
||||
token = "token", userId = "alex", username = "Alex",
|
||||
)
|
||||
val sam = EmbyProfile(
|
||||
id = "https://emby::sam", serverUrl = "https://emby",
|
||||
token = "token", userId = "sam", username = "Sam",
|
||||
)
|
||||
// Written by the previous build: schema 1, and the profiles carry no toggles.
|
||||
val stored = """[${json.encodeToString(alex)},${json.encodeToString(sam)}]"""
|
||||
.replace(Regex(""","showTitleLogo":[a-z]+"""), "")
|
||||
val source = mutablePreferencesOf(
|
||||
intPreferencesKey("settings_schema_version") to 1,
|
||||
stringPreferencesKey("profiles") to stored,
|
||||
booleanPreferencesKey("show_title_logo") to false,
|
||||
booleanPreferencesKey("auto_play_next_episode") to false,
|
||||
booleanPreferencesKey("show_ten_minute_reminder") to true,
|
||||
)
|
||||
|
||||
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
|
||||
val profiles = json.decodeFromString<List<EmbyProfile>>(
|
||||
migrated[stringPreferencesKey("profiles")]!!,
|
||||
)
|
||||
|
||||
assertEquals(2, profiles.size)
|
||||
profiles.forEach { profile ->
|
||||
assertFalse("${profile.userId} lost the title logo choice", profile.showTitleLogo)
|
||||
assertFalse("${profile.userId} lost the auto-play choice", profile.autoPlayNextEpisode)
|
||||
assertTrue(profile.showTenMinuteReminder)
|
||||
}
|
||||
assertEquals(2, migrated[intPreferencesKey("settings_schema_version")])
|
||||
}
|
||||
|
||||
/**
|
||||
* The migration must run exactly once, and the schema version is the only thing making
|
||||
* that true — it cannot detect its own work, because the encoder omits default values
|
||||
* and so a profile that chose `true` is byte-identical to one that never chose.
|
||||
*/
|
||||
@Test
|
||||
fun `the toggle migration does not run a second time`() {
|
||||
val profile = EmbyProfile(
|
||||
id = "https://emby::alex", serverUrl = "https://emby",
|
||||
token = "token", userId = "alex", username = "Alex",
|
||||
)
|
||||
val source = mutablePreferencesOf(
|
||||
intPreferencesKey("settings_schema_version") to 1,
|
||||
stringPreferencesKey("profiles") to "[${json.encodeToString(profile)}]",
|
||||
booleanPreferencesKey("show_title_logo") to false,
|
||||
)
|
||||
|
||||
val once = SettingsMigrationLogic.migrateToCurrent(source)
|
||||
// The viewer turns it back on afterwards; the device-wide key is stale from here
|
||||
// on, and must never be applied again.
|
||||
val afterEdit = once.toMutablePreferences().apply {
|
||||
this[stringPreferencesKey("profiles")] =
|
||||
"[${json.encodeToString(profile.copy(showTitleLogo = true))}]"
|
||||
}
|
||||
|
||||
val twice = SettingsMigrationLogic.migrateToCurrent(afterEdit)
|
||||
|
||||
assertTrue(
|
||||
json.decodeFromString<List<EmbyProfile>>(
|
||||
twice[stringPreferencesKey("profiles")]!!,
|
||||
).single().showTitleLogo,
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing chosen means nothing to preserve, and no whole-file rewrite to pay for. */
|
||||
@Test
|
||||
fun `an install with default toggles is left alone`() {
|
||||
val profile = EmbyProfile(
|
||||
id = "https://emby::alex", serverUrl = "https://emby",
|
||||
token = "token", userId = "alex", username = "Alex",
|
||||
)
|
||||
val stored = "[${json.encodeToString(profile)}]"
|
||||
val source = mutablePreferencesOf(
|
||||
intPreferencesKey("settings_schema_version") to 1,
|
||||
stringPreferencesKey("profiles") to stored,
|
||||
)
|
||||
|
||||
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
|
||||
|
||||
assertEquals(stored, migrated[stringPreferencesKey("profiles")])
|
||||
assertEquals(2, migrated[intPreferencesKey("settings_schema_version")])
|
||||
}
|
||||
|
||||
/**
|
||||
* A first-ever launch of the new build on an install that predates the profile list
|
||||
* has to come out at the current schema in one pass, not stop halfway.
|
||||
*/
|
||||
@Test
|
||||
fun `an unversioned install migrates all the way to the current schema`() {
|
||||
val source = mutablePreferencesOf(
|
||||
stringPreferencesKey("server_url") to "https://emby/",
|
||||
stringPreferencesKey("token") to "token",
|
||||
stringPreferencesKey("user_id") to "alex",
|
||||
booleanPreferencesKey("auto_play_next_episode") to false,
|
||||
)
|
||||
|
||||
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
|
||||
val profile = json.decodeFromString<List<EmbyProfile>>(
|
||||
migrated[stringPreferencesKey("profiles")]!!,
|
||||
).single()
|
||||
|
||||
assertEquals(CURRENT_SETTINGS_SCHEMA_FOR_TEST, migrated[intPreferencesKey("settings_schema_version")])
|
||||
assertFalse(profile.autoPlayNextEpisode)
|
||||
// Never synced, so the first sync pushes these values up rather than pulling
|
||||
// defaults down over them.
|
||||
assertEquals(0L, profile.preferencesRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration is idempotent and does not alter a future schema`() {
|
||||
val current = SettingsMigrationLogic.migrateToCurrent(mutablePreferencesOf())
|
||||
assertEquals(current, SettingsMigrationLogic.migrateToCurrent(current))
|
||||
|
||||
val future = mutablePreferencesOf(
|
||||
intPreferencesKey("settings_schema_version") to 99,
|
||||
stringPreferencesKey("profiles") to "future-format",
|
||||
)
|
||||
val unchanged = SettingsMigrationLogic.migrateToCurrent(future)
|
||||
assertNotNull(unchanged)
|
||||
assertEquals("future-format", unchanged[stringPreferencesKey("profiles")])
|
||||
assertEquals(99, unchanged[intPreferencesKey("settings_schema_version")])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The direct path's copy of the gateway's subtitle rule. It exists so the two agree — the
|
||||
* matching Go tests are in `server/internal/api/subtitles_test.go`, and the cases here are
|
||||
* deliberately the same ones.
|
||||
*/
|
||||
class SubtitlePreferenceTest {
|
||||
private fun track(
|
||||
id: String,
|
||||
language: String?,
|
||||
default: Boolean = false,
|
||||
forced: Boolean = false,
|
||||
hearingImpaired: Boolean = false,
|
||||
) = SubtitleCandidate(id, language, default, forced, hearingImpaired)
|
||||
|
||||
@Test
|
||||
fun `folds the codes Emby writes onto one vocabulary`() {
|
||||
assertEquals("it", normalizeSubtitleLanguage("ita"))
|
||||
assertEquals("it", normalizeSubtitleLanguage(" ITA "))
|
||||
assertEquals("fr", normalizeSubtitleLanguage("fra"))
|
||||
assertEquals("fr", normalizeSubtitleLanguage("fre"))
|
||||
assertEquals("pt", normalizeSubtitleLanguage("pt-BR"))
|
||||
assertEquals("", normalizeSubtitleLanguage(null))
|
||||
// An unrecognised code survives as itself, so an exact match still works.
|
||||
assertEquals("klingon", normalizeSubtitleLanguage("Klingon"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subtitles turned off means nothing is selected`() {
|
||||
val tracks = listOf(track("1", "eng", default = true))
|
||||
assertNull(selectSubtitleId(tracks, enabled = false, language = "en"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chosen language takes the full track, not the forced or SDH one`() {
|
||||
val tracks = listOf(
|
||||
track("1", "eng", default = true),
|
||||
track("2", "ita", forced = true),
|
||||
track("3", "ita", hearingImpaired = true),
|
||||
track("4", "ita"),
|
||||
)
|
||||
|
||||
assertEquals("4", selectSubtitleId(tracks, enabled = true, language = "it"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a language the title does not have falls back to forced only`() {
|
||||
val withForced = listOf(
|
||||
track("1", "eng", default = true),
|
||||
track("2", "eng", forced = true),
|
||||
)
|
||||
assertEquals("2", selectSubtitleId(withForced, enabled = true, language = "it"))
|
||||
|
||||
// Nothing rather than English, which the viewer did not ask for.
|
||||
val withoutForced = listOf(track("1", "eng", default = true))
|
||||
assertNull(selectSubtitleId(withoutForced, enabled = true, language = "it"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no chosen language keeps the flag order an untouched install had`() {
|
||||
val tracks = listOf(
|
||||
track("1", "eng"),
|
||||
track("2", "eng", default = true),
|
||||
track("3", "eng", forced = true),
|
||||
)
|
||||
|
||||
assertEquals("3", selectSubtitleId(tracks, enabled = true, SUBTITLE_LANGUAGE_AUTO))
|
||||
assertEquals("2", selectSubtitleId(tracks.take(2), enabled = true, language = ""))
|
||||
assertEquals("1", selectSubtitleId(tracks.take(1), enabled = true, SUBTITLE_LANGUAGE_AUTO))
|
||||
assertNull(selectSubtitleId(emptyList(), enabled = true, SUBTITLE_LANGUAGE_AUTO))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the choice rides the synced settings document`() {
|
||||
val local = UserPreferences(subtitlesEnabled = false, subtitleLanguage = "it")
|
||||
|
||||
val encoded = local.encode()
|
||||
assertEquals(false, encoded["subtitlesEnabled"].toString().toBoolean())
|
||||
assertEquals(local, decodeUserPreferences(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gateway that omits the keys leaves this TV's choice alone`() {
|
||||
// The whole point of the fallback: an older server growing the setting later must
|
||||
// not reset somebody who has already chosen one.
|
||||
val local = UserPreferences(subtitlesEnabled = false, subtitleLanguage = "it")
|
||||
|
||||
val decoded = decodeUserPreferences(buildJsonObject { put("showTitleLogo", true) }, local)
|
||||
|
||||
assertEquals(false, decoded.subtitlesEnabled)
|
||||
assertEquals("it", decoded.subtitleLanguage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private fun json(text: String): JsonObject = Json.parseToJsonElement(text) as JsonObject
|
||||
|
||||
class UserPreferencesTest {
|
||||
|
||||
/**
|
||||
* The document has to survive the round trip unchanged, or two televisions could
|
||||
* disagree about settings they had just agreed on — and the disagreement would push
|
||||
* itself back and forth forever, since a difference is what triggers a push.
|
||||
*/
|
||||
@Test
|
||||
fun `encoding and decoding is a fixed point`() {
|
||||
val original = UserPreferences(
|
||||
homeSections = listOf("latest", "continue"),
|
||||
homeCardDensity = "large",
|
||||
homeArtworkStyle = "poster",
|
||||
showHomeCardMetadata = false,
|
||||
showRatingsStrip = false,
|
||||
hideWatchedMovies = true,
|
||||
showTitleLogo = false,
|
||||
welcomeQuoteStyle = "homicidal",
|
||||
autoPlayNextEpisode = false,
|
||||
showTenMinuteReminder = false,
|
||||
seekIntervalSeconds = 30,
|
||||
forYouMinutes = 60,
|
||||
homeRowOrder = listOf("recommended", "latest"),
|
||||
homePinnedRows = listOf("continue"),
|
||||
homeHiddenRows = listOf("studio-a24"),
|
||||
)
|
||||
assertEquals(original, decodeUserPreferences(original.encode()))
|
||||
}
|
||||
|
||||
/**
|
||||
* A key the server did not send must leave that setting alone. This is what lets the
|
||||
* gateway grow a setting before every television in the house has the release that
|
||||
* knows about it — the alternative is a silent reset of anything not yet understood.
|
||||
*/
|
||||
@Test
|
||||
fun `a missing key keeps the local value`() {
|
||||
val local = UserPreferences(homeCardDensity = "compact", hideWatchedMovies = true)
|
||||
val decoded = decodeUserPreferences(json("""{"showRatingsStrip":false}"""), local)
|
||||
|
||||
assertEquals("compact", decoded.homeCardDensity)
|
||||
assertTrue(decoded.hideWatchedMovies)
|
||||
assertEquals(false, decoded.showRatingsStrip)
|
||||
}
|
||||
|
||||
/** A value of the wrong type is as good as absent, and must not become a reset. */
|
||||
@Test
|
||||
fun `a wrongly typed value keeps the local value`() {
|
||||
val local = UserPreferences(showTitleLogo = false, forYouMinutes = 120)
|
||||
val decoded = decodeUserPreferences(
|
||||
json("""{"showTitleLogo":"false","forYouMinutes":"120"}"""),
|
||||
local,
|
||||
)
|
||||
|
||||
assertEquals(false, decoded.showTitleLogo)
|
||||
assertEquals(120, decoded.forYouMinutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The skip interval is the one number both ends normalise. A value this build has no
|
||||
* vocabulary for must become the default rather than reaching the player as a step
|
||||
* size — the gateway's catalogue is allowed to grow one before every TV understands it.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown skip interval falls back to the default`() {
|
||||
assertEquals(
|
||||
DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||
decodeUserPreferences(json("""{"seekIntervalSeconds":45}""")).seekIntervalSeconds,
|
||||
)
|
||||
assertEquals(
|
||||
30,
|
||||
decodeUserPreferences(json("""{"seekIntervalSeconds":30}""")).seekIntervalSeconds,
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||
Settings(seekIntervalSeconds = 45).toUserPreferences().seekIntervalSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
/** An empty row selection would be a launcher with nothing on it. */
|
||||
@Test
|
||||
fun `an empty section list falls back rather than emptying the launcher`() {
|
||||
val local = UserPreferences(homeSections = listOf("favorites"))
|
||||
val decoded = decodeUserPreferences(json("""{"homeSections":[]}"""), local)
|
||||
|
||||
assertEquals(listOf("favorites"), decoded.homeSections)
|
||||
}
|
||||
|
||||
/** Row ids are stored newline-separated, so a blank entry would become a phantom row. */
|
||||
@Test
|
||||
fun `blank and duplicate row ids are dropped`() {
|
||||
val decoded = decodeUserPreferences(
|
||||
json("""{"homeRowOrder":["recommended"," ","recommended","latest",""]}"""),
|
||||
)
|
||||
assertEquals(listOf("recommended", "latest"), decoded.homeRowOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* The flat active-profile keys are what every screen renders from, so they are what a
|
||||
* push must carry — including the encoding used for the row lists.
|
||||
*/
|
||||
@Test
|
||||
fun `settings project onto the document the server holds`() {
|
||||
val settings = Settings(
|
||||
homeSections = "continue,latest",
|
||||
homeCardDensity = "compact",
|
||||
homeArtworkStyle = "backdrop",
|
||||
showHomeCardMetadata = false,
|
||||
showRatingsStrip = false,
|
||||
hideWatchedMovies = true,
|
||||
showTitleLogo = false,
|
||||
welcomeQuoteStyle = "positive",
|
||||
autoPlayNextEpisode = false,
|
||||
showTenMinuteReminder = false,
|
||||
forYouMinutes = 30,
|
||||
homeRowOrder = "recommended\nlatest",
|
||||
homePinnedRows = "continue",
|
||||
homeHiddenRows = "",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UserPreferences(
|
||||
homeSections = listOf("continue", "latest"),
|
||||
homeCardDensity = "compact",
|
||||
homeArtworkStyle = "backdrop",
|
||||
showHomeCardMetadata = false,
|
||||
showRatingsStrip = false,
|
||||
hideWatchedMovies = true,
|
||||
showTitleLogo = false,
|
||||
welcomeQuoteStyle = "positive",
|
||||
autoPlayNextEpisode = false,
|
||||
showTenMinuteReminder = false,
|
||||
forYouMinutes = 30,
|
||||
homeRowOrder = listOf("recommended", "latest"),
|
||||
homePinnedRows = listOf("continue"),
|
||||
homeHiddenRows = emptyList(),
|
||||
),
|
||||
settings.toUserPreferences(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing that identifies a *television* may reach the wire. Carrying the device name
|
||||
* or the update token across would rename someone's other set, or hand a private
|
||||
* credential to whichever server the document came from.
|
||||
*/
|
||||
@Test
|
||||
fun `device identity never enters the document`() {
|
||||
val encoded = Settings(
|
||||
deviceName = "Living room",
|
||||
deviceId = "device-1",
|
||||
updateToken = "gitea-secret",
|
||||
updateBaseUrl = "https://git.example",
|
||||
token = "session-token",
|
||||
ringColorHex = "FF0000",
|
||||
rotationIntervalSeconds = 45,
|
||||
).toUserPreferences().encode().toString()
|
||||
|
||||
listOf("Living room", "device-1", "gitea-secret", "git.example", "session-token")
|
||||
.forEach { assertTrue("$it reached the wire", it !in encoded) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.detail.formatAirDate
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AirDateTest {
|
||||
@Test
|
||||
fun `an Emby premiere date reads as a day, month and year`() {
|
||||
assertEquals("12 Mar 2024", formatAirDate("2024-03-12T00:00:00.0000000Z"))
|
||||
assertEquals("1 Jan 2019", formatAirDate("2019-01-01T00:00:00Z"))
|
||||
assertEquals("31 Dec 2026", formatAirDate("2026-12-31"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby writes a premiere as midnight UTC. Reading the date half literally is what stops
|
||||
* an episode broadcast on the 12th being listed as the 11th on every set west of
|
||||
* Greenwich, so a timestamp late in the day must still yield its own date.
|
||||
*/
|
||||
@Test
|
||||
fun `the date is taken literally, never shifted into the set's own zone`() {
|
||||
assertEquals("12 Mar 2024", formatAirDate("2024-03-12T23:30:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anything unusable is no date rather than a wrong one`() {
|
||||
assertNull(formatAirDate(null))
|
||||
assertNull(formatAirDate(""))
|
||||
assertNull(formatAirDate(" "))
|
||||
assertNull(formatAirDate("2024"))
|
||||
assertNull(formatAirDate("not a date at all"))
|
||||
assertNull(formatAirDate("2024/03/12"))
|
||||
assertNull(formatAirDate("2024-13-01T00:00:00Z"))
|
||||
assertNull(formatAirDate("2024-03-32T00:00:00Z"))
|
||||
assertNull(formatAirDate("20xx-03-12"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringNoticeTest {
|
||||
private fun card(
|
||||
availability: String = "upcoming",
|
||||
availabilityText: String = "Upcoming",
|
||||
dayLabel: String = "Tomorrow",
|
||||
airLabel: String = "Tomorrow: 9:00 PM",
|
||||
source: String? = "sonarr",
|
||||
seriesItemId: String? = "emby-42",
|
||||
) = BaseItem(
|
||||
id = "sonarr:7:42",
|
||||
name = "Northbound",
|
||||
type = "MembySonarrEpisode",
|
||||
genres = listOf("Drama"),
|
||||
productionYear = 2024,
|
||||
membySource = source,
|
||||
membyEpisodeTitle = "The Crossing",
|
||||
membyEpisodeCode = "S02E04",
|
||||
membyAirDayLabel = dayLabel,
|
||||
membyAirLabel = airLabel,
|
||||
membyAvailability = availability,
|
||||
membyAvailabilityText = availabilityText,
|
||||
membyPlayable = false,
|
||||
membySeriesItemId = seriesItemId,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `notice names the episode and repeats the card's own wording`() {
|
||||
val notice = requireNotNull(airingNoticeFor(card()))
|
||||
|
||||
assertEquals("AIRING TOMORROW", notice.label)
|
||||
assertTrue(notice.headline.startsWith("S02E04"))
|
||||
assertTrue(notice.headline.endsWith("The Crossing"))
|
||||
assertTrue(notice.detail.contains("Tomorrow: 9:00 PM"))
|
||||
assertTrue(notice.detail.contains("Upcoming"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode already on the server is not announced as airing`() {
|
||||
val notice = requireNotNull(
|
||||
airingNoticeFor(
|
||||
card(
|
||||
availability = "available",
|
||||
availabilityText = "Added at 8:12 PM",
|
||||
dayLabel = "Today",
|
||||
airLabel = "Aired today at 8:00 PM",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("NEW EPISODE READY", notice.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode that has aired but not landed says so`() {
|
||||
val notice = requireNotNull(
|
||||
airingNoticeFor(
|
||||
card(
|
||||
availability = "awaiting",
|
||||
availabilityText = "Awaiting download",
|
||||
dayLabel = "Today",
|
||||
airLabel = "Aired today at 8:00 PM",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("AIRED TODAY", notice.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an air time the gateway could not date is not given one`() {
|
||||
val notice = requireNotNull(
|
||||
airingNoticeFor(card(dayLabel = "Upcoming", airLabel = "Coming up")),
|
||||
)
|
||||
|
||||
assertEquals("UPCOMING EPISODE", notice.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only schedule cards carry a notice`() {
|
||||
assertNull(airingNoticeFor(card(source = null)))
|
||||
assertNull(airingNoticeFor(card(source = "radarr")))
|
||||
assertNull(airingNoticeFor(BaseItem(id = "series", name = "Northbound", type = "Series")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a card with nothing to say produces no notice`() {
|
||||
val silent = BaseItem(
|
||||
id = "sonarr:7:42",
|
||||
name = "Northbound",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
)
|
||||
|
||||
assertNull(airingNoticeFor(silent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stub opens the show, not the episode that was pressed`() {
|
||||
val stub = requireNotNull(scheduleSeriesStub(card()))
|
||||
|
||||
assertEquals("emby-42", stub.id)
|
||||
assertEquals("Northbound", stub.name)
|
||||
assertTrue(stub.isSeries)
|
||||
assertEquals(listOf("Drama"), stub.genres)
|
||||
// The card's overview is the *episode's*; the page it opens is about the show.
|
||||
assertNull(stub.overview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a show Emby has never imported opens nothing`() {
|
||||
assertNull(scheduleSeriesStub(card(seriesItemId = null)))
|
||||
assertNull(scheduleSeriesStub(card(seriesItemId = " ")))
|
||||
assertNull(scheduleSeriesStub(BaseItem(id = "movie", name = "Film", type = "Movie")))
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,49 @@ package com.ponzischeme89.memby.ui
|
||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringTodayTagsTest {
|
||||
@Test
|
||||
fun `continue watching promotes episodes from shows airing today`() {
|
||||
val ended = BaseItem(
|
||||
id = "ended", name = "Old episode", type = "Episode", seriesName = "Ended Rewatch",
|
||||
)
|
||||
val pausedMovie = BaseItem(id = "movie", name = "Paused Movie", type = "Movie")
|
||||
val airing = BaseItem(
|
||||
id = "airing", name = "New episode", type = "Episode", seriesName = "The Bear",
|
||||
)
|
||||
val home = HomeSnapshot(
|
||||
continueWatching = listOf(ended, pausedMovie, airing),
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "continue", title = "Continue Watching",
|
||||
items = listOf(ended, pausedMovie, airing),
|
||||
),
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today", title = "Shows airing today", kind = "schedule",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:7:42", name = "The Bear", type = "MembySonarrEpisode",
|
||||
membySource = "sonarr", membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val ranked = home.withAiringTodayTags()
|
||||
|
||||
assertEquals(listOf("airing", "ended", "movie"), ranked.continueWatching.map { it.id })
|
||||
assertEquals(listOf("airing", "ended", "movie"), ranked.rows.first().items.map { it.id })
|
||||
assertTrue(ranked.continueWatching.first().membyAiringToday)
|
||||
assertFalse(ranked.continueWatching[1].membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags matching recommended series from today's TV schedule`() {
|
||||
val home = HomeSnapshot(
|
||||
@@ -174,8 +211,55 @@ class AiringTodayTagsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upcoming schedule status does not claim the show airs today`() {
|
||||
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
|
||||
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
|
||||
fun `upcoming schedule status leaves the air day unobstructed`() {
|
||||
assertEquals(null, scheduleStatusBadgeLabel("upcoming"))
|
||||
assertEquals(null, scheduleStatusBadgeLabel(""))
|
||||
assertEquals("DOWNLOADING", scheduleStatusBadgeLabel("downloading"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a schedule card wears the lifecycle the gateway worded`() {
|
||||
fun card(lifecycle: String?, text: String?) = BaseItem(
|
||||
id = "sonarr:1:2", name = "Show", type = "MembySonarrEpisode",
|
||||
membySource = "sonarr", membyLifecycle = lifecycle, membyLifecycleText = text,
|
||||
)
|
||||
|
||||
assertEquals("CONTINUING", lifecycleBadgeLabel(card("continuing", "CONTINUING")))
|
||||
assertEquals("IN CINEMAS", lifecycleBadgeLabel(card("incinemas", "IN CINEMAS")))
|
||||
// A status this build predates still reads correctly, because the wording is the
|
||||
// server's rather than derived from the slug.
|
||||
assertEquals("PILOT ORDERED", lifecycleBadgeLabel(card("pilot", "Pilot ordered")))
|
||||
// An older gateway, or a show *arr has no status for: no tag rather than a blank one.
|
||||
assertEquals(null, lifecycleBadgeLabel(card(null, null)))
|
||||
assertEquals(null, lifecycleBadgeLabel(card("continuing", " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled show outranks everything else on its My Shows card`() {
|
||||
val cancelled = MyShow(
|
||||
itemId = "1", title = "Ended Show", lifecycle = "Cancelled",
|
||||
sonarrStatus = "Monitored", nextEpisode = "2026-08-09T20:00:00Z",
|
||||
)
|
||||
|
||||
// Red, and the same word Sonarr uses, is worth more than a next-episode date on a
|
||||
// show that will not have one.
|
||||
assertEquals("ended" to "CANCELLED", myShowBadge(cancelled))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a continuing show says so`() {
|
||||
val continuing = MyShow(
|
||||
itemId = "1", title = "Severance", lifecycle = "Continuing", sonarrStatus = "Monitored",
|
||||
)
|
||||
assertEquals("continuing" to "CONTINUING", myShowBadge(continuing))
|
||||
|
||||
val dated = continuing.copy(nextEpisode = "2026-08-09T20:00:00Z")
|
||||
assertEquals("upcoming" to "UPCOMING", myShowBadge(dated))
|
||||
|
||||
val unmonitored = continuing.copy(sonarrStatus = "Not monitored")
|
||||
assertEquals("unmonitored" to "UNMONITORED", myShowBadge(unmonitored))
|
||||
|
||||
val unknown = continuing.copy(lifecycle = "Unknown", sonarrStatus = "Not found")
|
||||
assertEquals(null, myShowBadge(unknown))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import org.junit.Before
|
||||
@@ -33,7 +34,7 @@ import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders the movie and series detail pages to PNGs under `build/screenshots/`.
|
||||
* Renders the movie and series detail pages to PNGs under `build/screenshots/detail-page/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
|
||||
@@ -108,6 +109,32 @@ class DetailPageScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opened from the "Shows airing in the next 5 days" row. The band takes the accent line
|
||||
* the recommendation reason holds on every other route into this page — compare against
|
||||
* `df_detail-series`, which is the same show reached any other way.
|
||||
*/
|
||||
@Test
|
||||
fun `series page opened from the airing row`() {
|
||||
capture("df_detail-series-airing") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
airingNotice = AiringNotice(
|
||||
label = "AIRING TOMORROW",
|
||||
headline = "S02E04 • The Crossing",
|
||||
detail = "Tomorrow: 9:00 PM • Awaiting download",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The first frame, before the episode request lands. The header must already be whole. */
|
||||
@Test
|
||||
fun `series page loading`() {
|
||||
@@ -215,14 +242,14 @@ class DetailPageScreenshotTest {
|
||||
) { pane() }
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
|
||||
}
|
||||
|
||||
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
|
||||
@@ -230,7 +257,7 @@ class DetailPageScreenshotTest {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onNodeWithText(tab).performClick()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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
|
||||
import com.ponzischeme89.memby.data.EmbyOutage
|
||||
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 Emby outage bar to PNGs under `build/screenshots/emby-outage-banner/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*EmbyOutageBannerScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* Drawn over the same playback still the alert bar uses, because that is the situation the
|
||||
* red bar is really for: the film has stalled and this is the only thing on screen that
|
||||
* explains why. Seeing the two against the same frame is also how their two reds and their
|
||||
* shared rule-and-fade stay a deliberate pair rather than a coincidence.
|
||||
*
|
||||
* [OutageBanner] is rendered rather than [EmbyOutageBanner]: the wrapper's job is the
|
||||
* slide-in, and the clock is passed in so the countdown is a chosen value instead of
|
||||
* whatever the test machine's uptime happened to be.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class EmbyOutageBannerScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
/** The state the bar spends nearly all of an outage in. */
|
||||
@Test
|
||||
fun `waiting for the next attempt`() {
|
||||
capture("outage-banner-waiting", nextAttemptInMillis = 42_000L)
|
||||
}
|
||||
|
||||
/** Freshly armed: the longest number the countdown ever shows. */
|
||||
@Test
|
||||
fun `full interval remaining`() {
|
||||
capture("outage-banner-full-interval", nextAttemptInMillis = 60_000L)
|
||||
}
|
||||
|
||||
/** The moment the attempt is due, which is a different string entirely. */
|
||||
@Test
|
||||
fun `retrying now`() {
|
||||
capture("outage-banner-retrying", nextAttemptInMillis = 0L)
|
||||
}
|
||||
|
||||
/** Single digits, where the label is at its narrowest and the layout can shift. */
|
||||
@Test
|
||||
fun `last seconds`() {
|
||||
capture("outage-banner-last-seconds", nextAttemptInMillis = 1_000L)
|
||||
}
|
||||
|
||||
private fun capture(name: String, nextAttemptInMillis: Long) {
|
||||
compose.setContent {
|
||||
OutageBannerOnPlaybackStill(
|
||||
EmbyOutage(
|
||||
nextAttemptAtMillis = nextAttemptInMillis,
|
||||
retryIntervalSeconds = 60,
|
||||
),
|
||||
)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/emby-outage-banner/$name.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutageBannerOnPlaybackStill(outage: EmbyOutage) {
|
||||
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(),
|
||||
)
|
||||
// A fixed clock, so each capture shows the countdown value it is named for.
|
||||
OutageBanner(outage, nowMillis = { 0L })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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.foundation.lazy.LazyListState
|
||||
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.onNodeWithTag
|
||||
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.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 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 an episode's own detail page to PNGs under `build/screenshots/episode-detail/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*EpisodeDetailScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* The viewer here is part-way through season 3 of a four-season show, which is the state
|
||||
* the season scroller exists for: seasons 1 and 2 dimmed and ticked, 3 selected and
|
||||
* flagged, 4 still ahead. As in [DetailPageScreenshotTest] there is no network, so every
|
||||
* artwork URL resolves to null and the page renders on its own scrims — the worst case and
|
||||
* the one worth looking at.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class EpisodeDetailScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
/** The opening frame: series logo, season and episode number, episode title. */
|
||||
@Test
|
||||
fun `episode page`() {
|
||||
capture("df_episode-detail") { EpisodePage() }
|
||||
}
|
||||
|
||||
/** Part-watched, which is how an episode reaches this page from Continue Watching. */
|
||||
@Test
|
||||
fun `episode page resuming`() {
|
||||
capture("df_episode-detail-resuming") {
|
||||
EpisodePage(item = current.copy(userData = UserItemData(playbackPositionTicks = 21L * TICKS_PER_MINUTE)))
|
||||
}
|
||||
}
|
||||
|
||||
/** The first frame, before the series' episode list lands. The hero must be whole. */
|
||||
@Test
|
||||
fun `episode page loading`() {
|
||||
capture("df_episode-detail-loading") { EpisodePage(episodes = null) }
|
||||
}
|
||||
|
||||
/** Season 1: behind the viewer, so its stop is ticked and its episodes all watched. */
|
||||
@Test
|
||||
fun `episode page browsing an earlier season`() {
|
||||
captureSeason("df_episode-detail-earlier-season", season = 1) { EpisodePage() }
|
||||
}
|
||||
|
||||
/** Season 4: still ahead, and nothing in it is dimmed. */
|
||||
@Test
|
||||
fun `episode page browsing a later season`() {
|
||||
captureSeason("df_episode-detail-later-season", season = 4) { EpisodePage() }
|
||||
}
|
||||
|
||||
/** Specials, which sort first and are the one stop that is not "Season n". */
|
||||
@Test
|
||||
fun `episode page browsing specials`() {
|
||||
captureSeason("df_episode-detail-specials", season = 0) {
|
||||
EpisodePage(episodes = library + episode(0, 1, "The Making of Signal Hill"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane at the geometry the scaffold gives it — the band the D-pad reaches by
|
||||
* pressing Down twice, and the only place the episode this page is about is flagged.
|
||||
*/
|
||||
@Test
|
||||
fun `episode list pane at its real slot geometry`() {
|
||||
capturePane("df_episode-detail-pane") {
|
||||
EpisodeSeasonPane(
|
||||
episodes = library,
|
||||
seasonEpisodes = library.filter { it.parentIndexNumber == 3 },
|
||||
currentEpisodeId = current.id,
|
||||
loadFailed = false,
|
||||
firstEpisodeFocusRequester = FocusRequester(),
|
||||
emptyFocusRequester = FocusRequester(),
|
||||
aboveEpisodes = FocusRequester.Default,
|
||||
// Where the page opens the list: on the episode it is about, not at the
|
||||
// top of the season.
|
||||
listState = LazyListState(firstVisibleItemIndex = 3),
|
||||
onPlay = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A season the household is done with: every row ticked, none of them flagged. */
|
||||
@Test
|
||||
fun `finished season pane`() {
|
||||
capturePane("df_episode-detail-pane-watched") {
|
||||
EpisodeSeasonPane(
|
||||
episodes = library,
|
||||
seasonEpisodes = library.filter { it.parentIndexNumber == 1 },
|
||||
currentEpisodeId = current.id,
|
||||
loadFailed = false,
|
||||
firstEpisodeFocusRequester = FocusRequester(),
|
||||
emptyFocusRequester = FocusRequester(),
|
||||
aboveEpisodes = FocusRequester.Default,
|
||||
listState = LazyListState(),
|
||||
onPlay = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodePage(
|
||||
item: BaseItem = current,
|
||||
episodes: List<BaseItem>? = library,
|
||||
) {
|
||||
EpisodeDetailContent(
|
||||
item = item,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
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("$DIR/$name.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { content() } }
|
||||
compose.onRoot().captureRoboImage("$DIR/$name.png")
|
||||
}
|
||||
|
||||
private fun captureSeason(name: String, season: Int, content: @Composable () -> Unit) {
|
||||
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { content() } }
|
||||
compose.onNodeWithTag("season-stop-$season").performClick()
|
||||
compose.onRoot().captureRoboImage("$DIR/$name.png")
|
||||
}
|
||||
|
||||
private val cast = listOf(
|
||||
person("Aria Vance", "Detective Iris Kell"),
|
||||
person("Marcus Oyelaran", "Samuel Reed"),
|
||||
person("Nina Kowalczyk", "Dr. Halvorsen"),
|
||||
)
|
||||
|
||||
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"),
|
||||
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
|
||||
)
|
||||
|
||||
private val current = episode(
|
||||
season = 3,
|
||||
number = 4,
|
||||
title = "The Long Count",
|
||||
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. Iris takes the " +
|
||||
"tape to the only person who was on the hill that winter.",
|
||||
).copy(
|
||||
productionYear = 2024,
|
||||
officialRating = "TV-MA",
|
||||
communityRating = 8.7,
|
||||
genres = listOf("Thriller", "Drama"),
|
||||
studios = listOf(Studio(name = "Harbour Line")),
|
||||
mediaStreams = streams,
|
||||
people = cast,
|
||||
)
|
||||
|
||||
private val seasonOneTitles = listOf(
|
||||
"Carrier Wave", "Dead Air", "Nightingale", "Six Weeks Out", "The Hill", "Landfall",
|
||||
)
|
||||
|
||||
private val seasonThreeTitles = listOf(
|
||||
"Shortwave", "Ground Truth", "The Operator", "The Long Count",
|
||||
"Blackout", "Tape Nine", "What the Log Says", "Signal Hill",
|
||||
)
|
||||
|
||||
private val library = buildList {
|
||||
(1..6).forEach { add(episode(1, it, seasonOneTitles[it - 1], played = true)) }
|
||||
(1..8).forEach { add(episode(2, it, "Season Two, Part $it", played = it <= 6)) }
|
||||
(1..8).forEach { add(episode(3, it, seasonThreeTitles[it - 1], played = it < 4)) }
|
||||
(1..6).forEach { add(episode(4, it, "Season Four, Part $it")) }
|
||||
}
|
||||
|
||||
private fun episode(
|
||||
season: Int,
|
||||
number: Int,
|
||||
title: String,
|
||||
played: Boolean = false,
|
||||
overview: String = "A coastal radio station keeps receiving a broadcast that has " +
|
||||
"not been transmitted yet, and the night operator has started writing it down.",
|
||||
) = BaseItem(
|
||||
id = "s${season}e$number",
|
||||
name = title,
|
||||
type = "Episode",
|
||||
seriesId = "series-1",
|
||||
seriesName = "Signal Hill",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
runTimeTicks = 48L * TICKS_PER_MINUTE,
|
||||
overview = overview,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
|
||||
private fun person(name: String, role: String) = EmbyPerson(
|
||||
id = name.filter(Char::isLetter),
|
||||
name = name,
|
||||
role = role,
|
||||
type = "Actor",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val DIR = "build/screenshots/episode-detail"
|
||||
const val TICKS_PER_MINUTE = 600_000_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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.SeasonProgress
|
||||
import com.ponzischeme89.memby.ui.detail.episodeAfter
|
||||
import com.ponzischeme89.memby.ui.detail.episodeEyebrow
|
||||
import com.ponzischeme89.memby.ui.detail.seasonMarkers
|
||||
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
|
||||
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* What an episode's own detail page says, in plain JUnit.
|
||||
*
|
||||
* The season scroller's whole job is to be right about where the household is up to, so the
|
||||
* marking rules are pinned here rather than left to a screenshot to notice.
|
||||
*/
|
||||
class EpisodeDetailTest {
|
||||
|
||||
@Test
|
||||
fun `eyebrow spells the season and episode out`() {
|
||||
assertEquals("SEASON 3 · EPISODE 4", episodeEyebrow(episode(3, 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eyebrow names specials rather than season zero`() {
|
||||
assertEquals("SPECIALS · EPISODE 1", episodeEyebrow(episode(0, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unnumbered episode has no eyebrow to show`() {
|
||||
assertNull(episodeEyebrow(BaseItem(id = "x", name = "Pilot", type = "Episode")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seasons behind the one being watched are marked watched`() {
|
||||
val markers = seasonMarkers(library, currentSeason = 3)
|
||||
assertEquals(
|
||||
listOf(SeasonProgress.WATCHED, SeasonProgress.WATCHED, SeasonProgress.CURRENT, SeasonProgress.UPCOMING),
|
||||
markers.map { it.progress },
|
||||
)
|
||||
}
|
||||
|
||||
/** Skipping an episode of season one does not put the viewer back in season one. */
|
||||
@Test
|
||||
fun `an earlier season with unwatched episodes is still behind the viewer`() {
|
||||
val markers = seasonMarkers(library, currentSeason = 3)
|
||||
val secondSeason = markers.single { it.season == 2 }
|
||||
assertEquals(SeasonProgress.WATCHED, secondSeason.progress)
|
||||
assertEquals(1, secondSeason.watchedEpisodes)
|
||||
assertEquals(2, secondSeason.totalEpisodes)
|
||||
}
|
||||
|
||||
/** A season ahead that has been sampled must not read as finished. */
|
||||
@Test
|
||||
fun `later seasons stay upcoming`() {
|
||||
val markers = seasonMarkers(library, currentSeason = 1)
|
||||
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 4 }.progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fully watched season is marked watched without a current season`() {
|
||||
val markers = seasonMarkers(library, currentSeason = null)
|
||||
assertEquals(SeasonProgress.WATCHED, markers.single { it.season == 1 }.progress)
|
||||
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 2 }.progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season progress reads as a count`() {
|
||||
val markers = seasonMarkers(library, currentSeason = 3)
|
||||
assertEquals("All 2 watched", seasonProgressLabel(markers.single { it.season == 1 }))
|
||||
assertEquals("1 of 2 watched", seasonProgressLabel(markers.single { it.season == 2 }))
|
||||
assertEquals("2 episodes", seasonProgressLabel(markers.single { it.season == 4 }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the series line places the season and counts what is left`() {
|
||||
val markers = seasonMarkers(library, currentSeason = 3)
|
||||
assertEquals("Season 3 of 4 • 4 episodes left", seriesProgressLabel(markers, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished show reports nothing left`() {
|
||||
val watched = library.map { it.copy(userData = UserItemData(played = true)) }
|
||||
val markers = seasonMarkers(watched, currentSeason = 4)
|
||||
assertEquals("Season 4 of 4", seriesProgressLabel(markers, 4))
|
||||
}
|
||||
|
||||
/** A special nobody has watched must not be ticked just for sorting first. */
|
||||
@Test
|
||||
fun `specials are not watched merely by being before the current season`() {
|
||||
val withSpecials = library + episode(0, 1)
|
||||
val markers = seasonMarkers(withSpecials, currentSeason = 3)
|
||||
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 0 }.progress)
|
||||
assertEquals("1 episode", seasonProgressLabel(markers.single { it.season == 0 }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a watched special is still marked watched`() {
|
||||
val markers = seasonMarkers(library + episode(0, 1, played = true), currentSeason = 3)
|
||||
assertEquals(SeasonProgress.WATCHED, markers.single { it.season == 0 }.progress)
|
||||
assertEquals("Watched", seasonProgressLabel(markers.single { it.season == 0 }))
|
||||
}
|
||||
|
||||
/** Specials are not a season, and counting them displaces every numbered one. */
|
||||
@Test
|
||||
fun `the series line counts numbered seasons only`() {
|
||||
val markers = seasonMarkers(library + episode(0, 1), currentSeason = 3)
|
||||
assertEquals("Season 3 of 4 • 5 episodes left", seriesProgressLabel(markers, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no seasons means no progress line`() {
|
||||
assertNull(seriesProgressLabel(emptyList(), 1))
|
||||
}
|
||||
|
||||
/** Position decides what is next, so a season boundary is not a stopping point. */
|
||||
@Test
|
||||
fun `the next episode crosses into the following season`() {
|
||||
val last = library.single { it.parentIndexNumber == 1 && it.indexNumber == 2 }
|
||||
assertEquals("s2e1", episodeAfter(library, last)?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last episode of the show has nothing after it`() {
|
||||
val last = library.single { it.parentIndexNumber == 4 && it.indexNumber == 2 }
|
||||
assertNull(episodeAfter(library, last))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode the library does not hold resolves to nothing`() {
|
||||
assertNull(episodeAfter(library, episode(9, 9)))
|
||||
}
|
||||
|
||||
private val library = listOf(
|
||||
episode(1, 1, played = true),
|
||||
episode(1, 2, played = true),
|
||||
episode(2, 1, played = true),
|
||||
episode(2, 2),
|
||||
episode(3, 1, played = true),
|
||||
episode(3, 2),
|
||||
episode(4, 1),
|
||||
episode(4, 2),
|
||||
)
|
||||
|
||||
private fun episode(season: Int, number: Int, played: Boolean = false) = BaseItem(
|
||||
id = "s${season}e$number",
|
||||
name = "Episode $number",
|
||||
type = "Episode",
|
||||
seriesId = "series-1",
|
||||
seriesName = "Signal Hill",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class HomeMovieHeroScreenshotTest {
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Play").fetchSemanticsNode()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
|
||||
}
|
||||
|
||||
private val movies = listOf(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ponzischeme89.memby.data.localEpochDay
|
||||
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -8,11 +10,10 @@ 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))
|
||||
fun `home hero follows the vertical scroll position`() {
|
||||
assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true))
|
||||
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, listAtTop = false))
|
||||
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, listAtTop = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,6 +66,120 @@ class HomeMovieHeroTest {
|
||||
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
|
||||
}
|
||||
|
||||
// --- Daily variants ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `each day leads with a different new release`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
|
||||
row("popular", "Popular Movies", "popular-1", "popular-2"),
|
||||
)
|
||||
|
||||
assertEquals("new-1", selectHomeHeroMovies(rows, day = 0).first().item.id)
|
||||
assertEquals("new-2", selectHomeHeroMovies(rows, day = 1).first().item.id)
|
||||
assertEquals("new-3", selectHomeHeroMovies(rows, day = 2).first().item.id)
|
||||
}
|
||||
|
||||
/** The pool is finite, so it has to come round rather than run out. */
|
||||
@Test
|
||||
fun `the rotation wraps once the pool is exhausted`() {
|
||||
val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))
|
||||
|
||||
assertEquals(
|
||||
selectHomeHeroMovies(rows, day = 0).map { it.item.id },
|
||||
selectHomeHeroMovies(rows, day = 3).map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher rebuilds constantly — every home refresh, every focus change. Asking
|
||||
* twice within a day has to give the same four cards or the hero would churn under a
|
||||
* viewer who was only walking past.
|
||||
*/
|
||||
@Test
|
||||
fun `the same day always picks the same cards`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
|
||||
row("popular", "Popular Movies", "popular-1", "popular-2"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
|
||||
selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/** A clock that has not been set yet reports an instant before the epoch. */
|
||||
@Test
|
||||
fun `a negative day still fills the hero`() {
|
||||
val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))
|
||||
|
||||
assertEquals(3, selectHomeHeroMovies(rows, day = -1).size)
|
||||
assertEquals("new-3", selectHomeHeroMovies(rows, day = -1).first().item.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotation never drops or duplicates a title`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
|
||||
row("popular", "Popular Movies", "popular-1", "popular-2"),
|
||||
)
|
||||
|
||||
(0L..7L).forEach { day ->
|
||||
val picked = selectHomeHeroMovies(rows, day).map { it.item.id }
|
||||
assertEquals("day $day", picked.size, picked.distinct().size)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Midnight ----------------------------------------------------------------
|
||||
|
||||
/** Local, not UTC: the day must turn over at the viewer's midnight. */
|
||||
@Test
|
||||
fun `the day is counted in the viewer's own time zone`() {
|
||||
val newYearUtc = 1_735_689_600_000L // 2025-01-01T00:00:00Z
|
||||
|
||||
// An hour ahead: already the 1st. An hour behind: still New Year's Eve.
|
||||
assertEquals(
|
||||
localEpochDay(newYearUtc, HOUR_MS.toInt()) - 1,
|
||||
localEpochDay(newYearUtc, -HOUR_MS.toInt()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight is a whole day away from itself, never zero`() {
|
||||
val midnightUtc = 1_735_689_600_000L
|
||||
|
||||
assertEquals(DAY_MS, millisUntilNextLocalDay(midnightUtc, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wait shortens as the evening goes on`() {
|
||||
val midnightUtc = 1_735_689_600_000L
|
||||
|
||||
assertEquals(DAY_MS - HOUR_MS, millisUntilNextLocalDay(midnightUtc + HOUR_MS, 0))
|
||||
assertEquals(HOUR_MS, millisUntilNextLocalDay(midnightUtc + 23 * HOUR_MS, 0))
|
||||
}
|
||||
|
||||
/** Waiting the reported time must always land on the following day, in any zone. */
|
||||
@Test
|
||||
fun `waiting the reported time advances the day exactly once`() {
|
||||
val offsets = listOf(0, HOUR_MS.toInt(), -5 * HOUR_MS.toInt(), 13 * HOUR_MS.toInt())
|
||||
val instants = listOf(0L, 1_735_689_600_000L, 1_735_689_600_000L + 37L, -86_400_001L)
|
||||
|
||||
offsets.forEach { offset ->
|
||||
instants.forEach { now ->
|
||||
val before = localEpochDay(now, offset)
|
||||
val after = localEpochDay(now + millisUntilNextLocalDay(now, offset), offset)
|
||||
assertEquals("offset=$offset now=$now", before + 1, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HOUR_MS = 60L * 60L * 1000L
|
||||
const val DAY_MS = 24L * HOUR_MS
|
||||
}
|
||||
|
||||
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
|
||||
id = id,
|
||||
title = title,
|
||||
|
||||
@@ -8,20 +8,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HomeUiStateTest {
|
||||
@Test
|
||||
fun combinedWatchingRowPreservesOrderAndRemovesDuplicates() {
|
||||
val resumable = BaseItem(id = "resume", name = "Resume")
|
||||
val duplicate = BaseItem(id = "same", name = "Resume copy")
|
||||
val next = BaseItem(id = "next", name = "Next")
|
||||
|
||||
val state = HomeUiState(
|
||||
continueWatching = listOf(resumable, duplicate),
|
||||
nextUp = listOf(duplicate.copy(name = "Next copy"), next),
|
||||
)
|
||||
|
||||
assertEquals(listOf("resume", "same", "next"), state.watchingAndNextUp.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachedContentIsShownWhileOnlyMissingRowsLoad() {
|
||||
val cache = HomeCache(
|
||||
@@ -32,8 +18,27 @@ class HomeUiStateTest {
|
||||
val state = HomeUiState.from(cache)
|
||||
|
||||
assertFalse(HomeSection.CONTINUE in state.loading)
|
||||
assertTrue(HomeSection.NEXT_UP in state.loading)
|
||||
assertFalse(HomeSection.FAVORITES in state.loading)
|
||||
assertTrue(HomeSection.LATEST in state.loading)
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache written by the build that still had two rows must not lose its Next Up
|
||||
* episodes on the first launch after the update — that cache is what the launcher
|
||||
* draws before any network response arrives.
|
||||
*/
|
||||
@Test
|
||||
fun cachedNextUpItemsFoldIntoContinueWatching() {
|
||||
val cache = HomeCache(
|
||||
continueWatching = listOf(BaseItem(id = "resume"), BaseItem(id = "same")),
|
||||
nextUp = listOf(BaseItem(id = "same", name = "Next copy"), BaseItem(id = "next")),
|
||||
)
|
||||
|
||||
val state = HomeUiState.from(cache)
|
||||
|
||||
assertEquals(
|
||||
listOf("resume", "same", "next"),
|
||||
state.continueWatching.map(BaseItem::id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ui.setup.SignInContent
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Every screen a new television shows before it reaches the launcher, to
|
||||
* `build/screenshots/onboarding/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*OnboardingScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* This sequence is the one nobody on the team sees twice — it happens once per TV, usually
|
||||
* while somebody else is setting it up in another room — and it is the sequence that decides
|
||||
* whether that TV can ever update itself. Being able to look at it without reinstalling the
|
||||
* app on hardware is the whole point.
|
||||
*
|
||||
* The composables here take their state as parameters, which is what allows this: none of
|
||||
* them reaches for `ServiceLocator`, so there is no repository, no gateway and no session
|
||||
* behind any of these frames.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class OnboardingScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `first run`() {
|
||||
capture("01-first-run") { FirstRunScreen(onGetStarted = {}) }
|
||||
}
|
||||
|
||||
/** The permission step as almost everyone meets it: a TV that has the settings screen. */
|
||||
@Test
|
||||
fun `install permission`() {
|
||||
capture("02-install-permission") {
|
||||
InstallPermissionContent(
|
||||
noScreenAvailable = false,
|
||||
onOpenSettings = {},
|
||||
onSkip = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same step on a television with no per-app permission screen. This is the state
|
||||
* that used to be a dead end — the button did nothing and said nothing — so it is worth
|
||||
* being able to see that the instructions now stand on their own.
|
||||
*/
|
||||
@Test
|
||||
fun `install permission with no settings screen`() {
|
||||
capture("03-install-permission-manual") {
|
||||
InstallPermissionContent(
|
||||
noScreenAvailable = true,
|
||||
onOpenSettings = {},
|
||||
onSkip = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign in`() {
|
||||
capture("04-sign-in") { SignIn(username = "", password = "") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign in with credentials typed`() {
|
||||
capture("05-sign-in-filled") { SignIn(username = "matt", password = "hunter2") }
|
||||
}
|
||||
|
||||
/** The failure everyone hits at least once: it must read as fixable, not as broken. */
|
||||
@Test
|
||||
fun `sign in rejected`() {
|
||||
capture("06-sign-in-error") {
|
||||
SignIn(
|
||||
username = "matt",
|
||||
password = "wrong",
|
||||
error = "Memby couldn't sign in. Check the username and password and try again.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign in while connecting`() {
|
||||
capture("07-sign-in-connecting") {
|
||||
SignIn(username = "matt", password = "hunter2", connecting = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** The same form reached from Settings, which is the only variant with a Back button. */
|
||||
@Test
|
||||
fun `adding another viewer`() {
|
||||
capture("08-add-viewer") { SignIn(username = "", password = "", onBack = {}) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignIn(
|
||||
username: String,
|
||||
password: String,
|
||||
connecting: Boolean = false,
|
||||
error: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
SignInContent(
|
||||
username = username,
|
||||
password = password,
|
||||
onUsernameChange = {},
|
||||
onPasswordChange = {},
|
||||
onSubmit = {},
|
||||
connecting = connecting,
|
||||
error = error,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent(content)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/onboarding/$name.png")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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.primaryActionLabel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PrimaryActionLabelTest {
|
||||
@Test
|
||||
fun `watched movie can be rewatched`() {
|
||||
val movie = BaseItem(
|
||||
id = "movie",
|
||||
name = "Movie",
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = true),
|
||||
)
|
||||
|
||||
assertEquals("Rewatch", primaryActionLabel(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `part watched movie still resumes`() {
|
||||
val movie = BaseItem(
|
||||
id = "movie",
|
||||
name = "Movie",
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = true, playbackPositionTicks = 1),
|
||||
)
|
||||
|
||||
assertEquals("Resume", primaryActionLabel(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unwatched movie still plays`() {
|
||||
val movie = BaseItem(id = "movie", name = "Movie", type = "Movie")
|
||||
|
||||
assertEquals("Play", primaryActionLabel(movie))
|
||||
}
|
||||
|
||||
/** The episode page is reached from Continue Watching; the button confirms which one. */
|
||||
@Test
|
||||
fun `an episode names itself on the button`() {
|
||||
assertEquals("Play S04E04", primaryActionLabel(episode(4, 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a part watched episode resumes by name`() {
|
||||
val resuming = episode(4, 4).copy(userData = UserItemData(playbackPositionTicks = 1))
|
||||
|
||||
assertEquals("Resume S04E04", primaryActionLabel(resuming))
|
||||
}
|
||||
|
||||
/** A series' button names its next episode in the same form the episode page uses. */
|
||||
@Test
|
||||
fun `a series names its next episode`() {
|
||||
val series = BaseItem(id = "series", name = "Series", type = "Series")
|
||||
|
||||
assertEquals("Play S01E02", primaryActionLabel(series, episode(1, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unnumbered episode falls back to a bare verb`() {
|
||||
val unnumbered = BaseItem(id = "e", name = "Pilot", type = "Episode")
|
||||
|
||||
assertEquals("Play", primaryActionLabel(unnumbered))
|
||||
}
|
||||
|
||||
private fun episode(season: Int, number: Int) = BaseItem(
|
||||
id = "s${season}e$number",
|
||||
name = "Episode $number",
|
||||
type = "Episode",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.model.MediaRating
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
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 ratings strip to PNGs under `build/screenshots/ratings-strip/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*RatingsStripScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* The marks are the reason this exists: three of them are supplied artwork and one is
|
||||
* drawn, they have different aspect ratios, and whether they sit level with the score at
|
||||
* both sizes is a thing to look at rather than assert.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class RatingsStripScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
/** The detail page's case: every provider MDBList returns, at full size. */
|
||||
@Test
|
||||
fun `detail page strip`() {
|
||||
capture("ratings-strip-detail", ALL, width = 640)
|
||||
}
|
||||
|
||||
/** The home hero and card case, where the strip is compact. */
|
||||
@Test
|
||||
fun `compact strip`() {
|
||||
capture("ratings-strip-compact", ALL, width = 480, compact = true)
|
||||
}
|
||||
|
||||
/** A poster card: the width limit cuts it to the first three. */
|
||||
@Test
|
||||
fun `narrow card strip`() {
|
||||
capture("ratings-strip-narrow", ALL, width = 220, compact = true)
|
||||
}
|
||||
|
||||
/** The mark for a provider no build has artwork for still has to read. */
|
||||
@Test
|
||||
fun `wordmark fallback`() {
|
||||
capture(
|
||||
"ratings-strip-wordmarks",
|
||||
listOf(
|
||||
MediaRating(source = "trakt", name = "Trakt", score = "83"),
|
||||
MediaRating(source = "mal", name = "MyAnimeList", score = "8.1"),
|
||||
MediaRating(source = "audience", name = "RT Audience", score = "91"),
|
||||
),
|
||||
width = 640,
|
||||
)
|
||||
}
|
||||
|
||||
/** One provider only, which is what an episode usually has. */
|
||||
@Test
|
||||
fun `single provider`() {
|
||||
capture(
|
||||
"ratings-strip-single",
|
||||
listOf(MediaRating(source = "imdb", name = "IMDb", score = "8.7")),
|
||||
width = 640,
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
ratings: List<MediaRating>,
|
||||
width: Int,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
compose.setContent { OnLauncherBackground(ratings, width, compact) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/ratings-strip/$name.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnLauncherBackground(
|
||||
ratings: List<MediaRating>,
|
||||
width: Int,
|
||||
compact: Boolean,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(40.dp).width(width.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
RatingsStrip(ratings = ratings, visible = true, compact = compact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ALL = listOf(
|
||||
MediaRating(source = "imdb", name = "IMDb", score = "8.2"),
|
||||
MediaRating(source = "tomatoes", name = "Rotten Tomatoes", score = "94"),
|
||||
MediaRating(source = "metacritic", name = "Metacritic", score = "81"),
|
||||
MediaRating(source = "letterboxd", name = "Letterboxd", score = "4.1"),
|
||||
MediaRating(source = "tmdb", name = "TMDb", score = "7.4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
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 RecommendationOnboardingScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie taste selection`() = capture(
|
||||
stageIndex = 0,
|
||||
heading = "Choose movies you love",
|
||||
fileName = "recommendation-onboarding-movies",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `actor taste selection`() = capture(
|
||||
stageIndex = 2,
|
||||
heading = "Pick actors you enjoy watching",
|
||||
fileName = "recommendation-onboarding-actors",
|
||||
)
|
||||
|
||||
private fun capture(stageIndex: Int, heading: String, fileName: String) {
|
||||
val previewFocus = FocusRequester()
|
||||
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
|
||||
.use(BitmapFactory::decodeStream)
|
||||
.asImageBitmap()
|
||||
compose.setContent {
|
||||
RecommendationOnboardingPreview(
|
||||
initialStageIndex = stageIndex,
|
||||
previewArtwork = artwork,
|
||||
previewFocusRequester = previewFocus,
|
||||
)
|
||||
}
|
||||
compose.runOnIdle { previewFocus.requestFocus() }
|
||||
compose.onNodeWithText(heading).fetchSemanticsNode()
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/recommendation-onboarding/$fileName.png",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.Studio
|
||||
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
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* The finish-date estimate as it actually sits on a series hero, to
|
||||
* `build/screenshots/series-pace/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*SeriesPaceScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* The line is deliberately quiet — it is a nicety on a page whose job is Play — and quiet
|
||||
* is the one property a unit test cannot check. What is worth looking at here is whether it
|
||||
* reads as part of the progress information or as a stray sentence under the synopsis, and
|
||||
* whether the two spacing cases (with a progress bar above it and without) both hold.
|
||||
*
|
||||
* The watch history is built relative to the clock rather than pinned, because the estimate
|
||||
* is a projection from *now* and a fixture dated 2026 would fall out of the recency window
|
||||
* and capture the empty case by accident. That makes the date in the image move with the
|
||||
* day it was rendered on; these are artifacts to look at, not checked-in goldens.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SeriesPaceScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
/**
|
||||
* An episode a night for the past four nights, five left, and nothing part-watched —
|
||||
* so there is no progress bar and the estimate is a line of its own. This is the state
|
||||
* the feature exists for: somebody who finished one last night and has not started the
|
||||
* next, whom the page previously told nothing at all.
|
||||
*/
|
||||
@Test
|
||||
fun `an episode a night`() {
|
||||
capture("sp_series-finish") {
|
||||
SeriesDetailContent(
|
||||
item = endedSeries,
|
||||
episodes = nightly(watched = 4, remaining = 5),
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The other spacing case: part-way through an episode, so the bar is above the line. */
|
||||
@Test
|
||||
fun `part way through tonight's episode`() {
|
||||
val episodes = nightly(watched = 4, remaining = 5).map { item ->
|
||||
if (item.indexNumber == 5) {
|
||||
item.copy(userData = UserItemData(playbackPositionTicks = 14L * 600_000_000L))
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
capture("sp_series-finish-resuming") {
|
||||
SeriesDetailContent(
|
||||
item = endedSeries,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A show still in production is caught up with, never finished. */
|
||||
@Test
|
||||
fun `a show still being made`() {
|
||||
capture("sp_series-catch-up") {
|
||||
SeriesDetailContent(
|
||||
item = endedSeries.copy(status = "Continuing"),
|
||||
episodes = nightly(watched = 4, remaining = 5),
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = true,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Three a night across two nights, six left: the near end of the wording. */
|
||||
@Test
|
||||
fun `a binge finishes tomorrow`() {
|
||||
val watched = listOf(
|
||||
stamp(daysAgo = 2, hour = 19), stamp(daysAgo = 2, hour = 20), stamp(daysAgo = 2, hour = 21),
|
||||
stamp(daysAgo = 1, hour = 19), stamp(daysAgo = 1, hour = 20), stamp(daysAgo = 1, hour = 21),
|
||||
)
|
||||
capture("sp_series-tomorrow") {
|
||||
SeriesDetailContent(
|
||||
item = endedSeries,
|
||||
episodes = library(watched, remaining = 6),
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The control, and the case that matters most: one episode watched is not a pace, so
|
||||
* the page says nothing. Compare against `sp_series-finish` — the hero must be
|
||||
* identical apart from the missing line, with nothing left holding its space.
|
||||
*/
|
||||
@Test
|
||||
fun `not enough history to say`() {
|
||||
capture("sp_series-none") {
|
||||
SeriesDetailContent(
|
||||
item = endedSeries,
|
||||
episodes = nightly(watched = 1, remaining = 8),
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/series-pace/$name.png")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** [watched] episodes on consecutive nights ending last night, then [remaining] unwatched. */
|
||||
private fun nightly(watched: Int, remaining: Int): List<BaseItem> =
|
||||
library((0 until watched).map { stamp(daysAgo = watched - it, hour = 20) }, remaining)
|
||||
|
||||
private fun library(watched: List<String>, remaining: Int): List<BaseItem> =
|
||||
watched.mapIndexed { index, at -> episode(index + 1, at) } +
|
||||
(0 until remaining).map { episode(watched.size + it + 1, null) }
|
||||
|
||||
private fun episode(number: Int, playedAt: String?) = BaseItem(
|
||||
id = "s1e$number",
|
||||
name = EPISODE_TITLES[(number - 1) % EPISODE_TITLES.size],
|
||||
type = "Episode",
|
||||
seriesName = "Signal Hill",
|
||||
parentIndexNumber = 1,
|
||||
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 = playedAt != null, lastPlayedDate = playedAt),
|
||||
)
|
||||
|
||||
/** Emby's UTC stamp for [hour] o'clock, [daysAgo] days back. */
|
||||
private fun stamp(daysAgo: Int, hour: Int): String {
|
||||
val at = System.currentTimeMillis() - daysAgo * 86_400_000L
|
||||
val midnight = at / 86_400_000L * 86_400_000L
|
||||
return iso.format(java.util.Date(midnight + hour * 3_600_000L))
|
||||
}
|
||||
|
||||
private val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private val endedSeries = BaseItem(
|
||||
id = "series-1",
|
||||
name = "Signal Hill",
|
||||
type = "Series",
|
||||
overview = "A coastal radio station keeps receiving a broadcast that has not been " +
|
||||
"transmitted yet. Six weeks before the storm, the night operator starts writing " +
|
||||
"down what she hears.",
|
||||
productionYear = 2022,
|
||||
officialRating = "TV-MA",
|
||||
genres = listOf("Thriller", "Drama"),
|
||||
studios = listOf(Studio(name = "Harbour Line")),
|
||||
status = "Ended",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val EPISODE_TITLES = listOf(
|
||||
"Carrier Wave", "Dead Air", "The Long Count", "Nightingale", "Six Weeks Out",
|
||||
"Landfall", "The Shipping Forecast", "Quiet Hours", "Storm Glass", "Last Transmission",
|
||||
"Harbour Line", "The Night Operator", "Signal Hill",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ class ServerHomeRowsTest {
|
||||
|
||||
private val serverRows = listOf(
|
||||
row("continue", "continue", "a"),
|
||||
row("next-up", "nextup", "b"),
|
||||
row("favorites", "favorites", "c"),
|
||||
row("latest-movies", "latest", "d"),
|
||||
row("similar:sev", "similar", "e"),
|
||||
@@ -62,12 +61,58 @@ class ServerHomeRowsTest {
|
||||
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
|
||||
|
||||
assertEquals(
|
||||
listOf("continue", "next-up", "favorites", "latest-movies", "similar:sev", "recommended"),
|
||||
listOf("continue", "favorites", "latest-movies", "similar:sev", "recommended"),
|
||||
rows.map { it.id },
|
||||
)
|
||||
assertEquals("Recommended", rows.last().title)
|
||||
}
|
||||
|
||||
/**
|
||||
* A gateway that predates the merge — or, far more commonly, the home cache written by
|
||||
* the previous build, which is what a TV draws before its first refresh lands — still
|
||||
* carries a `nextup` row. Its episodes belong on the end of Continue Watching rather
|
||||
* than in a row of their own.
|
||||
*/
|
||||
@Test
|
||||
fun `a legacy next up row folds into continue watching`() {
|
||||
val legacy = listOf(
|
||||
row("continue", "continue", "a"),
|
||||
row("next-up", "nextup", "b"),
|
||||
row("favorites", "favorites", "c"),
|
||||
)
|
||||
|
||||
val rows = serverHomeRows(HomeUiState(rows = legacy, loading = emptySet()), Settings())
|
||||
|
||||
assertEquals(listOf("continue", "favorites"), rows.map { it.id })
|
||||
assertEquals(listOf("a", "b"), rows.first().items.map { it.id })
|
||||
}
|
||||
|
||||
/** A show already part-way through must not also appear as the episode after it. */
|
||||
@Test
|
||||
fun `folding a legacy next up row drops shows already in progress`() {
|
||||
val legacy = listOf(
|
||||
HomeRow(
|
||||
id = "continue",
|
||||
title = "Continue Watching",
|
||||
kind = "continue",
|
||||
items = listOf(BaseItem(id = "s1e3", seriesId = "show")),
|
||||
),
|
||||
HomeRow(
|
||||
id = "next-up",
|
||||
title = "Next Up",
|
||||
kind = "nextup",
|
||||
items = listOf(
|
||||
BaseItem(id = "s1e4", seriesId = "show"),
|
||||
BaseItem(id = "other-e1", seriesId = "other"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val rows = serverHomeRows(HomeUiState(rows = legacy, loading = emptySet()), Settings())
|
||||
|
||||
assertEquals(listOf("s1e3", "other-e1"), rows.single().items.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `favorites row uses the friendly profile name`() {
|
||||
val peter = serverHomeRows(
|
||||
@@ -79,8 +124,8 @@ class ServerHomeRowsTest {
|
||||
Settings(username = "PaulR"),
|
||||
)
|
||||
|
||||
assertEquals("Peter's Favorites", peter.first { it.id == "favorites" }.title)
|
||||
assertEquals("Paul's Favorites", paul.first { it.id == "favorites" }.title)
|
||||
assertEquals("Peter's Favourites", peter.first { it.id == "favorites" }.title)
|
||||
assertEquals("Paul's Favourites", paul.first { it.id == "favorites" }.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,8 +135,8 @@ class ServerHomeRowsTest {
|
||||
assertEquals("MattCohen", friendlyProfileName("MattCohen"))
|
||||
assertEquals("CHRIS", friendlyProfileName("CHRIS"))
|
||||
assertEquals("PJ", friendlyProfileName("PJ"))
|
||||
assertEquals("Chris' Favorites", personalizedFavoritesTitle("Chris"))
|
||||
assertEquals("Favorites", personalizedFavoritesTitle(null))
|
||||
assertEquals("Chris' Favourites", personalisedFavouritesTitle("Chris"))
|
||||
assertEquals("Favourites", personalisedFavouritesTitle(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,7 +151,7 @@ class ServerHomeRowsTest {
|
||||
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), settings)
|
||||
|
||||
assertEquals(
|
||||
listOf("continue", "next-up", "similar:sev", "recommended"),
|
||||
listOf("continue", "similar:sev", "recommended"),
|
||||
rows.map { it.id },
|
||||
)
|
||||
}
|
||||
@@ -117,7 +162,6 @@ class ServerHomeRowsTest {
|
||||
.associateBy { it.id }
|
||||
|
||||
assertEquals(MediaRowKind.CONTINUE, rows.getValue("continue").kind)
|
||||
assertEquals(MediaRowKind.NEXT_UP, rows.getValue("next-up").kind)
|
||||
assertEquals(MediaRowKind.FAVORITES, rows.getValue("favorites").kind)
|
||||
assertEquals(MediaRowKind.MOVIES, rows.getValue("recommended").kind)
|
||||
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
|
||||
@@ -168,7 +212,6 @@ class ServerHomeRowsTest {
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"next-up",
|
||||
"continue-shows",
|
||||
"favourite-shows",
|
||||
"curated:comedy-shows",
|
||||
@@ -276,6 +319,31 @@ class ServerHomeRowsTest {
|
||||
assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TV schedule cards are ordered by their air date`() {
|
||||
val friday = BaseItem(
|
||||
id = "friday",
|
||||
membyAirsAt = "2026-08-07T20:00:00+12:00",
|
||||
)
|
||||
val wednesday = BaseItem(
|
||||
id = "wednesday",
|
||||
membyAirsAt = "2026-08-05T20:00:00+12:00",
|
||||
)
|
||||
val schedule = HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing in the next 5 days",
|
||||
kind = "schedule",
|
||||
items = listOf(friday, wednesday),
|
||||
)
|
||||
|
||||
val cards = serverHomeRows(
|
||||
HomeUiState(rows = listOf(schedule), loading = emptySet()),
|
||||
Settings(),
|
||||
).single().items
|
||||
|
||||
assertEquals(listOf("wednesday", "friday"), cards.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Radarr schedule rows use movie cards and explain an empty digital window`() {
|
||||
val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7")
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders the aired banner to PNGs under `build/screenshots/`, so its layout can be
|
||||
* Renders the aired banner to PNGs under `build/screenshots/service-alert-banner/`, so its layout can be
|
||||
* looked at without deploying to a TV.
|
||||
*
|
||||
* ```powershell
|
||||
@@ -165,12 +165,14 @@ class ServiceAlertBannerScreenshotTest {
|
||||
)
|
||||
}
|
||||
compose.mainClock.advanceTimeBy(6_500L)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/alert-banner-countdown.png")
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/service-alert-banner/alert-banner-countdown.png",
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(name: String, alert: ServiceAlert) {
|
||||
compose.setContent { AlertBannerOnHomeBackground(alert) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/service-alert-banner/$name.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -66,7 +66,9 @@ class WatchedVisibilityScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
compose.onRoot().captureRoboImage("build/screenshots/watched-visibility-row.png")
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/watched-visibility/watched-visibility-row.png",
|
||||
)
|
||||
}
|
||||
|
||||
private val row = HomeBrowseRow(
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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.LinearLayout
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertEquals
|
||||
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
|
||||
|
||||
/**
|
||||
* Renders the cast panel to PNGs under `build/screenshots/cast-panel/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*CastPanelScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* Captured over a playback still, because the panel has no card of its own — it is a fade
|
||||
* into the picture, and the only way to judge whether the names are legible over a bright
|
||||
* frame is to put them over one. Artwork cannot be fetched here, which is the point of the
|
||||
* injectable loader: every capture shows the initials fallback, the state a real cast row
|
||||
* is partly in anyway.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class CastPanelScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `a full cast with the first face focused`() {
|
||||
capture(
|
||||
name = "cast-panel-loaded",
|
||||
state = CastPanelState(
|
||||
title = "The Lives of Others",
|
||||
loaded = true,
|
||||
members = listOf(
|
||||
CastMember("Ulrich Mühe", "Hauptmann Gerd Wiesler"),
|
||||
CastMember("Martina Gedeck", "Christa-Maria Sieland"),
|
||||
CastMember("Sebastian Koch", "Georg Dreyman"),
|
||||
CastMember("Ulrich Tukur", "Oberstleutnant Anton Grubitz"),
|
||||
CastMember("Thomas Thieme", "Minister Bruno Hempf"),
|
||||
CastMember("Hans-Uwe Bauer", "Paul Hauser"),
|
||||
CastMember("Volkmar Kleinert", "Albert Jerska"),
|
||||
),
|
||||
),
|
||||
focused = 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Focus part-way along, which is what the row looks like while somebody scrolls it. */
|
||||
@Test
|
||||
fun `focus part way along the row`() {
|
||||
capture(
|
||||
name = "cast-panel-focus-mid-row",
|
||||
state = CastPanelState(
|
||||
title = "Arrival",
|
||||
loaded = true,
|
||||
members = listOf(
|
||||
CastMember("Amy Adams", "Louise Banks"),
|
||||
CastMember("Jeremy Renner", "Ian Donnelly"),
|
||||
CastMember("Forest Whitaker", "Colonel Weber"),
|
||||
CastMember("Michael Stuhlbarg", "Agent Halpern"),
|
||||
CastMember("Tzi Ma", "General Shang"),
|
||||
),
|
||||
),
|
||||
focused = 2,
|
||||
)
|
||||
}
|
||||
|
||||
/** A name with no character recorded — the card must not leave a gap where the role was. */
|
||||
@Test
|
||||
fun `people with no role`() {
|
||||
capture(
|
||||
name = "cast-panel-no-roles",
|
||||
state = CastPanelState(
|
||||
title = "Koyaanisqatsi",
|
||||
loaded = true,
|
||||
members = listOf(
|
||||
CastMember("Lou Dobbs"),
|
||||
CastMember("Ted Koppel"),
|
||||
CastMember("Philip Glass"),
|
||||
),
|
||||
),
|
||||
focused = 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Still fetching. The panel opens instantly and says so rather than showing nothing. */
|
||||
@Test
|
||||
fun `still loading`() {
|
||||
capture(name = "cast-panel-loading", state = CastPanelState(title = "Arrival"))
|
||||
}
|
||||
|
||||
/** A title Emby holds no cast for, which must read differently from "still loading". */
|
||||
@Test
|
||||
fun `no cast recorded`() {
|
||||
capture(
|
||||
name = "cast-panel-empty",
|
||||
state = CastPanelState(title = "Home video, 1998", loaded = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initials cover a mononym, a middle name and a blank`() {
|
||||
assertEquals("AA", castInitials("Amy Adams"))
|
||||
// First and surname, not the first two words: the surname is what identifies
|
||||
// somebody, so a middle name is skipped rather than taking the second slot.
|
||||
assertEquals("PH", castInitials("Philip Seymour Hoffman"))
|
||||
assertEquals("C", castInitials("Cher"))
|
||||
assertEquals("AA", castInitials(" amy adams "))
|
||||
assertEquals("", castInitials(" "))
|
||||
}
|
||||
|
||||
private fun capture(name: String, state: CastPanelState, focused: Int? = null) {
|
||||
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 overlay = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.player_cast_overlay, root, false)
|
||||
root.addView(overlay)
|
||||
overlay.visibility = View.VISIBLE
|
||||
bindCastPanel(overlay, state)
|
||||
activity.setContentView(root)
|
||||
|
||||
if (focused != null) {
|
||||
// Robolectric starts in touch mode, where a focusable card refuses focus and the
|
||||
// capture would show every face unfocused. This leaves touch mode the way a
|
||||
// D-pad press does.
|
||||
val card = requireNotNull(
|
||||
overlay.findViewById<LinearLayout>(R.id.player_cast_people).getChildAt(focused),
|
||||
)
|
||||
card.requestFocusFromTouch()
|
||||
check(card.isFocused) { "the card to capture under focus never took it" }
|
||||
}
|
||||
|
||||
root.captureRoboImage("build/screenshots/cast-panel/$name.png")
|
||||
}
|
||||
|
||||
private fun previewArtwork() =
|
||||
javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream)
|
||||
}
|
||||
+3
-1
@@ -87,6 +87,8 @@ class PlaybackIdentityScreenshotTest {
|
||||
activity.setContentView(root)
|
||||
|
||||
assertEquals(5_000L, PlayerActivity.PLAYBACK_IDENTITY_VISIBLE_MS)
|
||||
root.captureRoboImage("build/screenshots/player-playback-identity.png")
|
||||
root.captureRoboImage(
|
||||
"build/screenshots/playback-identity/player-playback-identity.png",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,24 @@ class PlaybackRecoveryTest {
|
||||
assertTrue(failure.requiresTranscode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOrRejectedStartupStreamsAutomaticallyResolveAFreshUrl() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||
)
|
||||
|
||||
assertTrue(failure.canAutoRetry)
|
||||
assertTrue(failure.requiresFreshStream)
|
||||
assertFalse(failure.requiresTranscode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericPlayerTimeoutsAreSafeToRetry() {
|
||||
val failure = describePlaybackFailure(PlaybackException.ERROR_CODE_TIMEOUT)
|
||||
|
||||
assertTrue(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun automaticRetriesAreBoundedAndBackOff() {
|
||||
assertEquals(1_000L, automaticRetryDelayMs(1))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the trace section names to the literals `:benchmark` looks for.
|
||||
*
|
||||
* `PlaybackBenchmark` cannot import [PlaybackTraceSections] — it is a `com.android.test`
|
||||
* module that drives the app from outside rather than linking against it — so the two
|
||||
* copies of these strings are kept in step by hand. That is exactly the arrangement that
|
||||
* rots silently: rename the constant, and `TraceSectionMetric` simply finds no slices and
|
||||
* reports zero, which reads as "playback got infinitely fast" rather than as a broken
|
||||
* benchmark. This test turns that into a failure at the moment of the rename.
|
||||
*
|
||||
* If you are here because this test failed: change `PlaybackBenchmark` too, then update
|
||||
* the literals below.
|
||||
*/
|
||||
class PlaybackTraceNamesTest {
|
||||
|
||||
@Test
|
||||
fun `launch section name matches the benchmark`() {
|
||||
assertEquals("Memby.playbackLaunch", PlaybackTraceSections.LAUNCH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first frame section name matches the benchmark`() {
|
||||
assertEquals("Memby.playbackFirstFrame", PlaybackTraceSections.FIRST_FRAME)
|
||||
}
|
||||
|
||||
/** Overlapping launches share a name, so the cookies must not collide. */
|
||||
@Test
|
||||
fun `cookies are unique`() {
|
||||
val cookies = List(100) { PlaybackTraceSections.nextCookie() }
|
||||
|
||||
assertEquals(cookies.size, cookies.distinct().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackTraceTest {
|
||||
|
||||
private class FakeClock(var nowMs: Long) : () -> Long {
|
||||
override fun invoke(): Long = nowMs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marks are cumulative from the moment play was pressed`() {
|
||||
val clock = FakeClock(1_000L)
|
||||
val trace = PlaybackTrace(startedAtMs = 1_000L, clock = clock)
|
||||
|
||||
clock.nowMs = 1_120L
|
||||
assertEquals(120L, trace.mark(PlaybackTrace.ACTIVITY_CREATED))
|
||||
clock.nowMs = 1_400L
|
||||
assertEquals(400L, trace.mark(PlaybackTrace.STREAM_RESOLVED))
|
||||
clock.nowMs = 2_050L
|
||||
assertEquals(1_050L, trace.mark(PlaybackTrace.FIRST_FRAME))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `summary reports each stage with the time it added`() {
|
||||
val clock = FakeClock(0L)
|
||||
val trace = PlaybackTrace(startedAtMs = 0L, clock = clock)
|
||||
|
||||
clock.nowMs = 100L
|
||||
trace.mark(PlaybackTrace.ACTIVITY_CREATED)
|
||||
clock.nowMs = 250L
|
||||
trace.mark(PlaybackTrace.PREPARED)
|
||||
clock.nowMs = 900L
|
||||
trace.mark(PlaybackTrace.FIRST_FRAME)
|
||||
|
||||
assertEquals(
|
||||
"activity=100ms(+100ms) prepared=250ms(+150ms) first_frame=900ms(+650ms)",
|
||||
trace.summary(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A retry re-enters buffering and re-reaches READY. The viewer's wait is measured from
|
||||
* the first time they got there, not the last.
|
||||
*/
|
||||
@Test
|
||||
fun `a repeated stage keeps the first time it was reached`() {
|
||||
val clock = FakeClock(0L)
|
||||
val trace = PlaybackTrace(startedAtMs = 0L, clock = clock)
|
||||
|
||||
clock.nowMs = 500L
|
||||
trace.mark(PlaybackTrace.READY)
|
||||
clock.nowMs = 4_000L
|
||||
assertEquals(500L, trace.mark(PlaybackTrace.READY))
|
||||
assertEquals("ready=500ms(+500ms)", trace.summary())
|
||||
}
|
||||
|
||||
/** Stages that never happened are absent, not reported as having taken no time. */
|
||||
@Test
|
||||
fun `summary omits stages that were never reached`() {
|
||||
val clock = FakeClock(0L)
|
||||
val trace = PlaybackTrace(startedAtMs = 0L, clock = clock)
|
||||
|
||||
clock.nowMs = 60L
|
||||
trace.mark(PlaybackTrace.ACTIVITY_CREATED)
|
||||
|
||||
assertEquals("activity=60ms(+60ms)", trace.summary())
|
||||
}
|
||||
|
||||
/**
|
||||
* elapsedRealtime is monotonic, but the start instant crosses a process boundary in an
|
||||
* intent — a negative wait is meaningless and must never be printed as one.
|
||||
*/
|
||||
@Test
|
||||
fun `a start instant in the future clamps to zero rather than going negative`() {
|
||||
val clock = FakeClock(500L)
|
||||
val trace = PlaybackTrace(startedAtMs = 900L, clock = clock)
|
||||
|
||||
assertEquals(0L, trace.mark(PlaybackTrace.ACTIVITY_CREATED))
|
||||
assertEquals(0L, trace.elapsedMs())
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -36,7 +36,7 @@ class PlayerPauseOverlayScreenshotTest {
|
||||
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")
|
||||
root.captureRoboImage("build/screenshots/player-pause-overlay/player-paused-movie-overlay.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,7 +54,7 @@ class PlayerPauseOverlayScreenshotTest {
|
||||
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")
|
||||
root.captureRoboImage("build/screenshots/player-pause-overlay/player-osd-no-black-container.png")
|
||||
}
|
||||
|
||||
private fun playerSurface(): Triple<Activity, FrameLayout, View> {
|
||||
|
||||
@@ -95,10 +95,12 @@ class PrerollScreenshotTest {
|
||||
)
|
||||
|
||||
activity.setContentView(root)
|
||||
root.captureRoboImage("build/screenshots/player-sonarr-preroll.png")
|
||||
root.captureRoboImage("build/screenshots/sonarr-preroll/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")
|
||||
root.captureRoboImage(
|
||||
"build/screenshots/sonarr-preroll/player-sonarr-preroll-mid-countdown.png",
|
||||
)
|
||||
}
|
||||
|
||||
private fun dp(activity: Activity, value: Int): Int =
|
||||
|
||||
@@ -33,4 +33,11 @@ class PrerollSequenceTest {
|
||||
assertFalse(prerollCanHandOff(true, true, false))
|
||||
assertFalse(prerollCanHandOff(false, true, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `countdown advances on a ready paused first frame`() {
|
||||
assertTrue(prerollCountdownAdvances(lifecycleStarted = true, playbackReady = true))
|
||||
assertFalse(prerollCountdownAdvances(lifecycleStarted = false, playbackReady = true))
|
||||
assertFalse(prerollCountdownAdvances(lifecycleStarted = true, playbackReady = false))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -48,6 +48,8 @@ class SeasonFinaleLowerThirdScreenshotTest {
|
||||
root.addView(finale)
|
||||
activity.setContentView(root)
|
||||
|
||||
root.captureRoboImage("build/screenshots/player-season-finale-lower-third.png")
|
||||
root.captureRoboImage(
|
||||
"build/screenshots/season-finale/player-season-finale-lower-third.png",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
||||
import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SeekControlsTest {
|
||||
|
||||
private val duration = 90L * 60_000L
|
||||
|
||||
@Test
|
||||
fun `a press moves one interval from the playhead`() {
|
||||
val preview = accumulateSeek(
|
||||
previous = null,
|
||||
positionMs = 60_000L,
|
||||
durationMs = duration,
|
||||
stepMs = 30_000L,
|
||||
forward = true,
|
||||
)
|
||||
assertEquals(90_000L, preview.targetMs)
|
||||
assertEquals(30_000L, preview.offsetMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason presses accumulate against the preview rather than the playhead: the film
|
||||
* is still running while somebody works down the remote, and measuring each step from
|
||||
* the live position would quietly swallow part of every press after the first.
|
||||
*/
|
||||
@Test
|
||||
fun `repeated presses accumulate against the preview, not the moving playhead`() {
|
||||
var preview = accumulateSeek(null, 60_000L, duration, 30_000L, forward = true)
|
||||
preview = accumulateSeek(preview, 61_200L, duration, 30_000L, forward = true)
|
||||
preview = accumulateSeek(preview, 62_400L, duration, 30_000L, forward = true)
|
||||
|
||||
assertEquals(150_000L, preview.targetMs)
|
||||
assertEquals(90_000L, preview.offsetMs)
|
||||
assertEquals("1 min 30 secs", seekAmountLabel(preview.offsetMs))
|
||||
}
|
||||
|
||||
/** Reversing mid-burst is how a viewer corrects an overshoot without leaving the OSD. */
|
||||
@Test
|
||||
fun `a reversal reduces the running total`() {
|
||||
var preview = accumulateSeek(null, 60_000L, duration, 10_000L, forward = true)
|
||||
preview = accumulateSeek(preview, 60_500L, duration, 10_000L, forward = true)
|
||||
preview = accumulateSeek(preview, 61_000L, duration, 10_000L, forward = false)
|
||||
|
||||
assertEquals(70_000L, preview.targetMs)
|
||||
assertEquals(10_000L, preview.offsetMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewinding past the start stops at the start`() {
|
||||
val preview = accumulateSeek(null, 8_000L, duration, 30_000L, forward = false)
|
||||
assertEquals(0L, preview.targetMs)
|
||||
assertEquals(-8_000L, preview.offsetMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing on the final moment ends playback, which on an episode rolls straight into
|
||||
* the next one — a skip forward must never be a way to finish what you are watching.
|
||||
*/
|
||||
@Test
|
||||
fun `fast-forwarding stops short of the end`() {
|
||||
val preview = accumulateSeek(null, duration - 5_000L, duration, 30_000L, forward = true)
|
||||
assertEquals(duration - 1_000L, preview.targetMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amounts read as words rather than signed numbers`() {
|
||||
assertEquals("10 seconds", seekAmountLabel(10_000L))
|
||||
assertEquals("30 seconds", seekAmountLabel(-30_000L))
|
||||
assertEquals("1 min", seekAmountLabel(60_000L))
|
||||
assertEquals("2 mins", seekAmountLabel(-120_000L))
|
||||
assertEquals("1 min 30 secs", seekAmountLabel(90_000L))
|
||||
assertEquals("2 mins 10 secs", seekAmountLabel(130_000L))
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of the position line are formatted against the *duration*, so the digits
|
||||
* do not change shape as somebody skips past the hour of a two-hour film.
|
||||
*/
|
||||
@Test
|
||||
fun `the position line matches the shape of the total`() {
|
||||
assertEquals("12:34 / 42:00", seekPositionLabel(754_000L, 2_520_000L))
|
||||
assertEquals("0:12:34 / 1:45:00", seekPositionLabel(754_000L, 6_300_000L))
|
||||
assertEquals("1:00:00 / 1:45:00", seekPositionLabel(3_600_000L, 6_300_000L))
|
||||
}
|
||||
|
||||
/** An interval this build has no vocabulary for is the default, never an odd skip. */
|
||||
@Test
|
||||
fun `only the offered intervals survive`() {
|
||||
assertEquals(10, normalizeSeekIntervalSeconds(10))
|
||||
assertEquals(20, normalizeSeekIntervalSeconds(20))
|
||||
assertEquals(30, normalizeSeekIntervalSeconds(30))
|
||||
assertEquals(DEFAULT_SEEK_INTERVAL_SECONDS, normalizeSeekIntervalSeconds(0))
|
||||
assertEquals(DEFAULT_SEEK_INTERVAL_SECONDS, normalizeSeekIntervalSeconds(45))
|
||||
assertEquals(DEFAULT_SEEK_INTERVAL_SECONDS, normalizeSeekIntervalSeconds(-10))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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.LinearLayout
|
||||
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
|
||||
|
||||
/**
|
||||
* Renders the subtitle drop-up to PNGs under `build/screenshots/subtitles-menu/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*SubtitleMenuScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* Captured over a playback still, so a near-black panel can be judged against a bright
|
||||
* frame. The transport row is drawn underneath even though the real menu hides the
|
||||
* controller: it is the thing the panel's bottom margin is measured off, and the gap above
|
||||
* it is only checkable if both are on screen. [bindSubtitleMenu] is the same call
|
||||
* `PlayerActivity` makes, so these are the real rows rather than a mock-up of them.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SubtitleMenuScreenshotTest {
|
||||
|
||||
/** The ordinary case: a couple of sidecars, one of them on. */
|
||||
@Test
|
||||
fun `subtitles on with a short track list`() {
|
||||
capture(
|
||||
name = "subtitles-menu-track-selected",
|
||||
tracks = listOf(
|
||||
entry("Off"),
|
||||
entry("English", selected = true),
|
||||
entry("English · SDH"),
|
||||
entry("Italian · Forced"),
|
||||
),
|
||||
focused = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/** Subtitles off, which is the first row and the one focus lands on. */
|
||||
@Test
|
||||
fun `subtitles off`() {
|
||||
capture(
|
||||
name = "subtitles-menu-off",
|
||||
tracks = listOf(entry("Off", selected = true), entry("English"), entry("Français")),
|
||||
focused = 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Focus in the size chips, the only other thing the menu can be doing. */
|
||||
@Test
|
||||
fun `text size focused`() {
|
||||
capture(
|
||||
name = "subtitles-menu-text-size",
|
||||
tracks = listOf(entry("Off"), entry("English", selected = true)),
|
||||
focusedSize = 2,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A library title with every track Emby holds, plus a burned-in one: the case that
|
||||
* decides whether the list scrolls inside the panel instead of growing up the screen.
|
||||
*/
|
||||
@Test
|
||||
fun `long track list scrolls inside the panel`() {
|
||||
capture(
|
||||
name = "subtitles-menu-long-list",
|
||||
tracks = listOf(
|
||||
entry("Off"),
|
||||
entry("English", selected = true),
|
||||
entry("English · SDH"),
|
||||
entry("Français"),
|
||||
entry("Deutsch · Forced"),
|
||||
entry("Italiano"),
|
||||
entry("Español · Default"),
|
||||
entry("Português (Brasil)"),
|
||||
entry("Nederlands · SDH · Burned in"),
|
||||
),
|
||||
focused = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The download section as a viewer first meets it: one row offering a search, under a
|
||||
* track list that has nothing in the language they wanted. This is the case the whole
|
||||
* feature exists for, so it is the one worth being able to look at.
|
||||
*/
|
||||
@Test
|
||||
fun `download offered with nothing suitable in the list`() {
|
||||
capture(
|
||||
name = "subtitles-menu-download-offered",
|
||||
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
|
||||
downloads = SubtitleDownloadState(
|
||||
available = true,
|
||||
entries = listOf(entry("Search for subtitles…")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A search in flight: the row is drawn but cannot be pressed a second time. */
|
||||
@Test
|
||||
fun `download searching`() {
|
||||
capture(
|
||||
name = "subtitles-menu-download-searching",
|
||||
tracks = listOf(entry("Off", selected = true)),
|
||||
downloads = SubtitleDownloadState(
|
||||
available = true,
|
||||
status = "Looking for subtitles. This can take a moment.",
|
||||
expanded = true,
|
||||
entries = listOf(SubtitleMenuEntry("Search for subtitles…", false, enabled = false)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Results. Both scrollers are now competing for the panel's height, which is the layout
|
||||
* question this capture answers.
|
||||
*/
|
||||
@Test
|
||||
fun `download results`() {
|
||||
capture(
|
||||
name = "subtitles-menu-download-results",
|
||||
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
|
||||
downloads = SubtitleDownloadState(
|
||||
available = true,
|
||||
expanded = true,
|
||||
entries = listOf(
|
||||
entry("Search again"),
|
||||
entry("English · 98% match"),
|
||||
entry("English · Hearing impaired · 96% match"),
|
||||
entry("English · Forced · 91% match"),
|
||||
entry("Italian · 84% match"),
|
||||
),
|
||||
),
|
||||
focusedDownload = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing found, which is an ordinary answer and has to read as one. */
|
||||
@Test
|
||||
fun `download found nothing`() {
|
||||
capture(
|
||||
name = "subtitles-menu-download-empty",
|
||||
tracks = listOf(entry("Off", selected = true)),
|
||||
downloads = SubtitleDownloadState(
|
||||
available = true,
|
||||
status = "No subtitles were found for this release.",
|
||||
expanded = true,
|
||||
entries = listOf(entry("Search for subtitles…")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun entry(label: String, selected: Boolean = false) = SubtitleMenuEntry(label, selected)
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
tracks: List<SubtitleMenuEntry>,
|
||||
focused: Int? = null,
|
||||
focusedSize: Int? = null,
|
||||
focusedDownload: Int? = null,
|
||||
downloads: SubtitleDownloadState = SubtitleDownloadState(),
|
||||
) {
|
||||
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,
|
||||
),
|
||||
)
|
||||
// The controls the menu drops up from, so the gap above them is visible.
|
||||
val controls = FrameLayout(activity)
|
||||
root.addView(controls, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
LayoutInflater.from(activity).inflate(R.layout.memby_player_controls, controls, true)
|
||||
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.GONE
|
||||
|
||||
val overlay = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.player_subtitle_overlay, root, false)
|
||||
root.addView(overlay)
|
||||
overlay.visibility = View.VISIBLE
|
||||
bindSubtitleMenu(
|
||||
overlay = overlay,
|
||||
tracks = tracks,
|
||||
sizes = listOf(entry("Small"), entry("Medium", selected = true), entry("Large")),
|
||||
downloads = downloads,
|
||||
)
|
||||
activity.setContentView(root)
|
||||
|
||||
val container = when {
|
||||
focusedSize != null -> R.id.player_subtitle_sizes
|
||||
focusedDownload != null -> R.id.player_subtitle_downloads
|
||||
else -> R.id.player_subtitle_tracks
|
||||
}
|
||||
val index = focusedSize ?: focusedDownload ?: focused
|
||||
if (index != null) {
|
||||
// Robolectric starts in touch mode, where a focusable row refuses focus and the
|
||||
// capture would show every option unfocused. This leaves touch mode the way a
|
||||
// D-pad press does.
|
||||
val row = requireNotNull(overlay.findViewById<LinearLayout>(container).getChildAt(index))
|
||||
row.requestFocusFromTouch()
|
||||
check(row.isFocused) { "the row to capture under focus never took it" }
|
||||
}
|
||||
|
||||
root.captureRoboImage("build/screenshots/subtitles-menu/$name.png")
|
||||
}
|
||||
|
||||
private fun previewArtwork() =
|
||||
javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream)
|
||||
}
|
||||
+1
-1
@@ -75,6 +75,6 @@ class TimeRemainingCueScreenshotTest {
|
||||
root.addView(cue)
|
||||
activity.setContentView(root)
|
||||
|
||||
root.captureRoboImage("build/screenshots/$name.png")
|
||||
root.captureRoboImage("build/screenshots/time-remaining-cue/$name.png")
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
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 RecommendationRequestScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `recommendation request panel`() {
|
||||
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
|
||||
.use(BitmapFactory::decodeStream)
|
||||
.asImageBitmap()
|
||||
|
||||
compose.setContent { RecommendationRequestPreview(previewArtwork = artwork) }
|
||||
compose.onNodeWithText("Request something new").fetchSemanticsNode()
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/recommendation-request/recommendation-request-panel.png",
|
||||
)
|
||||
}
|
||||
}
|
||||
+94
-4
@@ -2,14 +2,23 @@ package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||
import com.ponzischeme89.memby.update.UpdateStatus
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -34,13 +43,13 @@ class SettingsSheetScreenshotTest {
|
||||
@Test
|
||||
fun `active inactive and focused controls`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = false) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-panel.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-panel.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home overlay width`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = true) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-overlay.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -48,7 +57,7 @@ class SettingsSheetScreenshotTest {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.PLAYBACK)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-playback.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-playback.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,7 +65,88 @@ class SettingsSheetScreenshotTest {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.HOME)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-home.png")
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-home.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `welcome tone options`() {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.WELCOME)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-welcome.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signed-in devices`() {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.DEVICES)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-devices.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates page`() {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.UPDATES)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-updates.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every rail destination maps to its page`() {
|
||||
compose.setContent { InteractiveSettingsFixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
SettingsPage.entries.drop(1).forEach { expected ->
|
||||
compose.onNodeWithTag("settings-rail-${expected.name.lowercase()}").performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-page-${expected.name.lowercase()}").assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `about lists the release history and opens one release`() {
|
||||
compose.setContent { InteractiveSettingsFixture() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-rail-about").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
val releases = MembyReleaseHistory
|
||||
assertTrue("the shipped changelog parsed to nothing", releases.size >= 2)
|
||||
compose.onNodeWithTag("settings-release-${releases[1].version}")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText(releases[1].changes.first()).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `about version history`() {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.ABOUT)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractiveSettingsFixture() {
|
||||
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(170)
|
||||
firstFocus.requestFocus()
|
||||
}
|
||||
PreviewSurface {
|
||||
SettingsPanelContent(
|
||||
state = SettingsPanelState(
|
||||
selectedPage = selectedPage,
|
||||
installedVersion = "0.1.60",
|
||||
),
|
||||
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
|
||||
overlay = false,
|
||||
firstFocusRequester = firstFocus,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VersionHistoryTest {
|
||||
|
||||
@Test
|
||||
fun `parses versions dates and bullets`() {
|
||||
val releases = parseChangelog(
|
||||
"""
|
||||
# Changelog
|
||||
|
||||
Prose before the first release is format documentation, not history.
|
||||
|
||||
## 0.2.16 — 2026-08-03
|
||||
- Signed-in devices can be renamed.
|
||||
- MDBList ratings.
|
||||
|
||||
## 0.1.53
|
||||
- Renamed to Memby.
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("0.2.16", "0.1.53"), releases.map { it.version })
|
||||
assertEquals("2026-08-03", releases[0].date)
|
||||
assertEquals(2, releases[0].changes.size)
|
||||
assertEquals("MDBList ratings.", releases[0].changes[1])
|
||||
// A release with no date renders without one rather than showing a placeholder.
|
||||
assertEquals("", releases[1].date)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `joins a bullet wrapped across lines`() {
|
||||
val releases = parseChangelog(
|
||||
"""
|
||||
## 1.0.0 - 2026-01-01
|
||||
- A change long enough that the file
|
||||
wraps it onto a second line.
|
||||
- A second change.
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"A change long enough that the file wraps it onto a second line.",
|
||||
"A second change.",
|
||||
),
|
||||
releases.single().changes,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores text that is not a release`() {
|
||||
assertTrue(parseChangelog("# Changelog\n\nNothing released yet.\n").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped changelog is readable and newest first`() {
|
||||
val releases = MembyReleaseHistory
|
||||
assertTrue("CHANGELOG.md parsed to no releases", releases.size >= 2)
|
||||
assertTrue(releases.all { it.changes.isNotEmpty() })
|
||||
assertEquals(
|
||||
"the About page shows the file's order verbatim",
|
||||
releases.map { it.version }.sortedByDescending { version ->
|
||||
version.split('.').fold(0) { acc, part -> acc * 1_000 + part.toInt() }
|
||||
},
|
||||
releases.map { it.version },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.ponzischeme89.memby.ui.whatsnew
|
||||
|
||||
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.ui.settings.ReleaseNote
|
||||
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
|
||||
|
||||
/**
|
||||
* The panel a television shows once, after it has updated itself overnight. Nobody sees it
|
||||
* twice, so being able to look at it without reinstalling on hardware is the point.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class WhatsNewScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `a typical release`() {
|
||||
compose.setContent {
|
||||
PreviewSurface {
|
||||
WhatsNewOverlay(
|
||||
release = ReleaseNote(
|
||||
version = "0.2.24",
|
||||
date = "2026-08-05",
|
||||
changes = listOf(
|
||||
"Fixed: The centre button on the remote now pauses straight away.",
|
||||
"Changed: Subtitles now open in a small menu above the button.",
|
||||
"Added: Continuing and ended tags on My Shows.",
|
||||
),
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/whats-new/whats-new.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long release is capped`() {
|
||||
compose.setContent {
|
||||
PreviewSurface {
|
||||
WhatsNewOverlay(
|
||||
release = ReleaseNote(
|
||||
version = "0.2.24",
|
||||
date = "2026-08-05",
|
||||
changes = List(9) { "Fixed: Change number ${it + 1} in a long release." },
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/whats-new/whats-new-long.png")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ponzischeme89.memby.ui.whatsnew
|
||||
|
||||
import com.ponzischeme89.memby.ui.settings.ReleaseNote
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class WhatsNewTest {
|
||||
|
||||
private val history = listOf(
|
||||
ReleaseNote("0.2.24", "2026-08-05", listOf("Fixed: A thing.", "Added: Another thing.")),
|
||||
ReleaseNote("0.2.23", "2026-08-04", listOf("Added: An older thing.")),
|
||||
)
|
||||
|
||||
private fun decide(
|
||||
installed: String = "0.2.24",
|
||||
seen: String? = "0.2.23",
|
||||
signedIn: Boolean = true,
|
||||
releases: List<ReleaseNote> = history,
|
||||
) = whatsNewDecision(installed, seen, signedIn, releases)
|
||||
|
||||
@Test
|
||||
fun `an updated television is shown the notes for the build it is running`() {
|
||||
assertEquals(WhatsNewDecision.Show(history[0]), decide())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same build is only announced once`() {
|
||||
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.24"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install records the version instead of announcing it`() {
|
||||
assertEquals(
|
||||
WhatsNewDecision.MarkSeen("0.2.24"),
|
||||
decide(seen = null, signedIn = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an install that predates the record is announced once it is signed in`() {
|
||||
assertEquals(WhatsNewDecision.Show(history[0]), decide(seen = null, signedIn = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signed-out television with a record waits rather than burning the notes`() {
|
||||
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.23", signedIn = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a build the changelog does not describe is recorded silently`() {
|
||||
assertEquals(WhatsNewDecision.MarkSeen("0.9.0"), decide(installed = "0.9.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a release with no bullets is not shown as an empty panel`() {
|
||||
val empty = listOf(ReleaseNote("0.2.24", "2026-08-05", emptyList()))
|
||||
assertEquals(WhatsNewDecision.MarkSeen("0.2.24"), decide(releases = empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable version does nothing at all`() {
|
||||
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `known labels become their own chip`() {
|
||||
assertEquals(ChangeLine("FIXED", "A thing."), changeLine("Fixed: A thing."))
|
||||
assertEquals(ChangeLine("ADDED", "Another thing."), changeLine("Added: Another thing."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bullet count is cut to what the screen can hold`() {
|
||||
// A 720p television, which is the small case this exists for.
|
||||
assertEquals(4, maxChangesFor(540))
|
||||
// Room for more does not mean an unbounded list.
|
||||
assertEquals(6, maxChangesFor(1200))
|
||||
// Never zero: a panel with a heading and nothing under it says nothing at all.
|
||||
assertEquals(1, maxChangesFor(200))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sentence that merely contains a colon is left whole`() {
|
||||
assertEquals(
|
||||
ChangeLine(null, "Rename a TV in Settings: Devices."),
|
||||
changeLine("Rename a TV in Settings: Devices."),
|
||||
)
|
||||
assertEquals(ChangeLine(null, "No label here."), changeLine("No label here."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ponzischeme89.memby.update
|
||||
|
||||
import android.content.pm.PackageInstaller
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The wording is the feature here: a failed self-update on a television has no logcat and
|
||||
* no support channel, so the sentence on screen is the whole diagnosis.
|
||||
*/
|
||||
class InstallStatusMessageTest {
|
||||
|
||||
@Test
|
||||
fun `blocked install names where the permission lives`() {
|
||||
val message = installStatusMessage(PackageInstaller.STATUS_FAILURE_BLOCKED, null)
|
||||
assertTrue(message, message.contains("Unknown sources"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conflicting signature tells the viewer to reinstall`() {
|
||||
val message = installStatusMessage(PackageInstaller.STATUS_FAILURE_CONFLICT, null)
|
||||
assertTrue(message, message.contains("Uninstall Memby"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an aborted install invites another attempt rather than reading as broken`() {
|
||||
val message = installStatusMessage(PackageInstaller.STATUS_FAILURE_ABORTED, null)
|
||||
assertTrue(message, message.contains("try again", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised failure carries the system's own message`() {
|
||||
assertEquals(
|
||||
"The update could not be installed (INSTALL_FAILED_VERSION_DOWNGRADE).",
|
||||
installStatusMessage(PackageInstaller.STATUS_FAILURE, "INSTALL_FAILED_VERSION_DOWNGRADE"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised failure with no message still says something`() {
|
||||
assertEquals(
|
||||
"The update could not be installed.",
|
||||
installStatusMessage(PackageInstaller.STATUS_FAILURE, " "),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success is reported, because the app may live long enough to show it`() {
|
||||
assertTrue(installStatusMessage(PackageInstaller.STATUS_SUCCESS, null).contains("installed"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ponzischeme89.memby.update
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rule that decides whether an update is allowed to reach the installer.
|
||||
*
|
||||
* The case worth pinning is [SignerVerdict.UNVERIFIABLE]: an unreadable signature on the
|
||||
* downloaded APK used to be reported as the wrong signing key, which rejected correctly
|
||||
* signed updates on the televisions where `getPackageArchiveInfo` returns no signing
|
||||
* information, and left a manual reinstall as the only way to move a version.
|
||||
*/
|
||||
class SignerVerdictTest {
|
||||
|
||||
private val key = setOf("0b61ec2a364d61b765949e5cf281a131d91e60f20ac934282feea41bf509d894")
|
||||
private val otherKey = setOf("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
|
||||
|
||||
@Test
|
||||
fun `the same key matches`() {
|
||||
assertEquals(SignerVerdict.MATCH, signerVerdict(key, key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different key is a mismatch`() {
|
||||
assertEquals(SignerVerdict.MISMATCH, signerVerdict(otherKey, key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable archive signature is not a mismatch`() {
|
||||
assertEquals(SignerVerdict.UNVERIFIABLE, signerVerdict(emptySet(), key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable installed signature is not a mismatch either`() {
|
||||
assertEquals(SignerVerdict.UNVERIFIABLE, signerVerdict(key, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signer order does not matter`() {
|
||||
val a = setOf("aa", "bb")
|
||||
val b = setOf("bb", "aa")
|
||||
assertEquals(SignerVerdict.MATCH, signerVerdict(a, b))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user