Integrate real Conductarr TV title and recovery providers

This commit is contained in:
justinzeus 2026-11-06 00:10:27 +01:00
commit 78322f588a
550 changed files with 5029 additions and 293 deletions

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ConductarrHomeSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun currentIdentityFeedsRealHomeAndSavedRequestsWithoutInventingJellyfinPlayback() {
FakeConductarr().use { fake ->
fake.localArt = true
ConductarrSession(MemoryCacheStore()).use { session ->
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fake", fake.url, null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val home = mockk<HomeReadFacade>()
coEvery { home.read(user.id) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
try {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
rule.setContent { WolfskinTheme {
LaunchedEffect(model) { model.loadLibrary(server, user, "shared") }
val snapshot = model.snapshot
LaunchedEffect(snapshot.rows) {
val index = snapshot.rows.indexOfFirst { it.catalogue && it.cards.any { card -> card.title == "Moon" } }
if (index >= 0) { shell.view.row = index; shell.view.home.focusedKey = snapshot.rows[index].id; shell.navFocused = false }
}
CompositionLocalProvider(LocalScreenRatings provides ConductarrRatings(session.identity.value!!.client)) {
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
}
} }
rule.waitUntil(10_000) { model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } }
rule.onAllNodesWithText("Moon")[0].assertIsDisplayed()
assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held })
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "conductarr-home-real.png").absolutePath)
val provider = session.identity.value!!.provider
runBlocking { provider.requests().last() }
fake.status = 503
val saved = runBlocking { provider.requests().last() }
assertNotNull(saved.savedAt)
assertEquals(ComingInStage.Downloading, saved.items.single().stage)
assertEquals("Shared", saved.items.single().actor(shell.person))
rule.runOnIdle { shell.requests.update(saved); shell.openRequests() }
rule.onAllNodesWithText("Moon")[0].assertIsDisplayed()
rule.onRoot().captureRoboImage(File(directory, "conductarr-requests-saved.png").absolutePath)
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -82,7 +82,8 @@ class ConductarrProjectionTest {
val ratings = ConductarrRatings(client)
assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source })
val episode = ratings.read(44, "episode", 1, 1)!!
assertEquals(listOf("rtCritics", "rtAudience", "episode"), episode.map { it.source })
assertEquals(listOf("rtCritics", "rtAudience", "tmdb"), episode.map { it.source })
assertTrue(episode.last().episode)
assertEquals(8.1, episode.last().score, 0.0)
ratings.read(44, "episode", 1, 2)
assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") })
@ -95,13 +96,9 @@ class ConductarrProjectionTest {
assertEquals(title.savedAtMillis, revalidated.savedAtMillis)
assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"])
fake.notModified = false
assertEquals("Sam Rockwell", (provider.cast(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().name)
assertEquals(28L, (provider.season(44, 1).last() as ReadResult.Value).saved.value!!.episodes!!.single().voteCount)
assertEquals("Sam Rockwell", (provider.performer(216).last() as ReadResult.Value).saved.value!!.name)
assertEquals("Sam Bell", (provider.filmography(216).last() as ReadResult.Value).saved.value!!.items!!.single().credits!!.single().character)
assertEquals("Arrival", (provider.related(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().title)
assertEquals("Moon", (provider.display(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.title)
assertEquals("mindel", (provider.activity(42, MediaType.MOVIE, "mindel").last() as ReadResult.Value).saved.value!!.viewer.key)
fake.ratingsStatus = 401
assertEquals(emptyList<LibraryRating>(), ratings.read(45, "movie", null, null))
assertEquals(emptyList<LibraryRating>(), ratings.read(42, "movie", null, null))
}
}
}

View file

@ -0,0 +1,88 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.conductarr.model.Rating
import org.justinvisser.wolfskin.design.*
class ConductarrTitlePagesTest {
@Test fun catalogueEvidenceNeverGrantsPlaybackAndOfflinePagesKeepTheirLastKnownGoodFacts() = runBlocking {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
session.authenticate()
val identity = session.identity.value!!
identity.provider.audiences()
val pages = ConductarrTitlePages(identity)
val live = pages.title(42, MediaType.MOVIE).last()
val state = TitleState(live, ShellPerson("shared", "Shared", "S", true)) {}
assertEquals(97 * 60L, live.item.runtimeSeconds)
assertEquals(listOf("Comedy"), live.item.genres)
assertEquals("216", live.cast.single().id)
assertEquals("Sam Bell", live.cast.single().character)
assertEquals("movie:43", live.related.single().id)
assertFalse(live.item.held)
assertNull(live.playTarget)
assertFalse(state.options.any { it.id in listOf("play", "watched", "version") })
assertTrue(state.options.any { it.id == "request" })
val actor = pages.person(216).last()
assertEquals("Actor.", actor.person!!.biography)
assertEquals("Sam Bell", actor.credits.single().character)
assertFalse(actor.credits.single().held)
assertFalse(actor.hasMore)
fake.status = 503
val saved = pages.title(42, MediaType.MOVIE).last()
assertEquals(live.item, saved.item)
assertEquals(live.cast, saved.cast)
assertEquals(TitleConnection.Offline, saved.connection)
assertNotNull(saved.savedAt)
assertFalse(TitleState(saved, state.person) {}.options.any { it.id == "request" })
}
}
}
@Test fun onlyResolvedJellyfinEpisodesArePlayableAndTheirObservedVotesReplaceSeriesScores() = runBlocking {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
session.authenticate()
fake.seasonTwoScore = 7.2
val episode = TitleItem("00000000-0000-0000-0000-000000000012", "Episode", "episode", held = true, season = 1, episode = 1)
val held = TitlePageData(TitleItem("00000000-0000-0000-0000-000000000011", "Dark", "tv", held = true, seasons = 2),
playTarget = episode, watchTarget = episode, episodes = listOf(episode.copy(id = "00000000-0000-0000-0000-000000000013", season = 2)), selectedSeason = 2)
val page = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last()
assertEquals(episode.id, page.playTarget!!.id)
assertEquals(episode.id, page.watchTarget.id)
assertEquals(held.item.id, page.item.id)
assertEquals(44L, page.episodes.single().tmdbId)
assertTrue(page.episodes.single().ratings.last().episode)
assertEquals(8.1, page.playTarget!!.ratings.last().score, 0.0)
assertEquals(7.2, page.episodes.single().ratings.last().score, 0.0)
assertFalse(page.item.ratings.any { it.episode })
fake.status = 401
val revoked = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last()
assertFalse(session.identity.value!!.signedIn)
assertEquals(TitleConnection.SignedOut, revoked.connection)
assertTrue(revoked.item.ratings.isEmpty())
assertTrue(revoked.playTarget!!.ratings.isEmpty())
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
}
}
}
@Test fun ratingsUseObservedLabelsPrecedenceAndPositiveVoteCountsWithoutDiscardingRealZeroes() {
fun ratings(json: String) = Json.decodeFromString<List<Rating>>(json).screenRatings("tv")
assertEquals(listOf("rtCritics"), ratings("""[{"source":"rt_critics","state":"observed","score":0},{"source":"tmdb","state":"observed","score":9,"votes":20}]""").map { it.source })
assertNull(ratings("""[{"source":"rt_critics","state":"observed","score":99,"status":"unknown"}]""").single().label)
assertTrue(ratings("""[{"source":"tmdb","state":"observed","score":7,"votes":0},{"source":"imdb","state":"observed","score":8,"votes":10}]""").isEmpty())
assertEquals(0.0, ratings("""[{"source":"tmdb","state":"observed","score":0,"votes":1}]""").single().score, 0.0)
assertTrue(emptyList<LibraryRating>().withEpisode(0.0, 1).single().episode)
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty())
}
}

View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import java.io.File
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.*
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ConductarrTitleSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun realTitleOffersNoFakePlaybackAndSharedRequestForMindelHasARealReceipt() {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
try {
val route = titleRoute("movie:42")
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination(route)
}
val art = mutableMapOf<String, ImageBitmap>()
fun decode(element: JsonElement) {
when (element) {
is JsonObject -> element.values.forEach(::decode)
is JsonArray -> element.forEach(::decode)
is JsonPrimitive -> element.contentOrNull?.takeIf { it.startsWith("http") || it.startsWith('/') }?.let { art[catalogueImage(it)!!] = ImageBitmap(1, 1) }
}
}
decode(fake.wire)
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false)
} } }
rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedIn }
rule.onNodeWithText("Moon").assertIsDisplayed()
assertFalse(viewModel.title!!.options.any { it.id == "play" })
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
rule.waitUntil(10_000) { viewModel.title?.data?.requestActor == "Shared" }
assertEquals("Mindel", viewModel.title!!.data.requestFor)
rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed()
val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" }
val body = Json.parseToJsonElement(post.body!!.utf8()).jsonObject
assertEquals("mindel", body.getValue("requester").jsonPrimitive.content)
assertFalse(body.containsKey("actor"))
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
fake.ratingsStatus = 401
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable }
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
} finally { viewModel.viewModelScope.cancel() }
}
}
}
}

View file

@ -22,12 +22,18 @@ internal class FakeConductarr : Closeable {
@Volatile var existingRequests = false
@Volatile var notModified = false
@Volatile var ratingsStatus = 200
@Volatile var seasonTwoScore = 8.1
@Volatile var localArt = false
private val imageBytes = java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
calls.add(request)
if (status != 200) return response("", status)
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
.body(okio.Buffer().write(imageBytes)).build()
val path = request.url.encodedPath
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
if (request.method == "POST") return response(wire.getValue("hide").toString())
@ -66,13 +72,24 @@ internal class FakeConductarr : Closeable {
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
}
if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2)))
return response(body.toString())
if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map {
JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore)))
})))
return response(if (localArt) localizeImages(body).toString() else body.toString())
}
}
server.start()
}
val url: String get() = server.url("/").toString()
private fun localizeImages(element: JsonElement): JsonElement = when (element) {
is JsonObject -> JsonObject(element.mapValues { (key, value) ->
if (key in setOf("poster_url", "poster_path", "backdrop_url", "backdrop_path", "profile_url") && value is JsonPrimitive && value.contentOrNull != null)
JsonPrimitive(server.url("/image").toString()) else localizeImages(value)
})
is JsonArray -> JsonArray(element.map(::localizeImages))
else -> element
}
private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build()
override fun close() = server.close()
}

View file

@ -54,4 +54,17 @@ class ShellRoutingTest {
assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries))
}
}
@Test fun titleRolloutOwnsOnlyFilmSeriesEpisodeAndPerson() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000001")
for (kind in listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)) {
val destination = Destination.MediaItem(id, kind)
assertNull(resolveUpstreamDestination(destination, true, libraries))
assertEquals(destination, resolveUpstreamDestination(destination, false, libraries))
}
for (kind in listOf(BaseItemKind.COLLECTION_FOLDER, BaseItemKind.AUDIO, BaseItemKind.MUSIC_ALBUM, BaseItemKind.BOX_SET)) {
val destination = Destination.MediaItem(id, kind)
assertEquals(destination, resolveUpstreamDestination(destination, true, libraries))
}
}
}

View file

@ -0,0 +1,165 @@
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.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.RecoveryMessage
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleReadStatus
import org.justinvisser.wolfskin.design.TitleSheet
import org.justinvisser.wolfskin.design.TitleTrack
import org.justinvisser.wolfskin.design.TitleVersion
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleAsyncFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel
@After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
}
@Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000012")
val ready = CompletableDeferred<TitlePageData>()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply {
pickerVisible = false
pickerLaunch = false
enterDestination(route)
}
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle()
assertTrue(shell.navFocused)
press(Key.DirectionDown)
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
rule.runOnIdle {
ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate"))))
}
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused }
press(Key.DirectionRight)
val state = requireNotNull(viewModel.title)
assertEquals("version", state.options[state.column].id)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Version, state.sheet)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertNull(state.sheet)
press(Key.DirectionRight)
assertEquals("tracks", state.options[state.column].id)
assertFalse(shell.navFocused)
}
@Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000013")
val reply = CompletableDeferred<org.justinvisser.wolfskin.tv.read.PersonRead>()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = personRoute(id.toString())
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false
pickerLaunch = false
enterDestination(route)
}
rule.setContent { WolfskinTheme {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel)
} }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle()
assertTrue(shell.navFocused)
press(Key.DirectionDown)
assertTrue(shell.navFocused)
rule.runOnIdle {
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")),
))
}
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
rule.waitForIdle()
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
assertEquals(0, viewModel.person!!.index)
press(Key.DirectionRight)
assertEquals(1, viewModel.person!!.index)
assertFalse(shell.navFocused)
}
@Test fun failedRetryKeepsThePersonsSavedTitleAndShowsSharedRecovery() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000014")
val reads = java.util.concurrent.atomic.AtomicInteger()
val saved = TitlePageData(TitleItem(id.toString(), "Saved title", "movie"),
connection = TitleConnection.Offline, savedAt = "20:42")
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns saved
coEvery { facade.read(id, any(), any()) } coAnswers {
reads.incrementAndGet()
throw java.io.IOException("offline")
}
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false; pickerLaunch = false; enterDestination(route)
}
rule.setContent { WolfskinTheme {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel)
} }
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 1 && viewModel.title?.toast != null }
rule.waitForIdle()
assertEquals("20:42", shell.navSavedAt)
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
rule.runOnIdle { viewModel.title!!.toast = null }
press(Key.DirectionCenter)
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
rule.waitForIdle()
assertEquals("Saved title", viewModel.title!!.data.item.name)
assertEquals("20:42", shell.navSavedAt)
assertEquals(RecoveryMessage.StillUnreachable, viewModel.title!!.toast)
assertFalse(shell.navFocused)
rule.onNodeWithTag("recovery-toast").assertIsDisplayed()
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
}

View file

@ -0,0 +1,218 @@
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 androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.graphics.toArgb
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
import org.junit.After
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
private var previousLocale = Locale.getDefault()
@Before fun fullscreen() {
previousLocale = Locale.getDefault()
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()) })
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
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 ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.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(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
val heading = rule.onAllNodesWithTag("title-heading").fetchSemanticsNodes().singleOrNull()
if (heading != null) {
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithTag("title-heading").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
val metrics = JSONObject().put("title", layout.layoutInput.text.text).put("lineCount", layout.lineCount)
.put("lineWidthsPx", org.json.JSONArray(List(layout.lineCount) { layout.getLineRight(it) - layout.getLineLeft(it) }))
.put("ellipsized", org.json.JSONArray(List(layout.lineCount) { layout.isLineEllipsized(it) }))
.put("maxWidthPx", layout.layoutInput.constraints.maxWidth).put("fontSizeSp", layout.layoutInput.style.fontSize.value)
.put("requestedWeight", layout.layoutInput.style.fontWeight?.weight)
.put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom)))
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
}
recordTextMetrics(directory, number)
recordElements(directory, number)
}
private fun recordTextMetrics(directory: File, number: Int) {
val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true)
val records = org.json.JSONArray()
elements.fetchSemanticsNodes().forEachIndexed { index, node ->
if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) {
val layouts = mutableListOf<TextLayoutResult>()
elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
records.put(JSONObject().put("text", layout.layoutInput.text.text)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value)
.put("weight", layout.layoutInput.style.fontWeight?.weight)
.put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16))
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
.put("lineCount", layout.lineCount)
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))
.put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span ->
JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight)
})))
}
}
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
}
private fun recordElements(directory: File, number: Int) {
val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag
val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription
val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description)
val records = org.json.JSONArray()
rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
records.put(JSONObject()
.put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL)
.put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL)
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))))
}
File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2))
}
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, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> 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, shell ->
state.focus("cast", 0)
state.key(ShellKey.Right, true, 0, shell)
state.key(ShellKey.Right, false, 0, shell)
}
@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, savedAt = "20:42", item = it.item.copy(ratings = emptyList()))
})
@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,345 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
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 androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens
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
import org.robolectric.annotation.GraphicsMode
/** 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 {
BackHandler { state.back(shell) }
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 })
}
@Test fun episodeWatchedToggleKeepsItsIdentityAndDoesNotAskAboutTheSeries() {
val episode = requireNotNull(fixture.title("Andor", "justin").playTarget)
render(episode.id)
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) }
press(Key.DirectionCenter)
assertNull(state.sheet)
val change = effects.single() as TitleAction.Watched
assertEquals(episode.id, change.item.id)
assertEquals(!episode.watched, change.watched)
}
@Test fun aPendingSeasonSkipsItsEpisodeSkeletons() {
render("Andor")
rule.runOnIdle { state.showSeasonLoading() }
press(Key.DirectionDown); press(Key.DirectionDown)
assertEquals("cast", state.row)
rule.runOnIdle { state.restoreSeason() }
press(Key.DirectionUp)
assertEquals("episodes", state.row)
}
@Test fun aFailedOlderVersionWriteCannotUndoTheNewerChoice() {
render()
press(Key.DirectionRight); press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter)
val first = effects.last() as TitleAction.Version
press(Key.DirectionCenter); press(Key.DirectionUp); press(Key.DirectionCenter)
val second = effects.last() as TitleAction.Version
rule.runOnIdle { state.rejectPlayback(first); state.confirmPlayback(second) }
assertEquals("v1", state.versionId)
press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter)
val failed = effects.last() as TitleAction.Version
rule.runOnIdle { state.rejectPlayback(failed) }
assertEquals("v1", state.versionId)
}
@Test fun aNewRowUsesTheNearestHorizontalCardBeforeItsOwnRememberedCard() {
render()
repeat(4) { press(Key.DirectionRight) }
val x = rule.onNodeWithTag("title-option-trailer").fetchSemanticsNode().boundsInRoot.center.x
val cast = state.data.cast.take(WolfskinTokens.Card.personSlots)
val nearest = cast.indices.minBy { index ->
kotlin.math.abs(rule.onNodeWithTag("title-cast-${cast[index].id}").fetchSemanticsNode().boundsInRoot.center.x - x)
}
press(Key.DirectionDown)
assertEquals(nearest, state.column)
press(Key.DirectionRight)
val remembered = state.column
press(Key.DirectionUp); press(Key.DirectionDown)
assertEquals(remembered, state.column)
}
@Test fun wheelThenRemoteLandsOnTheFirstVisibleFocusableRow() {
render()
rule.onNodeWithTag("title-page").performMouseInput { scroll(800f) }
val expected = rule.runOnIdle {
shell.view.listState.layoutInfo.visibleItemsInfo.first {
it.key in state.rows && it.offset + it.size > WolfskinTokens.Layout.pinnedLine.value * 2
}.key as String
}
assertNotEquals("header", expected)
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown)
assertTrue(shell.navFocused)
assertEquals("header", state.row)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects)
}
@Test fun unratedFactsDoNotReserveAnEmptyRatingsSlot() {
render("The Amazing Spider-Man", transform = {
it.copy(item = it.item.copy(ratings = emptyList(), genres = listOf("Drama"), runtimeSeconds = 0),
cast = emptyList(), related = emptyList())
})
val year = rule.onNodeWithText("2012", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun filmHeaderKeepsItsFixedCssStack() {
render()
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
render("Andor")
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
render(transform = { data ->
val source = data.versions.first().copy(
audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") },
subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") },
)
data.copy(versions = listOf(source))
})
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) }
press(Key.DirectionCenter)
val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom
val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done"
labels.forEachIndexed { index, label ->
if (index > 0) press(Key.DirectionDown)
rule.waitForIdle()
val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertTrue("$label must have visible height: $bounds", bounds.height > 20)
assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom)
assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0)
}
press(Key.DirectionCenter)
assertNull(state.sheet)
assertFalse(shell.navFocused)
}
}

View file

@ -0,0 +1,132 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Full launch paths render the real picker, Home hero/shelves, title and person surfaces. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleLaunchKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture
private lateinit var home: HomeFixture
private lateinit var shell: ShellState
private var title by mutableStateOf<TitleState?>(null)
private var person by mutableStateOf<PersonState?>(null)
private val actions = mutableListOf<TitleAction>()
private val shellActions = mutableListOf<ShellAction>()
private lateinit var paths: List<JSONObject>
@Before fun launch() {
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
home = HomeFixture(rule.activity)
val source = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
paths = List(source.length()) { source.getJSONObject(it) }
shell = ShellState(fixture.people, "justin") { action ->
shellActions.add(action)
if (action is ShellAction.Person) { title = null; person = null }
}
rule.setContent { WolfskinTheme {
val performer = person
val page = title
val snapshot = remember(shell.person.key) { home.snapshot(shell.person.key) }
when {
performer != null -> PersonSurface(performer, shell)
page != null -> TitleSurface(page, shell)
else -> ShellSurface(shell) { modifier ->
HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details) openTitle(action.title.id)
}
}
}
} }
}
private fun openTitle(id: String) {
val data = fixture.title(id, shell.person.key)
title = TitleState(data, shell.person, ::act)
person = null
shell.enterDestination(titleRoute(id))
}
private fun act(action: TitleAction) {
actions.add(action)
when (action) {
is TitleAction.Person -> {
val (actor, credits) = fixture.person(action.person.id, shell.person.key)
person = PersonState(actor, credits, ::act)
shell.enterDestination(personRoute(actor.id))
}
is TitleAction.OpenTitle -> openTitle(action.item.id)
else -> Unit
}
}
private fun replay(end: String, expectedPresses: Int) {
val path = paths.single { it.getString("end") == end }
val keys = path.getJSONArray("keys")
assertEquals(expectedPresses, path.getInt("presses"))
assertEquals(expectedPresses, keys.length())
for (index in 0 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 launch replay key")
}
rule.onRoot().performKeyInput { pressKey(key) }
}
}
@Test fun titleToNavTakesFourPressesFromTheRealHomeHero() {
replay("nav", 4)
assertNotNull(title)
assertTrue(shell.navFocused)
assertEquals(shell.currentTab, shell.navIndex)
}
@Test fun switchingPersonFromATitleTakesEightLaunchPresses() {
replay("person-mindel", 8)
assertEquals("mindel", shell.person.key)
assertFalse(shell.pickerVisible)
assertEquals("home", shell.route)
assertTrue(shellActions.last() is ShellAction.Person)
}
@Test fun lockeVersionTwoAndPlayTakesFifteenLaunchPresses() {
replay("play-version-2", 15)
val page = requireNotNull(title)
assertEquals("Locke", page.data.item.name)
assertEquals("v2", page.versionId)
assertNull(page.sheet)
assertEquals("play", page.options[page.column].id)
assertEquals("Locke", (actions.last() as TitleAction.Play).item.name)
}
@Test fun lockeToTomHardyTakesElevenLaunchPresses() {
replay("person-Tom Hardy", 11)
assertEquals("Tom Hardy", requireNotNull(person).person.name)
assertEquals(0, person!!.index)
assertFalse(shell.navFocused)
assertTrue(actions.last() is TitleAction.Person)
}
}

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.LibraryRating
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(LibraryRating("tmdb", 7.8)))
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, useUnmergedTree = true).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,215 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import com.github.damontecres.wholphin.WholphinApplication
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.data.model.JellyfinServer
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.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
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 com.github.damontecres.wholphin.test.TestTracks
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.junit.Assert.*
import org.junit.After
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 org.robolectric.RuntimeEnvironment
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().replace("-", "")
@Before fun setup() {
// BaseItem formats runtime using application resources; no Hilt/network application is started.
val app = mockk<WholphinApplication> {
every { resources } returns RuntimeEnvironment.getApplication().resources
}
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns app
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>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
@After fun teardown() {
unmockkObject(WholphinApplication.Companion)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto {
val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo()
val streams = source.mediaStreams.orEmpty().map { stream ->
when (stream.type) {
MediaStreamType.VIDEO -> stream.copy(index = 0, displayTitle = "1080p")
MediaStreamType.AUDIO -> stream.copy(index = 3, displayTitle = "English", isDefault = true)
MediaStreamType.SUBTITLE -> stream.copy(index = 8, displayTitle = "Dutch")
else -> error("Unexpected track in the SDK fixture builder")
}
}
return BaseItemDto(id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000,
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
}
@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", 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 personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest {
val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography")
coEvery { library.getItem(firstId) } returns successResponse(actor)
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId)))
facade.readPerson(firstId, 0)
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
coEvery { items.getItems(any<GetItemsRequest>()) } throws java.io.IOException("offline")
val saved = facade.readPersonSaved(firstId)!!
assertEquals("Actual biography", saved.person.biography)
assertEquals(secondId.toString(), saved.credits.single().id)
assertEquals("A real file", saved.credits.single().name)
}
@Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest {
val userA = mockk<JellyfinUser> { every { id } returns firstId }
val userB = mockk<JellyfinUser> { every { id } returns secondId }
val serverA = mockk<JellyfinServer> { every { id } returns seriesId }
val serverB = mockk<JellyfinServer> { every { id } returns seasonId }
every { repository.currentUser } returns userA
every { repository.currentServer } returns serverA
coEvery { library.getItem(firstId) } returns successResponse(
BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Saved actor", overview = "Private biography"))
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId)))
facade.readPerson(firstId, 0)
every { repository.currentUser } returns userB
assertNull(facade.readPersonSaved(firstId))
every { repository.currentUser } returns userA
every { repository.currentServer } returns serverB
assertNull(facade.readPersonSaved(firstId))
every { repository.currentServer } returns serverA
val saved = requireNotNull(facade.readPersonSaved(firstId))
assertEquals("Private biography", saved.person.biography)
assertEquals(secondId.toString(), saved.credits.single().id)
}
@Test fun cataloguePlaybackLookupRejectsAnotherMediaTypeOrTmdbIdentity() = runTest {
var payload = """{"Items":[{"Id":"$firstId","Type":"Series","ProviderIds":{"Tmdb":"42"}}]}"""
coEvery { api.request(any(), any(), any(), any(), any()) } coAnswers {
org.jellyfin.sdk.api.client.RawResponse(org.json.JSONObject(payload).put("TotalRecordCount", 1).put("StartIndex", 0).toString().toByteArray(), 200, emptyMap())
}
assertNull(facade.findHeld(42, "movie"))
payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"43"}}]}"""
assertNull(facade.findHeld(42, "movie"))
payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"42"}}]}"""
assertEquals(firstId, facade.findHeld(42, "movie"))
}
@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)
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.savePlayVersion(any(), any()) } returns null
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
}
@Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns
com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId)
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1))
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) }
coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) }
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8))
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) }
}
}

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"
}
]