Build native title and person surfaces

This commit is contained in:
justinzeus 2026-11-05 16:16:02 +01:00
parent 9be55c570a
commit 0db05174b1
535 changed files with 3088 additions and 17 deletions

View file

@ -0,0 +1,147 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.LocalTitleBitmaps
import org.justinvisser.wolfskin.design.PersonState
import org.justinvisser.wolfskin.design.PersonSurface
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleReadStatus
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import java.util.Locale
/** Frozen local art feeds the shipped Compose surface; no test can call a live server. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture
@Before fun fullscreen() {
Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
}
private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) {
val data = transform(fixture.title(title, person))
val shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") }
val state = TitleState(data, shell.person) {}
configure(state, shell)
val bitmaps = decodeArt(data)
rule.setContent {
WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
} }
}
if (number == 55) rule.runOnIdle {
shell.view.listState.requestScrollToItem(1, -(WolfskinTokens.Layout.pinnedLine.value * 2).toInt())
}
save(number)
}
private fun decodeArt(data: TitlePageData): Map<String, androidx.compose.ui.graphics.ImageBitmap> {
val requests = buildList<Triple<String, Int, Int>> {
data.item.backdrop?.let { add(Triple(it, 1280, 720)) }
if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) }
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> (item.backdrop ?: item.poster)?.let { add(Triple(it, 416, 234)) } }
}
return requests.distinctBy { it.first }.associate { (path, width, height) ->
val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! }
val scale = maxOf(width.toFloat() / bitmap.width, height.toFloat() / bitmap.height).coerceAtMost(1f)
val scaled = Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt(), (bitmap.height * scale).toInt(), true)
if (scaled !== bitmap) bitmap.recycle()
path to scaled.asImageBitmap()
}
}
private fun save(number: Int) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
}
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
private fun open(state: TitleState, shell: ShellState, id: String) {
option(state, id)
state.key(ShellKey.Ok, true, 0, shell)
state.key(ShellKey.Ok, false, 0, shell)
}
@Test fun st042_heldMovie() = capture(42, "The Girl Who Leapt Through Time")
@Test fun st043_options() = capture(43) { state, _ -> option(state, "version") }
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
@Test fun st049_watched() = capture(49) { state, _ -> state.restoreWatched(true); option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") }
@Test fun st052_filmCast() = capture(52) { state, _ -> state.focus("cast", 0) }
@Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) }
@Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) }
@Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
@Test fun st056_castUnavailable() = capture(56, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Unavailable) }) { state, _ -> state.focus("related", 0) }
@Test fun st057_person() {
val (person, credits) = fixture.person("Tom Holland", "justin")
val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; enterDestination("person/Tom Holland") }
val state = PersonState(person, credits, {})
val data = TitlePageData(credits.first(), cast = listOf(person), related = credits)
val art = decodeArt(data)
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) {
PersonSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
} } }
save(57)
}
@Test fun st058_hiddenReceipt() = capture(58, "The Amazing Spider-Man") { state, shell -> open(state, shell, "hide") }
@Test fun st059_hiddenPick() = capture(59, "The Amazing Spider-Man", transform = { it.copy(hidden = true) }) { state, _ -> option(state, "unhide") }
@Test fun st060_titleNav() = capture(60) { _, shell -> shell.focusNav(0) }
@Test fun st061_loading() = capture(61, transform = { it.copy(status = TitleReadStatus.Loading) }) { _, shell -> shell.focusNav(0) }
@Test fun st062_error() = capture(62, transform = { it.copy(status = TitleReadStatus.Unavailable) })
@Test fun st063_signedOut() = capture(63, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.SignedOut) })
@Test fun st064_seriesNext() = capture(64, "Andor")
@Test fun st065_resumeEpisode() = capture(65, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st066_notHeld() = capture(66, "The Amazing Spider-Man")
@Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man")
@Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") })
@Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.Offline) })
@Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) }
@Test fun st106_heldRatings() = capture(106, "American Gangster", "shared")
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
}

View file

@ -0,0 +1,187 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleAction
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleReadStatus
import org.justinvisser.wolfskin.design.TitleSheet
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture
private val effects = mutableListOf<TitleAction>()
private val shellEffects = mutableListOf<ShellAction>()
private lateinit var state: TitleState
private lateinit var shell: ShellState
private lateinit var paths: List<JSONObject>
@Before fun fixtures() {
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val data = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
paths = List(data.length()) { data.getJSONObject(it) }
}
private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) {
shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) }
state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
private fun replay(end: String): Int {
val path = paths.single { it.getString("end") == end }
render(path.getString("title"))
val keys = path.getJSONArray("keys")
var presses = 0
for (index in path.getInt("entryPresses") until keys.length()) {
val key = when (keys.getString(index)) {
"UP" -> Key.DirectionUp
"DOWN" -> Key.DirectionDown
"LEFT" -> Key.DirectionLeft
"RIGHT" -> Key.DirectionRight
"OK" -> Key.DirectionCenter
else -> error("Unknown replay key")
}
press(key)
presses++
}
return presses
}
@Test fun titleToNavOnePressFromEntry() {
assertEquals(1, replay("nav"))
assertTrue(shell.navFocused)
assertEquals(shell.currentTab, shell.navIndex)
}
@Test fun switchPersonFivePressesFromTitleEntry() {
assertEquals(5, replay("person-mindel"))
assertEquals("mindel", shell.person.key)
assertEquals("home", shell.route)
assertFalse(shell.pickerVisible)
assertTrue(shellEffects.last() is ShellAction.Person)
}
@Test fun lockeSecondVersionSixPressesFromTitleEntry() {
assertEquals(6, replay("play-version-2"))
assertEquals("v2", state.versionId)
assertNull(state.sheet)
assertEquals("play", state.options[state.column].id)
assertTrue(effects.last() is TitleAction.Play)
assertEquals("Locke", (effects.last() as TitleAction.Play).item.name)
}
@Test fun lockeCastTwoPressesFromTitleEntry() {
assertEquals(2, replay("person-Tom Hardy"))
assertEquals("cast", state.row)
assertEquals("Tom Hardy", (effects.single() as TitleAction.Person).person.name)
}
@Test fun sheetBackRestoresItsOpeningOptionAndChosenVersion() {
render()
press(Key.DirectionRight)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Version, state.sheet)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertEquals("v2", state.versionId)
assertEquals("Version", state.options[state.column].label)
press(Key.DirectionCenter)
assertEquals(1, state.sheetIndex)
press(Key.Back)
assertNull(state.sheet)
assertEquals("Version", state.options[state.column].label)
assertFalse(shell.navFocused)
}
@Test fun trackChoiceStaysInSheetAndOffRemainsAnExplicitChoice() {
render()
press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter)
assertEquals(TitleSheet.Tracks, state.sheet)
press(Key.DirectionDown); press(Key.DirectionCenter)
assertEquals(1, state.audioIndex)
assertEquals(TitleSheet.Tracks, state.sheet)
press(Key.DirectionDown); press(Key.DirectionDown); press(Key.DirectionCenter)
assertEquals(2, state.subtitleIndex)
press(Key.DirectionUp); press(Key.DirectionCenter)
assertEquals(-1, state.subtitleIndex)
press(Key.Back)
assertNull(state.sheet)
assertEquals("tracks", state.options[state.column].id)
}
@Test fun castLoadingAndUnavailableAreNeverFocusable() {
render(transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
press(Key.DirectionDown)
assertEquals("related", state.row)
assertFalse(state.rows.contains("cast"))
}
@Test fun wholeSeriesRequiresConfirmationButEpisodesDoNot() {
render("Andor")
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) }
press(Key.DirectionCenter)
assertEquals(TitleSheet.ConfirmWatched, state.sheet)
assertFalse(state.watched)
press(Key.DirectionRight); press(Key.DirectionCenter)
assertFalse(state.watched)
assertTrue(effects.none { it is TitleAction.Watched })
press(Key.DirectionCenter); press(Key.DirectionCenter)
assertTrue(state.watched)
assertTrue(effects.last() is TitleAction.Watched)
}
@Test fun titlesNobodyHoldsHaveNoTrailerUntilTheCatalogueFieldExists() {
render("The Amazing Spider-Man")
assertTrue(state.options.none { it.id == "trailer" })
assertEquals("Request", state.options.first().label)
rule.runOnIdle { state.update(state.data.copy(connection = TitleConnection.SignedOut)) }
assertEquals(listOf("retry"), state.options.map { it.id })
}
@Test fun hiddenReceiptUndoRestoresTitleAndOptionInPlace() {
render("The Amazing Spider-Man")
press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter)
assertTrue(state.data.hidden)
assertEquals(TitleSheet.HiddenReceipt, state.sheet)
press(Key.DirectionCenter)
assertFalse(state.data.hidden)
assertNull(state.sheet)
assertEquals("hide", state.options[state.column].id)
}
@Test fun heldOkCannotChooseTheVersionInTheSheetItOpened() {
render()
press(Key.DirectionRight)
rule.runOnIdle {
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
repeat(5) { index -> rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, index + 1)) }
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
}
assertEquals(TitleSheet.Version, state.sheet)
assertTrue(effects.none { it is TitleAction.Version })
}
}

View file

@ -0,0 +1,57 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.text.TextLayoutResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleRating
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.util.Locale
/** Native text layout proves locale formatting still fits the focused poster's one-line plate. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleLocaleTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private fun render(locale: Locale, expected: String) {
val previous = Locale.getDefault()
try {
Locale.setDefault(locale)
val person = ShellPerson("justin", "Justin", "J", false)
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(TitleRating("tmdb", 7.8, "tmdb")))
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") }
val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {}
state.focus("related", 0)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.mainClock.advanceTimeBy(1000)
val layouts = mutableListOf<TextLayoutResult>()
rule.onAllNodesWithText(expected).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals(1, layouts.single().lineCount)
assertFalse(layouts.single().didOverflowWidth)
} finally { Locale.setDefault(previous) }
}
@Test fun dutchScoreUsesCommaOnTheNativePlate() = render(Locale.forLanguageTag("nl-NL"), "7,8")
@Test fun englishScoreUsesPointOnTheNativePlate() = render(Locale.US, "7.8")
}

View file

@ -0,0 +1,128 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.TvShowsApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class TitleReadFacadeTest {
@get:Rule val directory = TemporaryFolder()
private val library = mockk<UserLibraryApi>()
private val items = mockk<ItemsApi>()
private val shows = mockk<TvShowsApi>()
private val api = mockk<ApiClient>()
private val playback = mockk<ItemPlaybackRepository>()
private val repository = mockk<ServerRepository>()
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private lateinit var facade: TitleReadFacade
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000001")
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004")
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString()
@Before fun setup() {
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
every { repository.currentServer } returns null
every { repository.currentUser } returns null
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(), playback, repository, mockk<FavoriteWatchManager>(), context)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto(
id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000,
mediaSources = listOf(MediaSourceInfo(id = sourceId, name = "Original", container = "mkv", mediaStreams = listOf(
MediaStream(index = 0, type = MediaStreamType.VIDEO, displayTitle = "1080p"),
MediaStream(index = 3, type = MediaStreamType.AUDIO, displayTitle = "English", isDefault = true),
MediaStream(index = 8, type = MediaStreamType.SUBTITLE, displayTitle = "Dutch"),
))),
)
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
facade.read(firstId, null, preferences)
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
val saved = facade.readSaved(firstId, preferences)!!
val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {}
assertEquals("mkv".uppercase(), saved.versions.single().container)
assertEquals(3, screen.audio?.index)
assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index })
assertEquals(TitleConnection.SignedOut, saved.connection)
assertTrue(saved.item.ratings.isEmpty())
assertEquals(firstId.toString(), saved.watchTarget.id)
}
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
val userA = mockk<JellyfinUser> { every { id } returns firstId }
val userB = mockk<JellyfinUser> { every { id } returns secondId }
every { repository.currentUser } returns userA
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
facade.read(firstId, null, preferences)
every { repository.currentUser } returns userB
assertNull(facade.readSaved(firstId, preferences))
every { repository.currentUser } returns userA
assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name)
}
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES)
val secondSeason = BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 2)
coEvery { library.getItem(firstId) } returns successResponse(episode)
coEvery { library.getItem(seriesId) } returns successResponse(series)
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(secondSeason))
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(listOf(other))
val data = facade.read(firstId, 2, preferences)
assertEquals(seriesId.toString(), data.item.id)
assertEquals(firstId.toString(), data.playTarget?.id)
assertEquals(firstId.toString(), data.watchTarget.id)
assertEquals(listOf(secondId.toString()), data.episodes.map { it.id })
assertEquals(listOf(2), data.seasonNumbers)
}
}

View file

@ -0,0 +1,51 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/** Browser handlers must never become a fallback for a missing YouTube app. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class TitleTrailerTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val url = "https://www.youtube.com/watch?v=abcdefghijk"
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser")
assertFalse(TitleTrailer(context).open(url))
assertNull(shadowOf(context as Application).nextStartedActivity)
}
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube")
assertTrue(TitleTrailer(context).open(url))
val launched = shadowOf(context as Application).nextStartedActivity
assertEquals("test.youtube", launched.`package`)
assertEquals("vnd.youtube", launched.data?.scheme)
assertEquals("abcdefghijk", launched.data?.schemeSpecificPart)
}
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser")
assertFalse(TitleTrailer(context).open("https://trailers.example/video"))
assertNull(shadowOf(context as Application).nextStartedActivity)
}
private fun register(intent: Intent, name: String) {
val info = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = name; this.name = "$name.Player"; exported = true }
}
shadowOf(context.packageManager).addResolveInfoForIntent(intent, info)
}
}

View file

@ -0,0 +1,92 @@
[
{
"goal": "Round 3 · title page → nav",
"keys": [
"OK",
"RIGHT",
"OK",
"UP"
],
"presses": 4,
"note": "1 from the title page",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
"entryPresses": 3,
"title": "The Girl Who Leapt Through Time",
"end": "nav"
},
{
"goal": "Round 3 · play Locke with version 2 (sample files)",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"OK",
"RIGHT",
"OK",
"DOWN",
"OK",
"LEFT",
"OK"
],
"presses": 15,
"note": "6 on the title page",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
"entryPresses": 9,
"title": "Locke",
"end": "play-version-2"
},
{
"goal": "Round 3 · Locke → its first cast member’s page",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"OK",
"DOWN",
"OK"
],
"presses": 11,
"note": "2 on the title page: ↓ to Cast, OK",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
"entryPresses": 9,
"title": "Locke",
"end": "person-Tom Hardy"
},
{
"goal": "Round 3 · switch person from a title page",
"keys": [
"OK",
"RIGHT",
"OK",
"UP",
"LEFT",
"OK",
"RIGHT",
"OK"
],
"presses": 8,
"note": "5 from the title page",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
"entryPresses": 3,
"title": "The Girl Who Leapt Through Time",
"end": "person-mindel"
}
]