Build native title and person surfaces
This commit is contained in:
parent
9be55c570a
commit
0db05174b1
535 changed files with 3088 additions and 17 deletions
|
|
@ -0,0 +1,147 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.json.JSONObject
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.LocalTitleBitmaps
|
||||
import org.justinvisser.wolfskin.design.PersonState
|
||||
import org.justinvisser.wolfskin.design.PersonSurface
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleConnection
|
||||
import org.justinvisser.wolfskin.design.TitlePageData
|
||||
import org.justinvisser.wolfskin.design.TitleReadStatus
|
||||
import org.justinvisser.wolfskin.design.TitleState
|
||||
import org.justinvisser.wolfskin.design.TitleSurface
|
||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/** Frozen local art feeds the shipped Compose surface; no test can call a live server. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class TitleCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var fixture: TitleFixture
|
||||
|
||||
@Before fun fullscreen() {
|
||||
Locale.setDefault(Locale.US)
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
}
|
||||
|
||||
private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) {
|
||||
val data = transform(fixture.title(title, person))
|
||||
val shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") }
|
||||
val state = TitleState(data, shell.person) {}
|
||||
configure(state, shell)
|
||||
val bitmaps = decodeArt(data)
|
||||
rule.setContent {
|
||||
WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
|
||||
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||
} }
|
||||
}
|
||||
if (number == 55) rule.runOnIdle {
|
||||
shell.view.listState.requestScrollToItem(1, -(WolfskinTokens.Layout.pinnedLine.value * 2).toInt())
|
||||
}
|
||||
save(number)
|
||||
}
|
||||
|
||||
private fun decodeArt(data: TitlePageData): Map<String, androidx.compose.ui.graphics.ImageBitmap> {
|
||||
val requests = buildList<Triple<String, Int, Int>> {
|
||||
data.item.backdrop?.let { add(Triple(it, 1280, 720)) }
|
||||
if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) }
|
||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||
data.episodes.forEach { item -> (item.backdrop ?: item.poster)?.let { add(Triple(it, 416, 234)) } }
|
||||
}
|
||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||
val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! }
|
||||
val scale = maxOf(width.toFloat() / bitmap.width, height.toFloat() / bitmap.height).coerceAtMost(1f)
|
||||
val scaled = Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt(), (bitmap.height * scale).toInt(), true)
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
path to scaled.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(number: Int) {
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
|
||||
}
|
||||
|
||||
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
|
||||
private fun open(state: TitleState, shell: ShellState, id: String) {
|
||||
option(state, id)
|
||||
state.key(ShellKey.Ok, true, 0, shell)
|
||||
state.key(ShellKey.Ok, false, 0, shell)
|
||||
}
|
||||
|
||||
@Test fun st042_heldMovie() = capture(42, "The Girl Who Leapt Through Time")
|
||||
@Test fun st043_options() = capture(43) { state, _ -> option(state, "version") }
|
||||
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
||||
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
||||
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
||||
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
|
||||
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
||||
@Test fun st049_watched() = capture(49) { state, _ -> state.restoreWatched(true); option(state, "watched") }
|
||||
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
||||
@Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") }
|
||||
@Test fun st052_filmCast() = capture(52) { state, _ -> state.focus("cast", 0) }
|
||||
@Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) }
|
||||
@Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) }
|
||||
@Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
|
||||
@Test fun st056_castUnavailable() = capture(56, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Unavailable) }) { state, _ -> state.focus("related", 0) }
|
||||
@Test fun st057_person() {
|
||||
val (person, credits) = fixture.person("Tom Holland", "justin")
|
||||
val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; enterDestination("person/Tom Holland") }
|
||||
val state = PersonState(person, credits, {})
|
||||
val data = TitlePageData(credits.first(), cast = listOf(person), related = credits)
|
||||
val art = decodeArt(data)
|
||||
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||
PersonSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||
} } }
|
||||
save(57)
|
||||
}
|
||||
@Test fun st058_hiddenReceipt() = capture(58, "The Amazing Spider-Man") { state, shell -> open(state, shell, "hide") }
|
||||
@Test fun st059_hiddenPick() = capture(59, "The Amazing Spider-Man", transform = { it.copy(hidden = true) }) { state, _ -> option(state, "unhide") }
|
||||
@Test fun st060_titleNav() = capture(60) { _, shell -> shell.focusNav(0) }
|
||||
@Test fun st061_loading() = capture(61, transform = { it.copy(status = TitleReadStatus.Loading) }) { _, shell -> shell.focusNav(0) }
|
||||
@Test fun st062_error() = capture(62, transform = { it.copy(status = TitleReadStatus.Unavailable) })
|
||||
@Test fun st063_signedOut() = capture(63, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.SignedOut) })
|
||||
@Test fun st064_seriesNext() = capture(64, "Andor")
|
||||
@Test fun st065_resumeEpisode() = capture(65, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||
@Test fun st066_notHeld() = capture(66, "The Amazing Spider-Man")
|
||||
@Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man")
|
||||
@Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") })
|
||||
@Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) })
|
||||
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.Offline) })
|
||||
@Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) }
|
||||
@Test fun st106_heldRatings() = capture(106, "American Gangster", "shared")
|
||||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||
}
|
||||
187
app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt
Normal file
187
app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.ShellAction
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleAction
|
||||
import org.justinvisser.wolfskin.design.TitleConnection
|
||||
import org.justinvisser.wolfskin.design.TitleReadStatus
|
||||
import org.justinvisser.wolfskin.design.TitleSheet
|
||||
import org.justinvisser.wolfskin.design.TitleState
|
||||
import org.justinvisser.wolfskin.design.TitleSurface
|
||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class TitleKeysTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var fixture: TitleFixture
|
||||
private val effects = mutableListOf<TitleAction>()
|
||||
private val shellEffects = mutableListOf<ShellAction>()
|
||||
private lateinit var state: TitleState
|
||||
private lateinit var shell: ShellState
|
||||
private lateinit var paths: List<JSONObject>
|
||||
|
||||
@Before fun fixtures() {
|
||||
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
val data = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
|
||||
paths = List(data.length()) { data.getJSONObject(it) }
|
||||
}
|
||||
|
||||
private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) {
|
||||
shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) }
|
||||
state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add)
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
||||
}
|
||||
|
||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||
private fun replay(end: String): Int {
|
||||
val path = paths.single { it.getString("end") == end }
|
||||
render(path.getString("title"))
|
||||
val keys = path.getJSONArray("keys")
|
||||
var presses = 0
|
||||
for (index in path.getInt("entryPresses") until keys.length()) {
|
||||
val key = when (keys.getString(index)) {
|
||||
"UP" -> Key.DirectionUp
|
||||
"DOWN" -> Key.DirectionDown
|
||||
"LEFT" -> Key.DirectionLeft
|
||||
"RIGHT" -> Key.DirectionRight
|
||||
"OK" -> Key.DirectionCenter
|
||||
else -> error("Unknown replay key")
|
||||
}
|
||||
press(key)
|
||||
presses++
|
||||
}
|
||||
return presses
|
||||
}
|
||||
|
||||
@Test fun titleToNavOnePressFromEntry() {
|
||||
assertEquals(1, replay("nav"))
|
||||
assertTrue(shell.navFocused)
|
||||
assertEquals(shell.currentTab, shell.navIndex)
|
||||
}
|
||||
|
||||
@Test fun switchPersonFivePressesFromTitleEntry() {
|
||||
assertEquals(5, replay("person-mindel"))
|
||||
assertEquals("mindel", shell.person.key)
|
||||
assertEquals("home", shell.route)
|
||||
assertFalse(shell.pickerVisible)
|
||||
assertTrue(shellEffects.last() is ShellAction.Person)
|
||||
}
|
||||
|
||||
@Test fun lockeSecondVersionSixPressesFromTitleEntry() {
|
||||
assertEquals(6, replay("play-version-2"))
|
||||
assertEquals("v2", state.versionId)
|
||||
assertNull(state.sheet)
|
||||
assertEquals("play", state.options[state.column].id)
|
||||
assertTrue(effects.last() is TitleAction.Play)
|
||||
assertEquals("Locke", (effects.last() as TitleAction.Play).item.name)
|
||||
}
|
||||
|
||||
@Test fun lockeCastTwoPressesFromTitleEntry() {
|
||||
assertEquals(2, replay("person-Tom Hardy"))
|
||||
assertEquals("cast", state.row)
|
||||
assertEquals("Tom Hardy", (effects.single() as TitleAction.Person).person.name)
|
||||
}
|
||||
|
||||
@Test fun sheetBackRestoresItsOpeningOptionAndChosenVersion() {
|
||||
render()
|
||||
press(Key.DirectionRight)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Version, state.sheet)
|
||||
press(Key.DirectionDown)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("v2", state.versionId)
|
||||
assertEquals("Version", state.options[state.column].label)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, state.sheetIndex)
|
||||
press(Key.Back)
|
||||
assertNull(state.sheet)
|
||||
assertEquals("Version", state.options[state.column].label)
|
||||
assertFalse(shell.navFocused)
|
||||
}
|
||||
|
||||
@Test fun trackChoiceStaysInSheetAndOffRemainsAnExplicitChoice() {
|
||||
render()
|
||||
press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Tracks, state.sheet)
|
||||
press(Key.DirectionDown); press(Key.DirectionCenter)
|
||||
assertEquals(1, state.audioIndex)
|
||||
assertEquals(TitleSheet.Tracks, state.sheet)
|
||||
press(Key.DirectionDown); press(Key.DirectionDown); press(Key.DirectionCenter)
|
||||
assertEquals(2, state.subtitleIndex)
|
||||
press(Key.DirectionUp); press(Key.DirectionCenter)
|
||||
assertEquals(-1, state.subtitleIndex)
|
||||
press(Key.Back)
|
||||
assertNull(state.sheet)
|
||||
assertEquals("tracks", state.options[state.column].id)
|
||||
}
|
||||
|
||||
@Test fun castLoadingAndUnavailableAreNeverFocusable() {
|
||||
render(transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("related", state.row)
|
||||
assertFalse(state.rows.contains("cast"))
|
||||
}
|
||||
|
||||
@Test fun wholeSeriesRequiresConfirmationButEpisodesDoNot() {
|
||||
render("Andor")
|
||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) }
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.ConfirmWatched, state.sheet)
|
||||
assertFalse(state.watched)
|
||||
press(Key.DirectionRight); press(Key.DirectionCenter)
|
||||
assertFalse(state.watched)
|
||||
assertTrue(effects.none { it is TitleAction.Watched })
|
||||
press(Key.DirectionCenter); press(Key.DirectionCenter)
|
||||
assertTrue(state.watched)
|
||||
assertTrue(effects.last() is TitleAction.Watched)
|
||||
}
|
||||
|
||||
@Test fun titlesNobodyHoldsHaveNoTrailerUntilTheCatalogueFieldExists() {
|
||||
render("The Amazing Spider-Man")
|
||||
assertTrue(state.options.none { it.id == "trailer" })
|
||||
assertEquals("Request", state.options.first().label)
|
||||
rule.runOnIdle { state.update(state.data.copy(connection = TitleConnection.SignedOut)) }
|
||||
assertEquals(listOf("retry"), state.options.map { it.id })
|
||||
}
|
||||
|
||||
@Test fun hiddenReceiptUndoRestoresTitleAndOptionInPlace() {
|
||||
render("The Amazing Spider-Man")
|
||||
press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter)
|
||||
assertTrue(state.data.hidden)
|
||||
assertEquals(TitleSheet.HiddenReceipt, state.sheet)
|
||||
press(Key.DirectionCenter)
|
||||
assertFalse(state.data.hidden)
|
||||
assertNull(state.sheet)
|
||||
assertEquals("hide", state.options[state.column].id)
|
||||
}
|
||||
|
||||
@Test fun heldOkCannotChooseTheVersionInTheSheetItOpened() {
|
||||
render()
|
||||
press(Key.DirectionRight)
|
||||
rule.runOnIdle {
|
||||
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
|
||||
repeat(5) { index -> rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, index + 1)) }
|
||||
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
|
||||
}
|
||||
assertEquals(TitleSheet.Version, state.sheet)
|
||||
assertTrue(effects.none { it is TitleAction.Version })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onLast
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleConnection
|
||||
import org.justinvisser.wolfskin.design.TitleItem
|
||||
import org.justinvisser.wolfskin.design.TitlePageData
|
||||
import org.justinvisser.wolfskin.design.TitleRating
|
||||
import org.justinvisser.wolfskin.design.TitleState
|
||||
import org.justinvisser.wolfskin.design.TitleSurface
|
||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.util.Locale
|
||||
|
||||
/** Native text layout proves locale formatting still fits the focused poster's one-line plate. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class TitleLocaleTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun render(locale: Locale, expected: String) {
|
||||
val previous = Locale.getDefault()
|
||||
try {
|
||||
Locale.setDefault(locale)
|
||||
val person = ShellPerson("justin", "Justin", "J", false)
|
||||
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(TitleRating("tmdb", 7.8, "tmdb")))
|
||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") }
|
||||
val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {}
|
||||
state.focus("related", 0)
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
rule.onAllNodesWithText(expected).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
|
||||
assertEquals(1, layouts.single().lineCount)
|
||||
assertFalse(layouts.single().didOverflowWidth)
|
||||
} finally { Locale.setDefault(previous) }
|
||||
}
|
||||
|
||||
@Test fun dutchScoreUsesCommaOnTheNativePlate() = render(Locale.forLanguageTag("nl-NL"), "7,8")
|
||||
@Test fun englishScoreUsesPointOnTheNativePlate() = render(Locale.US, "7.8")
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.TvShowsApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.TitleConnection
|
||||
import org.justinvisser.wolfskin.design.TitleState
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class TitleReadFacadeTest {
|
||||
@get:Rule val directory = TemporaryFolder()
|
||||
private val library = mockk<UserLibraryApi>()
|
||||
private val items = mockk<ItemsApi>()
|
||||
private val shows = mockk<TvShowsApi>()
|
||||
private val api = mockk<ApiClient>()
|
||||
private val playback = mockk<ItemPlaybackRepository>()
|
||||
private val repository = mockk<ServerRepository>()
|
||||
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
private lateinit var facade: TitleReadFacade
|
||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004")
|
||||
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString()
|
||||
|
||||
@Before fun setup() {
|
||||
every { api.userLibraryApi } returns library
|
||||
every { api.itemsApi } returns items
|
||||
every { api.tvShowsApi } returns shows
|
||||
every { repository.currentServer } returns null
|
||||
every { repository.currentUser } returns null
|
||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||
}
|
||||
|
||||
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto(
|
||||
id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000,
|
||||
mediaSources = listOf(MediaSourceInfo(id = sourceId, name = "Original", container = "mkv", mediaStreams = listOf(
|
||||
MediaStream(index = 0, type = MediaStreamType.VIDEO, displayTitle = "1080p"),
|
||||
MediaStream(index = 3, type = MediaStreamType.AUDIO, displayTitle = "English", isDefault = true),
|
||||
MediaStream(index = 8, type = MediaStreamType.SUBTITLE, displayTitle = "Dutch"),
|
||||
))),
|
||||
)
|
||||
|
||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
facade.read(firstId, null, preferences)
|
||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||
val saved = facade.readSaved(firstId, preferences)!!
|
||||
val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {}
|
||||
assertEquals("mkv".uppercase(), saved.versions.single().container)
|
||||
assertEquals(3, screen.audio?.index)
|
||||
assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index })
|
||||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||
assertTrue(saved.item.ratings.isEmpty())
|
||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||
}
|
||||
|
||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||
val userA = mockk<JellyfinUser> { every { id } returns firstId }
|
||||
val userB = mockk<JellyfinUser> { every { id } returns secondId }
|
||||
every { repository.currentUser } returns userA
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
facade.read(firstId, null, preferences)
|
||||
every { repository.currentUser } returns userB
|
||||
assertNull(facade.readSaved(firstId, preferences))
|
||||
every { repository.currentUser } returns userA
|
||||
assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name)
|
||||
}
|
||||
|
||||
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
|
||||
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
|
||||
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
|
||||
val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES)
|
||||
val secondSeason = BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 2)
|
||||
coEvery { library.getItem(firstId) } returns successResponse(episode)
|
||||
coEvery { library.getItem(seriesId) } returns successResponse(series)
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(secondSeason))
|
||||
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(listOf(other))
|
||||
val data = facade.read(firstId, 2, preferences)
|
||||
assertEquals(seriesId.toString(), data.item.id)
|
||||
assertEquals(firstId.toString(), data.playTarget?.id)
|
||||
assertEquals(firstId.toString(), data.watchTarget.id)
|
||||
assertEquals(listOf(secondId.toString()), data.episodes.map { it.id })
|
||||
assertEquals(listOf(2), data.seasonNumbers)
|
||||
}
|
||||
}
|
||||
|
|
@ -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