Big changes
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CastMetadataTest {
|
||||
|
||||
@Test
|
||||
fun `cast contains actors and preserves Emby order`() {
|
||||
val item = BaseItem(
|
||||
id = "movie",
|
||||
people = listOf(
|
||||
EmbyPerson(id = "director", name = "Director", type = "Director"),
|
||||
EmbyPerson(id = "lead", name = "Lead", role = "Detective", type = "Actor"),
|
||||
EmbyPerson(id = "support", name = "Support", role = "Doctor", type = "Actor"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("lead", "support"), item.cast.map(EmbyPerson::id))
|
||||
assertEquals("Detective", item.cast.first().role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes emby people and portrait tags`() {
|
||||
val item = Json { ignoreUnknownKeys = true }.decodeFromString<BaseItem>(
|
||||
"""
|
||||
{
|
||||
"Id":"movie",
|
||||
"Name":"Example",
|
||||
"Type":"Movie",
|
||||
"People":[{
|
||||
"Id":"person-1",
|
||||
"Name":"Alex Actor",
|
||||
"Role":"Morgan",
|
||||
"Type":"Actor",
|
||||
"PrimaryImageTag":"portrait-tag"
|
||||
}]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("person-1", item.cast.single().id)
|
||||
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayAlert
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ServiceAlertTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
|
||||
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
|
||||
|
||||
@Test
|
||||
fun `picks the first alert this tv has not seen`() {
|
||||
val chosen = firstUnseenAlert(
|
||||
listOf(alert("a"), alert("b"), alert("c")),
|
||||
seen = setOf("a", "b"),
|
||||
)
|
||||
assertEquals("c", chosen?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert already shown never comes back`() {
|
||||
assertNull(firstUnseenAlert(listOf(alert("a")), seen = setOf("a")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incomplete alerts are dropped rather than drawn empty`() {
|
||||
val alerts = listOf(
|
||||
alert(id = ""),
|
||||
alert(id = "b", title = " "),
|
||||
alert(id = "c", message = ""),
|
||||
alert(id = "d"),
|
||||
)
|
||||
assertEquals("d", firstUnseenAlert(alerts, seen = emptySet())?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert still on offer keeps waiting for a screen`() {
|
||||
assertFalse(
|
||||
pendingAlertExpired(
|
||||
pendingId = "a",
|
||||
shownId = null,
|
||||
alerts = listOf(alert("a"), alert("b")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unshown alert is given up on once the gateway stops offering it`() {
|
||||
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = listOf(alert("b"))))
|
||||
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert already on screen is left to its own timer`() {
|
||||
assertFalse(pendingAlertExpired(pendingId = "a", shownId = "a", alerts = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes without alerts for a gateway that predates them`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"message":""}""",
|
||||
)
|
||||
assertTrue(status.alerts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes the alert payload the gateway sends`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""
|
||||
{"maintenance":false,"message":"","alerts":[{
|
||||
"id":"sonarr:7:42:aired","kind":"sonarr-aired","title":"Northbound",
|
||||
"message":"S02E04 aired at 9:00 PM and will be in Emby soon.",
|
||||
"itemId":"sonarr:7:42","imageTag":"sonarr","airedAt":"2026-07-27T21:00:00+12:00"
|
||||
}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val alert = status.alerts.single()
|
||||
assertEquals("sonarr:7:42:aired", alert.id)
|
||||
assertEquals("sonarr:7:42", alert.itemId)
|
||||
assertEquals("sonarr", alert.imageTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubtitleSupportTest {
|
||||
@Test
|
||||
fun `normalizes relative delivery urls and preserves selection metadata`() {
|
||||
val tracks = subtitleTracks(
|
||||
streams = listOf(
|
||||
MediaStream(
|
||||
index = 3,
|
||||
type = "Subtitle",
|
||||
codec = "srt",
|
||||
displayTitle = "English SDH",
|
||||
language = "eng",
|
||||
isDefault = true,
|
||||
isForced = true,
|
||||
isTextSubtitleStream = true,
|
||||
deliveryUrl = "/Videos/a/Subtitles/3/Stream.srt?x=1",
|
||||
),
|
||||
),
|
||||
serverUrl = "https://emby.example",
|
||||
token = "a b",
|
||||
itemId = "item",
|
||||
mediaSourceId = "source",
|
||||
)
|
||||
|
||||
assertEquals(1, tracks.size)
|
||||
assertEquals(
|
||||
"https://emby.example/Videos/a/Subtitles/3/Stream.srt?x=1&api_key=a+b",
|
||||
tracks.single().url,
|
||||
)
|
||||
assertEquals("application/x-subrip", tracks.single().mimeType)
|
||||
assertEquals("eng", tracks.single().language)
|
||||
assertTrue(tracks.single().isDefault)
|
||||
assertTrue(tracks.single().isForced)
|
||||
assertTrue(tracks.single().isHearingImpaired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps supported text subtitles and rejects bitmap formats`() {
|
||||
assertEquals("text/vtt", subtitleMimeType("webvtt"))
|
||||
assertEquals("text/x-ssa", subtitleMimeType(null, "https://host/subtitle.ass?token=x"))
|
||||
assertEquals(null, subtitleMimeType("pgssub"))
|
||||
assertEquals(null, subtitleMimeType("dvdsub"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not duplicate authentication already present`() {
|
||||
assertEquals(
|
||||
"https://host/sub.srt?api_key=existing",
|
||||
authenticatedDeliveryUrl("https://other", "https://host/sub.srt?api_key=existing", "new"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image subtitles remain visible as encode choices without an overlay url`() {
|
||||
val track = subtitleTracks(
|
||||
streams = listOf(
|
||||
MediaStream(
|
||||
index = 7,
|
||||
type = "Subtitle",
|
||||
codec = "pgssub",
|
||||
language = "eng",
|
||||
isForced = true,
|
||||
deliveryMethod = "Encode",
|
||||
),
|
||||
),
|
||||
serverUrl = "https://emby.example",
|
||||
token = "token",
|
||||
itemId = "item",
|
||||
mediaSourceId = "source",
|
||||
).single()
|
||||
|
||||
assertEquals("Encode", track.deliveryMethod)
|
||||
assertEquals("", track.url)
|
||||
assertEquals("", track.mimeType)
|
||||
assertTrue(track.isForced)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringTodayTagsTest {
|
||||
@Test
|
||||
fun `tags matching recommended series from today's Sonarr schedule`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
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",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
kind = "recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "The Bear", type = "Series"),
|
||||
BaseItem(id = "series-2", name = "Severance", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val recommendations = home.withAiringTodayTags().rows.last().items
|
||||
|
||||
assertTrue(recommendations[0].membyAiringToday)
|
||||
assertFalse(recommendations[1].membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show matching ignores punctuation spacing and case`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:2",
|
||||
name = "Marvel's DAREDEVIL",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "Marvels Daredevil", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not tag movies with a matching title`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:2",
|
||||
name = "Fargo",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(BaseItem(id = "movie-1", name = "Fargo", type = "Movie")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileViewModelStoreOwnerTest {
|
||||
private val factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ProbeViewModel() as T
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profiles have isolated view models and clearing cancels the old one`() {
|
||||
val mattOwner = ProfileViewModelStoreOwner()
|
||||
val familyOwner = ProfileViewModelStoreOwner()
|
||||
|
||||
val matt = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
|
||||
val mattAgain = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
|
||||
val family = ViewModelProvider(familyOwner, factory)[ProbeViewModel::class.java]
|
||||
|
||||
assertSame(matt, mattAgain)
|
||||
assertNotSame(matt, family)
|
||||
assertFalse(matt.cleared)
|
||||
assertFalse(family.cleared)
|
||||
|
||||
mattOwner.clear()
|
||||
|
||||
assertTrue(matt.cleared)
|
||||
assertFalse(family.cleared)
|
||||
}
|
||||
|
||||
private class ProbeViewModel : ViewModel() {
|
||||
var cleared = false
|
||||
private set
|
||||
|
||||
override fun onCleared() {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SeriesDetailsTest {
|
||||
private fun episode(
|
||||
id: String,
|
||||
season: Int,
|
||||
number: Int,
|
||||
played: Boolean = false,
|
||||
) = BaseItem(
|
||||
id = id,
|
||||
name = "Episode $number",
|
||||
type = "Episode",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `seasons are distinct and ordered with specials first`() {
|
||||
val episodes = listOf(
|
||||
episode("s2e1", 2, 1),
|
||||
episode("s0e1", 0, 1),
|
||||
episode("s1e1", 1, 1),
|
||||
episode("s2e2", 2, 2),
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 1, 2), availableSeasons(episodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season list is ordered by episode number`() {
|
||||
val episodes = listOf(
|
||||
episode("e3", 1, 3),
|
||||
episode("e1", 1, 1),
|
||||
episode("e2", 1, 2),
|
||||
)
|
||||
|
||||
assertEquals(listOf("e1", "e2", "e3"), episodesForSeason(episodes, 1).map(BaseItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default season contains the first unwatched episode`() {
|
||||
val episodes = listOf(
|
||||
episode("s1e1", 1, 1, played = true),
|
||||
episode("s1e2", 1, 2, played = true),
|
||||
episode("s2e1", 2, 1),
|
||||
)
|
||||
|
||||
assertEquals(2, defaultSeason(episodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail tabs default safely to episodes`() {
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
|
||||
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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 com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
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 aired banner to PNGs under `build/screenshots/`, so its layout can be
|
||||
* looked at without deploying to a TV.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServiceAlertBannerScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* This is the **only** part of `app/src/test` that touches Android — rendering a
|
||||
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
|
||||
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
|
||||
*
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
|
||||
* own background, flush to the top edge exactly as `MainActivity` places it.
|
||||
*
|
||||
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
|
||||
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
|
||||
* are null because there is no network here — which makes these the check on the fallback
|
||||
* tile too.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class ServiceAlertBannerScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `awaiting download`() {
|
||||
capture(
|
||||
"alert-banner-awaiting",
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloading now`() {
|
||||
capture(
|
||||
"alert-banner-downloading",
|
||||
ServiceAlert(
|
||||
id = "sonarr:8:43:aired",
|
||||
title = "The Long Dark",
|
||||
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both lines ellipsise at one line; this is the case that proves it. */
|
||||
@Test
|
||||
fun `long title and message`() {
|
||||
capture(
|
||||
"alert-banner-long-text",
|
||||
ServiceAlert(
|
||||
id = "sonarr:9:88:aired",
|
||||
title = "A Very Long Programme Title That Will Not Fit In One Line",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
||||
"downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Shortest plausible content: the bar keeps its height and the text stays put. */
|
||||
@Test
|
||||
fun `short title and message`() {
|
||||
capture(
|
||||
"alert-banner-short-text",
|
||||
ServiceAlert(
|
||||
id = "sonarr:3:12:aired",
|
||||
title = "Dune",
|
||||
message = "S01E01 aired and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown ring is full at t=0, which tells you nothing about whether it
|
||||
* empties. Holding the clock and stepping it forward captures it mid-sweep.
|
||||
*/
|
||||
@Test
|
||||
fun `countdown part way through`() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
AlertBannerOnHomeBackground(
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
compose.mainClock.advanceTimeBy(6_500L)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/alert-banner-countdown.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, alert: ServiceAlert) {
|
||||
compose.setContent { AlertBannerOnHomeBackground(alert) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PictureSizingTest {
|
||||
|
||||
@Test
|
||||
fun `auto fills classic four by three video`() {
|
||||
assertTrue(shouldZoomVideo("auto", 640, 480, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto accounts for anamorphic pixel aspect ratio`() {
|
||||
assertTrue(shouldZoomVideo("auto", 720, 480, 8f / 9f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto preserves modern widescreen video`() {
|
||||
assertFalse(shouldZoomVideo("auto", 1920, 1080, 1f))
|
||||
assertFalse(shouldZoomVideo("auto", 1920, 800, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual modes are deterministic`() {
|
||||
assertFalse(shouldZoomVideo("original", 640, 480, 1f))
|
||||
assertTrue(shouldZoomVideo("fill", 1920, 1080, 1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackRecoveryTest {
|
||||
@Test
|
||||
fun networkFailuresAreSafeToRetry() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
)
|
||||
|
||||
assertEquals("Connection interrupted", failure.title)
|
||||
assertTrue(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderFailuresRequireViewerAction() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
)
|
||||
|
||||
assertEquals("Video format not supported", failure.title)
|
||||
assertFalse(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun automaticRetriesAreBoundedAndBackOff() {
|
||||
assertEquals(1_000L, automaticRetryDelayMs(1))
|
||||
assertEquals(3_000L, automaticRetryDelayMs(2))
|
||||
assertNull(automaticRetryDelayMs(3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PlayerTimingTest {
|
||||
@Test
|
||||
fun formatsRemainingTimeForViewerFriendlyDisplay() {
|
||||
assertEquals("Less than a minute remaining", PlayerActivity.formatRemaining(30_000L))
|
||||
assertEquals("42 min remaining", PlayerActivity.formatRemaining(42L * 60_000L))
|
||||
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
|
||||
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class PrerollLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `player preroll and its background inflate`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_preroll,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
)
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_cast_overlay,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PrerollSequenceTest {
|
||||
@Test
|
||||
fun `handoff waits only for the five second gate`() {
|
||||
assertFalse(prerollCanHandOff(true, false, true))
|
||||
assertTrue(prerollCanHandOff(true, true, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handoff never starts playback in background`() {
|
||||
assertFalse(prerollCanHandOff(true, true, false))
|
||||
assertFalse(prerollCanHandOff(false, true, true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
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 SearchRankingTest {
|
||||
|
||||
private fun item(name: String, series: String? = null, id: String = name) =
|
||||
BaseItem(id = id, name = name, seriesName = series)
|
||||
|
||||
private fun names(items: List<BaseItem>) = items.map { it.name }
|
||||
|
||||
@Test
|
||||
fun `an exact title beats a title that merely starts with the query`() {
|
||||
val ranked = rankSearchResults(
|
||||
"dune",
|
||||
listOf(item("Dune: Part Two"), item("Dunes of Mars"), item("Dune")),
|
||||
)
|
||||
assertEquals("Dune", ranked.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `titles starting with the query come before mid-word matches`() {
|
||||
val ranked = rankSearchResults(
|
||||
"star",
|
||||
listOf(item("Costar"), item("Lone Star"), item("Stargate")),
|
||||
)
|
||||
// Prefix first, then the word-boundary match, then the buried one.
|
||||
assertEquals(listOf("Stargate", "Lone Star", "Costar"), names(ranked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode is found by its series name when its own title does not match`() {
|
||||
val ranked = rankSearchResults(
|
||||
"severance",
|
||||
listOf(item("Defiant Jazz", series = "Severance"), item("Severed Ties")),
|
||||
)
|
||||
assertEquals("Defiant Jazz", ranked.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weaker matches are kept rather than dropped`() {
|
||||
// A genre or overview match the backend returned: last, but still there, because
|
||||
// "no exact match but here is something related" beats an empty pane.
|
||||
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title")))
|
||||
assertEquals(1, ranked.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ranking is stable so the backend's own relevance order survives within a tier`() {
|
||||
val backendOrder = listOf(item("Alien 3"), item("Alien"), item("Aliens"))
|
||||
val ranked = rankSearchResults("alien", backendOrder)
|
||||
// "Alien" is the exact match and is lifted; the other two keep the order the
|
||||
// server chose rather than being re-sorted alphabetically.
|
||||
assertEquals(listOf("Alien", "Alien 3", "Aliens"), names(ranked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty query leaves the list untouched`() {
|
||||
val items = listOf(item("B"), item("A"))
|
||||
assertEquals(items, rankSearchResults(" ", items))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searching starts at two characters`() {
|
||||
assertFalse(shouldSearch(""))
|
||||
assertFalse(shouldSearch("a"))
|
||||
assertFalse(shouldSearch(" a "))
|
||||
assertTrue(shouldSearch("ab"))
|
||||
assertTrue(shouldSearch(" the wire "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
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 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
|
||||
|
||||
/**
|
||||
* Captures the real stateless Settings panel at TV size. The fixture deliberately mixes
|
||||
* enabled and disabled values so the screenshot proves state is legible without focus;
|
||||
* the first row also owns focus to prove the white focus ring and green active state are
|
||||
* visually distinct.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SettingsSheetScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `active inactive and focused controls`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = false) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-panel.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home overlay width`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = true) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPreviewFixture(overlay: Boolean) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstFocus.requestFocus() }
|
||||
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
|
||||
SettingsPanelContent(
|
||||
state = SettingsPanelState(
|
||||
showLogo = true,
|
||||
autoPlayNext = false,
|
||||
ringColor = "52B54B",
|
||||
homeSections = setOf("continue", "latest"),
|
||||
cardDensity = "standard",
|
||||
showCardMetadata = false,
|
||||
editableServer = true,
|
||||
baseUrl = "https://mserver.example/releases/latest.json",
|
||||
installedVersion = "0.1.60",
|
||||
),
|
||||
actions = SettingsPanelActions(),
|
||||
overlay = overlay,
|
||||
firstFocusRequester = firstFocus,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user