Memby v0.1.53: Android TV client plus gateway
Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Contract tests for the gateway wire format.
|
||||
*
|
||||
* The fixtures below are the exact shape `server/internal/api` emits: camelCase envelope
|
||||
* fields wrapping Emby's own PascalCase item JSON. If someone renames a field on either
|
||||
* side, this fails before a TV ever sees it.
|
||||
*/
|
||||
class GatewayPayloadTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
isLenient = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a home payload with emby-shaped items`() {
|
||||
val payload = """
|
||||
{
|
||||
"continueWatching": [
|
||||
{"Id":"1","Name":"Dune","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}
|
||||
],
|
||||
"nextUp": [
|
||||
{"Id":"2","Name":"Pilot","Type":"Episode","SeriesName":"Severance"}
|
||||
],
|
||||
"favorites": [
|
||||
{"Id":"3","Name":"Arrival","Type":"Movie","UserData":{"IsFavorite":true}}
|
||||
],
|
||||
"latestMovies": [],
|
||||
"partial": false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val home = json.decodeFromString<GatewayHome>(payload)
|
||||
|
||||
assertEquals("Dune", home.continueWatching.single().name)
|
||||
assertEquals(3_600_000L, home.continueWatching.single().userData!!.playbackPositionTicks / 10_000L)
|
||||
assertEquals("Severance", home.nextUp.single().seriesName)
|
||||
assertTrue(home.favorites.single().isFavorite)
|
||||
assertTrue(home.latestMovies.isEmpty())
|
||||
assertEquals(false, home.partial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes server-composed rows including recommendations`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows": [
|
||||
{"id":"continue","title":"Continue Watching","kind":"continue","items":[{"Id":"1","Name":"Dune","Type":"Movie"}]},
|
||||
{"id":"favorites","title":"Favourites","kind":"favorites","items":[{"Id":"3","Name":"Arrival","Type":"Movie"}]},
|
||||
{"id":"similar:sev","title":"Because you watched Severance","kind":"similar","items":[{"Id":"7","Name":"Devs","Type":"Series"}]},
|
||||
{"id":"recommended","title":"Recommended from your watching history","kind":"recommended","items":[{"Id":"8","Name":"Solaris","Type":"Movie"}]}
|
||||
],
|
||||
"continueWatching": [{"Id":"1","Name":"Dune","Type":"Movie"}],
|
||||
"nextUp": [],
|
||||
"favorites": [{"Id":"3","Name":"Arrival","Type":"Movie"}],
|
||||
"latestMovies": [],
|
||||
"partial": false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val home = json.decodeFromString<GatewayHome>(payload)
|
||||
|
||||
assertEquals(
|
||||
listOf("continue", "favorites", "similar:sev", "recommended"),
|
||||
home.rows.map { it.id },
|
||||
)
|
||||
assertEquals("Because you watched Severance", home.rows[2].title)
|
||||
assertEquals("Solaris", home.rows.last().items.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a home payload without rows still decodes`() {
|
||||
// The gateway omits recommendation rows while they are still building, and an
|
||||
// older gateway would not send `rows` at all.
|
||||
val home = json.decodeFromString<GatewayHome>(
|
||||
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""",
|
||||
)
|
||||
assertTrue(home.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a partial home payload still decodes`() {
|
||||
val home = json.decodeFromString<GatewayHome>(
|
||||
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""",
|
||||
)
|
||||
assertTrue(home.partial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a playback response`() {
|
||||
val playback = json.decodeFromString<GatewayPlayback>(
|
||||
"""{"itemId":"9","title":"Severance – Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
|
||||
)
|
||||
assertEquals("9", playback.itemId)
|
||||
assertEquals(42_000L, playback.resumePositionMs)
|
||||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The gateway's 503 body is the only thing allowed to put words on the maintenance
|
||||
* screen, so what gets trusted out of it matters.
|
||||
*/
|
||||
class MaintenanceMessageTest {
|
||||
|
||||
@Test
|
||||
fun `reads the operator's message`() {
|
||||
val body = """{"error":"Back at 9pm","maintenance":true,"message":"Back at 9pm"}"""
|
||||
|
||||
assertEquals("Back at 9pm", parseMaintenanceMessage(body))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores a 503 that is not a maintenance response`() {
|
||||
// Some proxy or upstream returning its own 503 must not get to write on screen.
|
||||
assertNull(parseMaintenanceMessage("""{"message":"upstream connect error"}"""))
|
||||
assertNull(parseMaintenanceMessage("""{"maintenance":false,"message":"nope"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `survives a body that is not the shape we expect`() {
|
||||
assertNull(parseMaintenanceMessage(""))
|
||||
assertNull(parseMaintenanceMessage(" "))
|
||||
assertNull(parseMaintenanceMessage("<html>502 Bad Gateway</html>"))
|
||||
assertNull(parseMaintenanceMessage("""{"maintenance":true"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank and whitespace-only messages are rejected`() {
|
||||
assertNull(parseMaintenanceMessage("""{"maintenance":true,"message":" "}"""))
|
||||
assertNull(parseMaintenanceMessage("""{"maintenance":true}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an over-long message is truncated to something that fits a screen`() {
|
||||
val long = "x".repeat(400)
|
||||
val parsed = parseMaintenanceMessage("""{"maintenance":true,"message":"$long"}""")
|
||||
|
||||
assertEquals(160, parsed?.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown fields from a newer gateway are ignored`() {
|
||||
val body = """{"maintenance":true,"message":"Upgrading","until":"2026-07-27T22:00:00Z"}"""
|
||||
|
||||
assertEquals("Upgrading", parseMaintenanceMessage(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackReportMathTest {
|
||||
@Test
|
||||
fun millisecondsAreConvertedToEmbyTicks() {
|
||||
assertEquals(12_340_000L, millisecondsToTicks(1_234L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negativePositionsAreClamped() {
|
||||
assertEquals(0L, millisecondsToTicks(-1L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileSettingsTest {
|
||||
private val profile = EmbyProfile(
|
||||
id = "server::user",
|
||||
serverUrl = "http://emby",
|
||||
token = "token",
|
||||
userId = "user",
|
||||
username = "Matt",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `active profile matches both server and user`() {
|
||||
val settings = Settings(
|
||||
serverUrl = profile.serverUrl,
|
||||
token = profile.token,
|
||||
userId = profile.userId,
|
||||
profiles = listOf(profile),
|
||||
)
|
||||
|
||||
assertEquals(profile.id, settings.activeProfileId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile from another server is not treated as active`() {
|
||||
val settings = Settings(
|
||||
serverUrl = "http://another-server",
|
||||
token = profile.token,
|
||||
userId = profile.userId,
|
||||
profiles = listOf(profile),
|
||||
)
|
||||
|
||||
assertNull(settings.activeProfileId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RowAnalyticsTest {
|
||||
|
||||
/** A controllable clock, so dwell assertions are exact rather than timing-dependent. */
|
||||
private class FakeClock(var millis: Long = 1_700_000_000_000) {
|
||||
fun advance(by: Long) { millis += by }
|
||||
}
|
||||
|
||||
private fun analytics(clock: FakeClock, maxBuffered: Int = 200) =
|
||||
RowAnalytics(now = { clock.millis }, maxBuffered = maxBuffered)
|
||||
|
||||
@Test
|
||||
fun `dwell is measured when focus leaves a row`() {
|
||||
val clock = FakeClock()
|
||||
val collector = analytics(clock)
|
||||
|
||||
collector.rowFocused("recommended", "MOVIES", "item-1")
|
||||
clock.advance(5_000)
|
||||
collector.rowFocused("favorites", "FAVORITES", "item-2")
|
||||
|
||||
val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS }
|
||||
assertEquals("recommended", focus.rowId)
|
||||
assertEquals(5_000L, focus.dwellMs)
|
||||
assertEquals("item-1", focus.itemId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving between cards inside one row keeps measuring the same dwell`() {
|
||||
val clock = FakeClock()
|
||||
val collector = analytics(clock)
|
||||
|
||||
collector.rowFocused("recommended", "MOVIES", "item-1")
|
||||
clock.advance(3_000)
|
||||
collector.rowFocused("recommended", "MOVIES", "item-2")
|
||||
clock.advance(3_000)
|
||||
collector.endFocus()
|
||||
|
||||
val focuses = collector.drain().filter { it.event == RowAnalytics.EVENT_FOCUS }
|
||||
assertEquals(1, focuses.size)
|
||||
assertEquals(6_000L, focuses.single().dwellMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passing through a row is not counted as attention`() {
|
||||
val clock = FakeClock()
|
||||
val collector = analytics(clock)
|
||||
|
||||
collector.rowFocused("continue", "CONTINUE", "a")
|
||||
clock.advance(RowAnalytics.MIN_DWELL_MS - 1)
|
||||
collector.rowFocused("favorites", "FAVORITES", "b")
|
||||
|
||||
assertTrue(
|
||||
"a sub-threshold glance should produce no focus event",
|
||||
collector.drain().none { it.event == RowAnalytics.EVENT_FOCUS },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an impression is recorded once per row`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
collector.rowImpression("recommended", "MOVIES")
|
||||
|
||||
val impressions = collector.drain().filter { it.event == RowAnalytics.EVENT_IMPRESSION }
|
||||
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `focusing a row that was never reported still records the impression`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
collector.rowFocused("similar:sev", "MOVIES", "item-1")
|
||||
|
||||
val events = collector.drain()
|
||||
assertEquals(1, events.count { it.event == RowAnalytics.EVENT_IMPRESSION })
|
||||
assertEquals("similar:sev", events.single().rowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selections are recorded with their item`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
collector.rowSelected("recommended", "MOVIES", "item-9")
|
||||
|
||||
val select = collector.drain().single()
|
||||
assertEquals(RowAnalytics.EVENT_SELECT, select.event)
|
||||
assertEquals("item-9", select.itemId)
|
||||
assertEquals("MOVIES", select.rowKind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draining clears the buffer`() {
|
||||
val collector = analytics(FakeClock())
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
|
||||
assertEquals(1, collector.drain().size)
|
||||
assertEquals(0, collector.drain().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the buffer is bounded and keeps the most recent events`() {
|
||||
val collector = analytics(FakeClock(), maxBuffered = 3)
|
||||
|
||||
repeat(6) { collector.rowImpression("row-$it", "MOVIES") }
|
||||
|
||||
val events = collector.drain()
|
||||
assertEquals(3, events.size)
|
||||
assertEquals(listOf("row-3", "row-4", "row-5"), events.map { it.rowId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timestamps are sent in the format the gateway parses`() {
|
||||
val collector = analytics(FakeClock())
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
|
||||
val occurredAt = collector.drain().single().occurredAt
|
||||
assertTrue(
|
||||
"expected RFC3339 UTC, got $occurredAt",
|
||||
occurredAt.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z""")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset forgets buffered events and seen rows`() {
|
||||
val collector = analytics(FakeClock())
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
|
||||
collector.reset()
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
|
||||
assertEquals(1, collector.drain().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ServerConfigTest {
|
||||
@Test
|
||||
fun `hardwired address wins over anything the user typed`() {
|
||||
assertEquals(
|
||||
"http://tv.example.com:8096",
|
||||
resolveServerUrl("http://tv.example.com:8096/", "http://192.168.1.50:8096"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hardwired address is normalised like a typed one`() {
|
||||
assertEquals("http://10.0.0.5:8096", resolveServerUrl("10.0.0.5:8096", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the typed address when the build pins nothing`() {
|
||||
assertEquals("https://emby.example.com", resolveServerUrl(null, "https://emby.example.com/"))
|
||||
assertEquals("https://emby.example.com", resolveServerUrl(" ", "https://emby.example.com/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when neither source supplies an address`() {
|
||||
assertNull(resolveServerUrl(null, ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
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(
|
||||
continueWatching = listOf(BaseItem(id = "resume")),
|
||||
favorites = listOf(BaseItem(id = "favorite")),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MediaBadgesTest {
|
||||
@Test
|
||||
fun `derives premium video and audio badges without duplicates`() {
|
||||
val item = BaseItem(
|
||||
id = "movie",
|
||||
mediaStreams = listOf(
|
||||
MediaStream(
|
||||
type = "Video",
|
||||
codec = "hevc",
|
||||
width = 3840,
|
||||
videoRangeType = "DOVI",
|
||||
title = "Dolby Vision HEVC",
|
||||
),
|
||||
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"),
|
||||
mediaBadges(item),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns no badges when stream metadata is unavailable`() {
|
||||
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The home screen is composed by the gateway. These pin the parts of that contract the
|
||||
* client is responsible for: honouring the user's section toggles, always showing rows
|
||||
* the server invented, and surviving a cold start from cache.
|
||||
*/
|
||||
class ServerHomeRowsTest {
|
||||
|
||||
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
|
||||
id = id,
|
||||
title = id.replaceFirstChar(Char::uppercase),
|
||||
kind = kind,
|
||||
items = itemIds.map { BaseItem(id = it) },
|
||||
)
|
||||
|
||||
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"),
|
||||
row("recommended", "recommended", "f"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `server row order and titles are preserved`() {
|
||||
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
|
||||
|
||||
assertEquals(
|
||||
listOf("continue", "next-up", "favorites", "latest-movies", "similar:sev", "recommended"),
|
||||
rows.map { it.id },
|
||||
)
|
||||
assertEquals("Recommended", rows.last().title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling a section hides its rows but never the recommendations`() {
|
||||
val settings = Settings(homeSections = "continue")
|
||||
|
||||
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), settings)
|
||||
|
||||
assertEquals(
|
||||
listOf("continue", "next-up", "similar:sev", "recommended"),
|
||||
rows.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recommendation rows render as poster cards`() {
|
||||
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
|
||||
.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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown row kind from a newer server still renders`() {
|
||||
val rows = serverHomeRows(
|
||||
HomeUiState(rows = listOf(row("collection:halloween", "collection", "x")), loading = emptySet()),
|
||||
Settings(),
|
||||
)
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only empty rows show a loading state while a refresh is running`() {
|
||||
val rows = serverHomeRows(
|
||||
HomeUiState(
|
||||
rows = listOf(row("continue", "continue", "a"), row("recommended", "recommended")),
|
||||
loading = setOf(HomeSection.CONTINUE),
|
||||
),
|
||||
Settings(),
|
||||
).associateBy { it.id }
|
||||
|
||||
assertEquals(false, rows.getValue("continue").loading)
|
||||
assertEquals(true, rows.getValue("recommended").loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows survive a round trip through the on-device cache`() {
|
||||
val state = HomeUiState(rows = serverRows, loading = emptySet())
|
||||
|
||||
val encoded = Json.encodeToString(HomeCache.serializer(), state.toCache())
|
||||
val restored = HomeUiState.from(Json.decodeFromString<HomeCache>(encoded))
|
||||
|
||||
assertEquals(serverRows.map { it.id }, restored.rows.map { it.id })
|
||||
assertEquals("f", restored.rows.last().items.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maintenance is a distinct state from an ordinary refresh error`() {
|
||||
val offline = HomeUiState(rows = serverRows, maintenanceMessage = "Back at 9pm", hasRefreshError = true)
|
||||
val slow = HomeUiState(rows = serverRows, hasRefreshError = true)
|
||||
|
||||
// The screen keys off maintenanceMessage; a slow connection must not trigger it.
|
||||
assertEquals("Back at 9pm", offline.maintenanceMessage)
|
||||
assertEquals(null, slow.maintenanceMessage)
|
||||
|
||||
// Rows survive underneath, so returning from maintenance does not start empty.
|
||||
assertEquals(serverRows.size, serverHomeRows(offline, Settings()).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache written before rows existed still decodes`() {
|
||||
val legacy = """{"continueWatching":[{"Id":"a"}],"favorites":[],"nextUp":[],"latestMovies":[]}"""
|
||||
|
||||
val restored = HomeUiState.from(Json { ignoreUnknownKeys = true }.decodeFromString<HomeCache>(legacy))
|
||||
|
||||
assertTrue(restored.rows.isEmpty())
|
||||
assertEquals("a", restored.continueWatching.single().id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ponzischeme89.memby.ui.screensaver
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the ring's pure geometry. The animation/lifecycle behaviour (progress
|
||||
* reaching 100% after the slide duration, advancing exactly once, resetting on the next
|
||||
* slide, restarting on manual navigation, and cancelling on teardown) is driven by
|
||||
* Compose's [androidx.compose.animation.core.Animatable] + `repeatOnLifecycle` and is
|
||||
* verified on-device against the DreamService path rather than here, since this module
|
||||
* has no Compose UI-test / Robolectric harness.
|
||||
*/
|
||||
class SlideProgressMathTest {
|
||||
|
||||
private val tolerance = 0.0001f
|
||||
|
||||
@Test
|
||||
fun beginsEmpty() {
|
||||
assertEquals(0f, SlideProgressMath.sweepAngle(0f), tolerance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachesFullCircleAtCompletion() {
|
||||
assertEquals(360f, SlideProgressMath.sweepAngle(1f), tolerance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isProportionalMidway() {
|
||||
assertEquals(180f, SlideProgressMath.sweepAngle(0.5f), tolerance)
|
||||
assertEquals(90f, SlideProgressMath.sweepAngle(0.25f), tolerance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampsOutOfRangeValues() {
|
||||
assertEquals(0f, SlideProgressMath.sweepAngle(-0.5f), tolerance)
|
||||
assertEquals(360f, SlideProgressMath.sweepAngle(1.5f), tolerance)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user