Integrate real Conductarr TV title and recovery providers
This commit is contained in:
commit
78322f588a
550 changed files with 5029 additions and 293 deletions
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
345
app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt
Normal file
345
app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
92
app/src/test/resources/title-press-paths.json
Normal file
92
app/src/test/resources/title-press-paths.json
Normal 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"
|
||||
}
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue