Integrate native Home and replay full title launch paths

This commit is contained in:
justinzeus 2026-11-05 21:49:34 +01:00
commit fc02ce8646
697 changed files with 2956 additions and 146 deletions

View file

@ -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"))
}
}

View file

@ -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()
}
}

View file

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

View file

@ -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)