Integrate native Home and replay full title launch paths
This commit is contained in:
commit
fc02ce8646
697 changed files with 2956 additions and 146 deletions
|
|
@ -0,0 +1,29 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
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.annotation.Config
|
||||
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||
import org.justinvisser.wolfskin.design.HomeTitle
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
|
||||
/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class HomeLibraryCacheTest {
|
||||
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42)
|
||||
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
|
||||
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
|
||||
HomeLibraryCache(context).write("server:person", library, 12345L)
|
||||
val saved = HomeLibraryCache(context).read("server:person")!!
|
||||
assertEquals(library, saved.first)
|
||||
assertEquals(12345L, saved.second)
|
||||
assertNull(HomeLibraryCache(context).read("server:someone-else"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
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.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Home opens the complete shared grid and Back restores its original continuation card. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class HomeSeeAllNavigationTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
|
||||
assertEquals(18, grid.items.size)
|
||||
repeat(2) { press(Key.DirectionDown) }
|
||||
repeat(5) { press(Key.DirectionRight) }
|
||||
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
|
||||
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
|
||||
}
|
||||
|
||||
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(6, grid.index)
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("justin")
|
||||
val row = snapshot.rows.first { it.id == rowId }
|
||||
val cardTag = "home-card:$rowId:$expectedColumn"
|
||||
val navigation = NavigationManager()
|
||||
val lists = WolfskinSeeAllLists()
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
|
||||
if (it == ShellAction.Pop) navigation.goBack()
|
||||
}.apply {
|
||||
pickerVisible = false
|
||||
view.row = snapshot.rows.indexOf(row)
|
||||
view.home.focusedKey = row.id
|
||||
view.cards[row.id] = row.cards.size
|
||||
}
|
||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
|
||||
val facade = mockk<LibraryReadFacade>()
|
||||
rule.setContent {
|
||||
val destination = navigation.backStack.last()
|
||||
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
|
||||
LaunchedEffect(destination) { shell.enterDestination(route) }
|
||||
WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||
if (destination is Destination.Home) HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
|
||||
} }
|
||||
}
|
||||
settle()
|
||||
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||
val home = shell.view
|
||||
val track = home.home.tracks.getValue(rowId)
|
||||
val verticalIndex = home.listState.firstVisibleItemIndex
|
||||
val verticalOffset = home.listState.firstVisibleItemScrollOffset
|
||||
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
|
||||
val horizontalIndex = track.firstVisibleItemIndex
|
||||
val horizontalOffset = track.firstVisibleItemScrollOffset
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
|
||||
val grid = requireNotNull(lists.get("justin/$rowId"))
|
||||
try {
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
exerciseGrid(grid)
|
||||
press(Key.Back)
|
||||
rule.waitForIdle()
|
||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||
assertEquals(expectedColumn, shell.view.cards[row.id])
|
||||
assertSame("Home retains its original route memory", home, shell.view)
|
||||
assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
|
||||
assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
|
||||
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
|
||||
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
|
||||
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
|
||||
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
|
||||
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||
} catch (failure: AssertionError) {
|
||||
throw AssertionError(
|
||||
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
|
||||
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
|
||||
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
|
||||
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
|
||||
failure,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun press(key: Key) {
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
settle()
|
||||
}
|
||||
|
||||
private fun settle() {
|
||||
repeat(3) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotFocused
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
|
|
@ -131,6 +133,7 @@ class UpstreamContentFocusTest {
|
|||
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
|
||||
rule.mainClock.advanceTimeBy(1500)
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
|
||||
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
|
||||
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
|
||||
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue