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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
@@ -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"),
)
}
}
@@ -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)
}
@@ -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())
}
}
@@ -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))
}
}
@@ -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)
}
@@ -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")
}
}
@@ -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",
)
}
}
@@ -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))
}
}